Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions crates/shell/src/approvals/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,17 @@ pub struct Approvals {
/// Decisions for the AI bus's held calls (`bus:` ids), drained by the
/// shell ([`take_bus_decisions`]).
bus: RecordingRelay,
/// Decisions for the peer link's calls (`peerlink:` ids), drained by
/// `peer_link` ([`take_peer_decisions`]).
peer: RecordingRelay,
external: Arc<Mutex<Option<Box<dyn ApprovalRelay>>>>,
}

/// Where the router's decisions go: the AI bus's own held calls to the
/// shell, the rest to the octos#2567 relay (queued until it is installed).
struct Dispatch {
bus: RecordingRelay,
peer: RecordingRelay,
queue: RecordingRelay,
external: Arc<Mutex<Option<Box<dyn ApprovalRelay>>>>,
}
Expand All @@ -79,6 +83,9 @@ impl ApprovalRelay for Dispatch {
if id.0.starts_with(crate::ai_bus::HELD_PREFIX) {
return self.bus.approval_decided(id, decision, reason);
}
if id.0.starts_with(crate::peer_link::link::HELD_PREFIX) {
return self.peer.approval_decided(id, decision, reason);
}
match self.external.lock().unwrap_or_else(|e| e.into_inner()).as_mut() {
Some(relay) => relay.approval_decided(id, decision, reason),
None => self.queue.approval_decided(id, decision, reason),
Expand All @@ -97,10 +104,11 @@ impl Approvals {
fn with_parts(rules: rules::RuleStore, audit: audit::AuditLog, consent: consent::ConsentStore, contacts: contacts::ContactsGate) -> Approvals {
let queue = RecordingRelay::default();
let bus = RecordingRelay::default();
let peer = RecordingRelay::default();
let external = Arc::new(Mutex::new(None));
let dispatch = Dispatch { bus: bus.clone(), queue: queue.clone(), external: external.clone() };
let dispatch = Dispatch { bus: bus.clone(), peer: peer.clone(), queue: queue.clone(), external: external.clone() };
let router = Router::new(rules, audit, Box::new(dev_hooks::ShellDevMode), contacts, Box::new(dispatch));
Approvals { router, consent, settings_open: false, queue, bus, external }
Approvals { router, consent, settings_open: false, queue, bus, peer, external }
}
/// Sheets, rules, consent and the page: one number for "redraw".
pub fn generation(&self) -> u64 {
Expand Down Expand Up @@ -174,6 +182,11 @@ pub fn take_bus_decisions() -> Vec<(RequestId, Decision, String)> {
with(|a| a.bus.take()).unwrap_or_default()
}

/// The peer link's decisions (`peer_link::link::HELD_PREFIX` ids).
pub fn take_peer_decisions() -> Vec<(RequestId, Decision, String)> {
with(|a| a.peer.take()).unwrap_or_default()
}

/// An app module registers its own confirmation sheet (`confirm: app`).
pub fn register_app_confirm(app: &str, handler: Box<dyn AppConfirm>) {
with(|a| a.router.register_app_confirm(app, handler));
Expand Down
104 changes: 102 additions & 2 deletions crates/shell/src/clients.rs
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,9 @@ pub struct ClientSlot {
/// viewport (a map that pans from the edge): the shell's back gesture
/// is not recognised over it. Off by default; an app opts in.
pub owns_edges: bool,
/// Its process died unexpectedly: the tile stays, closed, with a
/// Restart (ADR 0004 §2); nothing runs behind it any more.
pub stopped: bool,
}

impl ClientSlot {
Expand Down Expand Up @@ -582,6 +585,7 @@ impl ClientSlot {
linked_at: None,
closing: None,
pane: false,
stopped: false,
}
}
}
Expand Down Expand Up @@ -764,6 +768,14 @@ impl ClientSlot {
Some(message)
}

/// Whether an exit leaves this client's tile in place, closed with a
/// Restart: an unexpected death (`failure`) of a window the person
/// has. A warm instance, the AI pane, a Quick-Look preview and a
/// client being closed go away as before.
pub fn stops_in_place(&self, failure: bool) -> bool {
failure && self.ready && !self.warm && !self.pane && !self.is_preview && self.closing.is_none() && self.child.is_some()
}

pub fn display_title(&self) -> &str {
if self.title.is_empty() {
&self.app
Expand Down Expand Up @@ -921,6 +933,60 @@ pub fn launch_argv(
Ok((program, args))
}

/// The sandbox of a native app's process launch (`None` for a catalog app
/// that is not in `native-apps.json`). Its jail and secrets folders are
/// created first; its program and resources are the checkout, the target
/// dir and cargo's source cache (crate resources) or, installed, the
/// binary's own directory.
pub fn sandbox_policy(app: &AppDef, root: Option<&Path>, program: &Path, hub_port: u16) -> Option<crate::sandbox::Policy> {
let native = crate::native_apps::find(&app.id)?;
let layout = match crate::app_storage::host() {
Some(host) => host.layout().clone(),
None => crate::app_storage::Layout::platform(None).ok()?,
};
let paths = layout.app(native.id).ok()?;
for (base, dir) in [(layout.apps_root(), &paths.jail), (layout.secrets_root(), &paths.secrets)] {
if let Err(e) = crate::app_storage::ensure_private_dir(base, dir) {
makepad_widgets::log!("sandbox: {}: cannot prepare {}: {e}", native.id, dir.display());
}
}
let home = crate::sandbox::person_home().unwrap_or_else(|| PathBuf::from("/nonexistent"));
let mut roots: Vec<PathBuf> = Vec::new();
let manifest_root = app.manifest.as_ref().and_then(|m| Path::new(m).parent().map(Path::to_path_buf));
if let Some(checkout) = manifest_root.or_else(|| root.map(Path::to_path_buf)) {
roots.push(checkout.clone());
roots.push(app.target_dir.as_ref().map(PathBuf::from).or_else(|| std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from)).unwrap_or_else(|| checkout.join("target")));
let cargo_home = std::env::var_os("CARGO_HOME").map(PathBuf::from).unwrap_or_else(|| home.join(".cargo"));
roots.push(cargo_home.join("git"));
roots.push(cargo_home.join("registry"));
}
if let Some(dir) = program.parent() {
roots.push(dir.to_path_buf());
}
// The shell's own directory: an installed launch's sibling binary.
if let Some(dir) = std::env::current_exe().ok().and_then(|e| e.parent().map(Path::to_path_buf)) {
roots.push(dir);
}
roots.sort();
roots.dedup();
let mut policy = crate::sandbox::Policy::for_app(native, paths.jail, paths.secrets, &home, roots, hub_port);
// Cargo reads `.cargo/config.toml` from its working directory up (the
// manifest's directory, else the checkout): every `[env]` host variable
// it would hand the app is taken back out.
let cargo_cwd = app.manifest.as_ref().and_then(|m| Path::new(m).parent().map(Path::to_path_buf)).or_else(|| root.map(Path::to_path_buf));
if let Some(cwd) = cargo_cwd {
for dir in cwd.ancestors() {
let config = std::fs::read_to_string(dir.join(".cargo/config.toml")).unwrap_or_default();
for name in crate::sandbox::cargo_env_host_vars(&config) {
if !policy.cargo_env_unset.contains(&name) {
policy.cargo_env_unset.push(name);
}
}
}
}
Some(if crate::sandbox::narrowed_by_env(native.id) { policy.narrowed() } else { policy })
}

/// Spawn an app as a hub client.
pub fn spawn_client(
pool: &TaskPool,
Expand All @@ -944,8 +1010,16 @@ pub fn spawn_client(
let root = repo_root();
let via_cargo = !app.package.is_empty() && (app.manifest.is_some() || root.is_some());
let (program, args) = launch_argv(app, root.as_deref(), extra_args)?;
let mut cmd = Command::new(program);
cmd.args(&args);
// A native app runs under the OS sandbox its manifest entry builds
// (ADR 0004 §3, sandbox/); through cargo only the app is sandboxed
// where the platform lets the build stay outside.
let policy = sandbox_policy(app, root.as_deref(), &program, hub_port);
let (mut cmd, applied) = crate::sandbox::command(&program, &args, policy.as_ref(), via_cargo);
match &applied {
Some(crate::sandbox::Applied::Sandboxed(how)) => makepad_widgets::log!("sandbox: {how}"),
Some(crate::sandbox::Applied::Unavailable(why)) => makepad_widgets::log!("sandbox: UNSANDBOXED {why}"),
None => {}
}
// Give the process its own group (unix): `cargo run` does not
// exec-replace itself, so the compiled app (and, mid-build, rustc) are
// further children of the process we hold, not exec'd into it. Sharing
Expand Down Expand Up @@ -993,6 +1067,9 @@ pub fn spawn_client(
if warm {
cmd.env(WARM_ENV.0, WARM_ENV.1);
}
// No child inherits the kernel's descriptors or the host token: a
// process app reaches its agent only over the peer link (ADR 0004 §3).
crate::sandbox::scrub_env(&mut cmd);
let mut child = cmd
.spawn()
.map_err(|e| format!("spawn {}: {}", app.package, e))?;
Expand Down Expand Up @@ -1035,6 +1112,7 @@ pub fn spawn_client(
linked_at: None,
closing: None,
pane: false,
stopped: false,
})
}

Expand Down Expand Up @@ -1618,6 +1696,28 @@ mod tests {
"exiting successfully before creating a window is also a startup failure");
}

/// ADR 0004 §2: a process app that dies takes only itself down. Its
/// tile stays, closed with a Restart; a warm instance, the AI pane, a
/// preview and a client being closed are removed as before.
#[cfg(unix)]
#[test]
fn an_unexpected_death_keeps_the_tile_closed_with_restart() {
let mut slot = ClientSlot::module(9, "terminal", "Terminal");
slot.child = Some(std::process::Command::new("/usr/bin/true").spawn().unwrap());
assert!(slot.stops_in_place(true));
assert!(!slot.stops_in_place(false), "a clean exit closes the window");
slot.closing = Some(0.0);
assert!(!slot.stops_in_place(true), "the person closed it");
slot.closing = None;
for (warm, pane, preview, ready) in [(true, false, false, true), (false, true, false, true), (false, false, true, true), (false, false, false, false)] {
(slot.warm, slot.pane, slot.is_preview, slot.ready) = (warm, pane, preview, ready);
assert!(!slot.stops_in_place(true), "warm {warm} pane {pane} preview {preview} ready {ready}");
}
let _ = slot.child.take().map(|mut c| c.wait());
slot.ready = true;
assert!(!slot.stops_in_place(true), "a module slot has no process to stop");
}

#[cfg(unix)]
#[test]
fn requested_closes_warm_exits_and_normal_exits_do_not_notify() {
Expand Down
9 changes: 8 additions & 1 deletion crates/shell/src/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,14 @@ mod tests {

#[test]
fn the_desk_knows_where_it_runs() {
assert_eq!(processes_available(), cfg!(not(target_arch = "wasm32")));
// No processes on the web or on a phone (ADR 0004 §2): Android,
// iOS and OpenHarmony host their native apps in-process.
let mobile = cfg!(any(target_os = "android", target_os = "ios", target_env = "ohos"));
assert_eq!(cfg!(native_mobile), mobile, "native_mobile is exactly the phone targets");
assert_eq!(processes_available(), !cfg!(target_arch = "wasm32") && !mobile);
if cfg!(any(target_os = "macos", target_os = "windows", target_os = "linux")) && !mobile {
assert!(processes_available(), "a desktop hosts process apps");
}
assert!(now() >= 0.0);
}
}
Expand Down
60 changes: 56 additions & 4 deletions crates/shell/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ pub mod module_host;
mod module_panic_tests;
pub mod module_view;
pub mod native_apps;
pub mod sandbox;
pub mod pane_links;
pub mod peer_link;
pub mod preview;
pub mod run_view;
pub mod shell;
Expand Down Expand Up @@ -1541,8 +1543,9 @@ impl App {
fn request_close(&mut self, cx: &mut Cx, client: ClientId) {
// A module instance has no process to ask politely and nothing to
// reap later: it ends now, through the same removal as a death.
// So does one of its extra windows.
if self.module_host.is_module(client) || self.module_windows.contains_key(&client) {
// So does one of its extra windows, and a stopped process's tile.
let stopped = self.state.as_ref().and_then(|s| s.clients.get(&client)).is_some_and(|s| s.stopped);
if stopped || self.module_host.is_module(client) || self.module_windows.contains_key(&client) {
self.remove_client(cx, client);
self.update_bar(cx);
return;
Expand Down Expand Up @@ -1595,8 +1598,33 @@ impl App {
.unwrap_or(false)
}

/// A process app died unexpectedly: everything behind its tile goes
/// (the bus's registrations, its peer link's calls and contexts, the
/// process handle) and the tile stays, closed with a Restart.
fn process_stopped(&mut self, cx: &mut Cx, client: ClientId) {
let Some(slot) = self.state_mut().clients.get_mut(&client) else { return };
slot.stopped = true;
slot.sender = None;
slot.socket = None;
if let Some(mut child) = slot.child.take() {
let _ = child.wait();
}
let app = slot.app.clone();
let label = clients::find_app(&app).map(|a| a.label).unwrap_or_else(|| app.clone());
log!("wm: {app} (client {client}) stopped; its tile stays closed with Restart");
peer_link::process_gone(client);
if let Some(bye) = self.ai_bus.client_died(client) {
self.send_to_pane(bye);
}
self.pane_links.close_instance(client);
self.desk(cx).borrow_mut::<WmDesk>().map(|mut d| d.with_run_view(cx, client, |cx, v| v.show_stopped(cx, client, &label)));
self.update_bar(cx);
self.redraw_all(cx);
}

fn remove_client(&mut self, cx: &mut Cx, client: ClientId) {
log!("wm: removing client {}", client);
peer_link::process_gone(client);
// An instance's extra window: the tile lets go of the root (the
// instance keeps the widget), and the instance hears it was closed.
if let Some((owner, key)) = self.module_windows.remove(&client) {
Expand Down Expand Up @@ -1764,6 +1792,7 @@ impl App {
// Warm instances excluded: nobody is watching a tile that
// does not exist, and asking for one would build it.
!s.warm
&& !s.stopped
&& s.linked
&& s.sender.is_none()
&& s.linked_at.map(|t| host::now() - t > GRACE).unwrap_or(false)
Expand Down Expand Up @@ -1821,7 +1850,14 @@ impl App {
})
.collect();
for (id, failure) in dead {
self.remove_client(cx, id);
// ADR 0004 §2: a process app that dies takes only itself down;
// its tile stays, closed with a Restart.
let in_place = self.state_mut().clients.get(&id).is_some_and(|s| s.stops_in_place(failure.is_some()));
if in_place {
self.process_stopped(cx, id);
} else {
self.remove_client(cx, id);
}
if let Some(message) = failure {
log!("octosense: {message}");
self.notify(cx, "App stopped", &message);
Expand Down Expand Up @@ -2210,7 +2246,8 @@ impl App {
/// Restart on a failed module's tile: that window closes and the app
/// opens afresh, as a launch from the menu would open it.
fn restart_failed_module(&mut self, cx: &mut Cx, client: ClientId) {
if !self.module_host.is_failed(client) {
let stopped = self.state.as_ref().and_then(|s| s.clients.get(&client)).is_some_and(|s| s.stopped);
if !self.module_host.is_failed(client) && !stopped {
return;
}
let Some(app) = self.state.as_ref().and_then(|s| s.clients.get(&client)).map(|slot| slot.app.clone()) else {
Expand Down Expand Up @@ -2536,6 +2573,9 @@ impl App {
.to_string_lossy()
.to_string();
if let Some(slot) = self.state_mut().clients.get_mut(&client) {
// The peer link (ADR 0004 §5): the socket is this
// slot's app; granted apps get a link on it.
peer_link::connected(client, &slot.app, sender.clone());
slot.sender = Some(sender);
slot.socket = Some(socket);
if let Some(sender) = &slot.sender {
Expand Down Expand Up @@ -2567,6 +2607,7 @@ impl App {
.find(|(_, s)| s.socket == Some(socket))
.map(|(id, _)| *id);
if let Some(client) = client {
peer_link::process_gone(client);
if let Some(slot) = self.state_mut().clients.get_mut(&client) {
slot.sender = None;
slot.socket = None;
Expand Down Expand Up @@ -2694,6 +2735,16 @@ impl App {
self.sync_phone_keyboard(cx);
return;
}
// The peer link's own channel (ADR 0004 §5): never the
// bus's. Its app is the socket's slot, never the frame's.
if peer_link::wire::is_peer_frame(&json) {
let (app, sender) = match self.state_mut().clients.get(&client) {
Some(slot) => (slot.app.clone(), slot.sender.clone()),
None => return,
};
peer_link::on_frame(client, &app, &json, sender);
return;
}
// The typed app<->WM vocabulary (libs/wm_api) first; what
// is not the WM's own envelope is the AI bus's (or noise).
if let Some(req) = WmRequest::parse(&json) {
Expand Down Expand Up @@ -3121,6 +3172,7 @@ impl App {
/// must be told (every automatic approval among it).
fn approvals_tick(&mut self, cx: &mut Cx) {
approvals::tick();
peer_link::tick();
self.approvals_changed(cx);
}

Expand Down
Loading