diff --git a/apps/appcard/src/lib.rs b/apps/appcard/src/lib.rs index 5801cadc..b0490bb7 100644 --- a/apps/appcard/src/lib.rs +++ b/apps/appcard/src/lib.rs @@ -68,6 +68,61 @@ pub fn activity_reporter() -> Option<&'static dyn ActivityReporter> { ACTIVITY_REPORTER.get().map(|r| r.as_ref()) } +/// Archive the hosted app's card approval store once per host build, the +/// way a deployment of the standalone APK does it. +/// +/// octos-app pins every admitted card to the runtime bundle it was admitted +/// under (`l0_approval_store::require` with `SPLASH_RUNTIME_BUNDLE`) and +/// fails closed on a stale receipt — a card then draws nothing. Archiving the +/// store is the host's EXPLICIT deployment action (octos-app's own +/// `MAKEPAD_REAPPROVE_CARDS` does exactly this rename at startup), so the +/// host does it itself when its build id changed: the store moves to +/// `l0-approvals-before-studio-` beside itself, the same name the +/// app uses, and the id is remembered in `/octosense-host-build` so +/// the next launch of the same build leaves the fresh receipts alone. +/// +/// `config` is octos-app's config directory (`$OCTOS_APP_CONFIG_DIR`, else +/// `$HOME/.config/octos-app`) — [`octos_app_config_dir`] finds it. Returns +/// what was archived: `None` when this build already did it, or there was +/// nothing to archive and the marker is now written. +pub fn reapprove_cards_for_host_build(config: &std::path::Path, build_id: &str) -> Result, String> { + if build_id.is_empty() || !build_id.bytes().all(|b| b.is_ascii_digit()) { + return Err(format!("host build id {build_id:?} is not digits")); + } + let marker = config.join("octosense-host-build"); + if std::fs::read_to_string(&marker).map(|s| s.trim() == build_id).unwrap_or(false) { + return Ok(None); + } + let store = config.join("l0-approvals"); + let backup = config.join(format!("l0-approvals-before-studio-{build_id}")); + let archived = if store.is_dir() && !backup.exists() { + std::fs::rename(&store, &backup).map_err(|e| format!("archive card receipts {}: {e}", store.display()))?; + Some(backup) + } else { + None + }; + std::fs::create_dir_all(config).map_err(|e| format!("create {}: {e}", config.display()))?; + std::fs::write(&marker, build_id).map_err(|e| format!("write {}: {e}", marker.display()))?; + Ok(archived) +} + +/// Where octos-app keeps its config — its own rule, restated: the +/// `OCTOS_APP_CONFIG_DIR` override, else `/.config/octos-app`, where +/// `home` is what the app itself makes `$HOME` at its startup: on a phone +/// the platform's data dir (`cx.get_data_dir()`, `` on Android) — +/// the host asks BEFORE the app has started, so `$HOME` alone would be +/// unset there — else the process's `$HOME`. +pub fn octos_app_config_dir(data_dir: Option) -> Option { + if let Some(dir) = std::env::var_os("OCTOS_APP_CONFIG_DIR").filter(|v| !v.is_empty()) { + return Some(std::path::PathBuf::from(dir)); + } + let home = match data_dir.filter(|d| !d.is_empty() && cfg!(any(target_os = "android", target_env = "ohos"))) { + Some(dir) => std::path::PathBuf::from(dir), + None => std::path::PathBuf::from(std::env::var_os("HOME")?), + }; + Some(home.join(".config").join("octos-app")) +} + /// The kernel turn in flight, as the app's chat state knows it: the /// prompt text that started it (empty when the app has no record of it), /// `None` when no turn is running. Set at submit — the `ask` tool and the @@ -239,4 +294,35 @@ mod tests { fn cx_storage_for_test(vm: &mut ScriptVm) -> makepad_widgets::makepad_platform::storage::StorageHandle { vm.cx_mut().storage("appcard.test") } + + /// A new host build archives the store once, under the app's own backup + /// name; the same build again leaves the fresh receipts alone; a bad id + /// is refused before anything moves. + #[test] + fn a_new_host_build_archives_the_card_approvals_once() { + let dir = std::env::temp_dir().join(format!("octosense-appcard-reapprove-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let store = dir.join("l0-approvals"); + std::fs::create_dir_all(&store).unwrap(); + std::fs::write(store.join("receipt.json"), "{}").unwrap(); + assert!(reapprove_cards_for_host_build(&dir, "abc").is_err(), "an id must be digits"); + assert!(store.is_dir(), "a refused id moves nothing"); + let backup = reapprove_cards_for_host_build(&dir, "1700000000").unwrap().expect("the first launch of a build archives"); + assert_eq!(backup, dir.join("l0-approvals-before-studio-1700000000")); + assert!(backup.join("receipt.json").is_file() && !store.exists(), "the receipts moved aside intact"); + // The app admits cards again into a fresh store... + std::fs::create_dir_all(&store).unwrap(); + std::fs::write(store.join("fresh.json"), "{}").unwrap(); + // ...which the same build never touches, however often it launches. + assert_eq!(reapprove_cards_for_host_build(&dir, "1700000000").unwrap(), None); + assert!(store.join("fresh.json").is_file()); + // The next build archives again, under its own name. + assert!(reapprove_cards_for_host_build(&dir, "1700000001").unwrap().is_some()); + assert!(!store.exists() && dir.join("l0-approvals-before-studio-1700000001").join("fresh.json").is_file()); + // No store at all: nothing to archive, but the build is remembered. + assert_eq!(reapprove_cards_for_host_build(&dir, "1700000002").unwrap(), None); + assert_eq!(std::fs::read_to_string(dir.join("octosense-host-build")).unwrap(), "1700000002"); + assert_eq!(reapprove_cards_for_host_build(&dir, "1700000002").unwrap(), None); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/build.rs b/build.rs index 80771394..92b45280 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,5 @@ -//! The one place the standalone-shell condition is written. +//! The one place the standalone-shell condition is written — and the host's +//! build id. //! //! `mobile_only` is set for Android builds (cargo-makepad passes no features, //! and a Cargo feature cannot be target-conditional) and for any build with @@ -11,5 +12,18 @@ fn main() { if feature || android { println!("cargo:rustc-cfg=mobile_only"); } + // The host's build id: the second this build was configured, as digits. + // A hosted AppCard pins its card approvals to the runtime it admitted + // them under; when the host is a NEW build the store is archived once + // for re-admission (`octosense_appcard::reapprove_cards_for_host_build`), + // which is why any source change has to yield a new id. + let build_id = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + println!("cargo:rustc-env=OCTOSENSE_BUILD_ID={build_id}"); println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=src"); + println!("cargo:rerun-if-changed=apps"); + println!("cargo:rerun-if-changed=Cargo.lock"); } diff --git a/docs/android-appcard-build.md b/docs/android-appcard-build.md index efd5298d..86b0bd76 100644 --- a/docs/android-appcard-build.md +++ b/docs/android-appcard-build.md @@ -115,6 +115,45 @@ On a desktop the app spawns a local kernel only when BOTH `OCTOS_APP_CORE_BIN` `OCTOS_HOME=`) are set; otherwise it uses the WebSocket transport / login screen, so a developer's own `octos serve` is never touched. +## Card approvals across builds + +octos-app pins every card it admits to the runtime bundle that admitted it +(`l0_approval_store::require` with `SPLASH_RUNTIME_BUNDLE`) and fails closed +on a stale receipt: after a new APK the card draws nothing until the store is +archived for re-admission — the standalone app's own deployment action +(`MAKEPAD_REAPPROVE_CARDS=` renames the store to +`l0-approvals-before-studio-`). OctoSense does this itself on +Android: `build.rs` stamps the build with `OCTOSENSE_BUILD_ID` (the epoch +second it was configured, renewed by any source change), and the first +launch of a new build archives +`/.config/octos-app/l0-approvals` under that name and remembers the id +in `octosense-host-build` next to it +(`octosense_appcard::reapprove_cards_for_host_build`). Nothing to do by hand +after `cargo makepad android run`; the manual equivalent, for an APK built +elsewhere, is + +```sh +"$ADB" shell "su -c 'mv /data/data/dev.makepad.octosense/files/.config/octos-app/l0-approvals \ + /data/data/dev.makepad.octosense/files/.config/octos-app/l0-approvals-before-studio-$(date +%s)'" +``` + +## The hosted module in the phone's captures + +The phone desk never draws a client directly: each tile is recorded into a +`WindowFrame` (a pass of its own) and the texture is presented in the +client's slot — the app viewport, a split pane, a Recents card, a home tile — +under the shell's overlays (island, groups, keyboard, shade). A hosted module +draws into that pass like a process's frames land in it, with one thing to +know: a pass has an overlay of its own, and `WindowFrame` scopes it +(`Overlay::begin_nested_for_pass`) for the whole recording. Everything the +app draws through `begin_overlay_*` — the AppCard kit's glass surfaces, a +popup, a modal — composites into the capture, last, and never into the WM +window's overlay. Before that scope existed those lists painted over the +shade and the home page and stayed on screen after the tile was gone. The +module's tile forwards a press, a new touch or a wheel step to the app only +when it begins inside the tile's rect; the shell's recognizer has already +declined that finger by the time the tile sees it. + ## Provisioning the LLM key The quoting-safe way is `scripts/provision-appcard-llm.sh `: it builds the JSON, ships it through both shells intact, restarts OctoSense with the extra, and waits for the app to log `provisioned LLM`. GLM keys come in two families: `zhipu` for a bigmodel.cn key (OpenAI-style endpoint) and `zai` for a z.ai key (Anthropic-style endpoint); using the wrong one yields HTTP 401 even with a valid key. diff --git a/src/dock_warp.rs b/src/dock_warp.rs index e6a8ada5..18bc3fb2 100644 --- a/src/dock_warp.rs +++ b/src/dock_warp.rs @@ -1,5 +1,6 @@ //! A whole-window texture pulled into its dock icon. No app layout or resize //! occurs during the warp; the same frozen surface is used for restoration. +use makepad_widgets::makepad_draw::overlay::{Overlay, OverlayScope}; use makepad_widgets::*; script_mod! { @@ -53,9 +54,22 @@ pub struct DrawDockWarp { y_flip: f32, } +/// A client's tile recorded into a texture of its own: the dock warp, the +/// desktop's rounded window surface, every phone capture. +/// +/// The frame is a PASS, and a pass has an overlay of its own. Everything a +/// hosted module draws through `begin_overlay_*` while the frame records — +/// the AppCard kit's glass surfaces, a popup, a modal — composites into THIS +/// texture, after the frame's body, and the window's overlay never sees it. +/// Without that scope (`Overlay::begin_nested_for_pass`) those lists landed +/// in the WM window's overlay: painted last of all, over the shell's shade, +/// keyboard and home page, unclipped, and kept on screen after the tile was +/// no longer drawn — the module "painting over the whole window". pub struct WindowFrame { pass: DrawPass, list: DrawList2d, + overlay: Overlay, + overlay_scope: Option, texture: Texture, _depth: Texture, frozen: bool, @@ -89,6 +103,8 @@ impl WindowFrame { Self { pass, list: DrawList2d::new(cx), + overlay: Overlay { draw_list: DrawList::new(cx) }, + overlay_scope: None, texture, _depth: depth, frozen: false, @@ -114,8 +130,15 @@ impl WindowFrame { cx.begin_pass(&self.pass, Some(dpi)); self.list.begin_always(cx); cx.begin_root_turtle(root_size, Layout::flow_overlay()); + // From here to `end`, overlays belong to this pass (see the type doc). + self.overlay_scope = Some(self.overlay.begin_nested_for_pass(cx, self.pass.draw_pass_id())); } pub fn end(&mut self, cx: &mut Cx2d) { + // The overlay composites into the frame's own list, last, and the + // enclosing frame's overlay state comes back exactly as it was. + if let Some(scope) = self.overlay_scope.take() { + self.overlay.end_nested(cx, scope); + } cx.end_pass_sized_turtle(); self.list.end(cx); cx.end_pass(&self.pass); diff --git a/src/main.rs b/src/main.rs index 78694ccc..6107923d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4046,6 +4046,7 @@ impl MatchEvent for App { self.set_desktop_style(cx, startup_style); } mobile_island::install_producers(); + self.reapprove_hosted_cards(cx); if cfg!(any(target_os = "ios", target_os = "android")) { self.update_bar_chrome(cx, &WindowGeom { safe_area_insets: cx.display_context.safe_area_insets, diff --git a/src/mobile_app.rs b/src/mobile_app.rs index a6c4e4a9..810e2a03 100644 --- a/src/mobile_app.rs +++ b/src/mobile_app.rs @@ -9,6 +9,27 @@ use makepad_widgets::widget_async::{enter_isolate, leave_isolate}; enum PhonePointerPhase { Down, Move, Up, Scroll } impl App { + /// A hosted AppCard's card approvals are pinned to the build that + /// admitted them; on the phone every new APK is a deployment, so the + /// host archives the store once per build id (its explicit action, see + /// `octosense_appcard::reapprove_cards_for_host_build`). Desktop builds + /// leave the developer's own store alone. + pub(super) fn reapprove_hosted_cards(&self, cx: &Cx) { + #[cfg(not(target_os = "android"))] + let _ = cx; + #[cfg(target_os = "android")] + { + let Some(config) = octosense_appcard::octos_app_config_dir(cx.get_data_dir()) else { + log!("wm: card approvals not archived: no data dir to find the store in"); + return; + }; + match octosense_appcard::reapprove_cards_for_host_build(&config, env!("OCTOSENSE_BUILD_ID")) { + Ok(Some(backup)) => log!("wm: host build {} is new: card approvals archived to {}", env!("OCTOSENSE_BUILD_ID"), backup.display()), + Ok(None) => {} + Err(e) => log!("wm: card approvals not archived: {e}"), + } + } + } pub(super) fn animate_phone(&mut self,cx:&mut Cx) { self.phone_frame=cx.new_next_frame(); self.redraw_all(cx); diff --git a/src/module_view.rs b/src/module_view.rs index 34de8c12..4d9147f1 100644 --- a/src/module_view.rs +++ b/src/module_view.rs @@ -155,6 +155,39 @@ impl TileHost for MpModuleView { } } +/// Where a pointer event starts relative to the tile's rect. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PointerStart { + /// A press, a new touch or a wheel step inside the tile. + Inside, + /// One that begins outside it: not this tile's event. + Outside, + /// Not a pointer start (a move, a release, a key, a frame...). + None, +} + +/// Classify `event` against the tile's rect (`None` while the tile has not +/// drawn yet: nothing is inside a rect that does not exist). Moves and +/// releases pass through: a finger the root captured inside keeps reaching +/// it wherever it goes, as for any widget. +fn pointer_start(event: &Event, rect: Option) -> PointerStart { + let abs = match event { + Event::MouseDown(e) => Some(e.abs), + Event::Scroll(e) => Some(e.abs), + Event::TouchUpdate(update) => match update.touches.iter() + .find(|point| point.state == makepad_platform::event::TouchState::Start) { + Some(point) => Some(point.abs), + None => return PointerStart::None, + }, + _ => None, + }; + match (abs, rect) { + (None, _) => PointerStart::None, + (Some(abs), Some(rect)) if rect.contains(abs) => PointerStart::Inside, + (Some(_), _) => PointerStart::Outside, + } +} + impl Widget for MpModuleView { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { let Some(root) = self.root.clone() else { @@ -165,21 +198,22 @@ impl Widget for MpModuleView { if matches!(event, Event::KeyDown(_) | Event::KeyUp(_) | Event::TextInput(_)) && !self.focused { return; } - let press = match event { - Event::MouseDown(e) => Some(e.abs), - Event::TouchUpdate(update) => update.touches.iter() - .find(|point| point.state == makepad_platform::event::TouchState::Start) - .map(|point| point.abs), - _ => None, - }; - if let Some(abs) = press { - if self.area.is_valid(cx) && self.area.rect(cx).contains(abs) { + // The root is a whole app: its widgets hit-test by their own areas, + // which cover exactly the tile, but a press, a touch or a wheel + // that begins OUTSIDE the tile is not this instance's to see — it + // is the shell's (a band, the shade, another tile). A process tile + // gets the same gate from `event.hits(cx, self.area)`. + let rect = self.area.is_valid(cx).then(|| self.area.rect(cx)); + match pointer_start(event, rect) { + PointerStart::Outside => return, + PointerStart::Inside => { if let Some(client) = self.client { // The WM moves focus here (and back to us through // `focus_keyboard`), exactly as for a process tile. cx.widget_action(self.uid, MpRunViewAction::Clicked { client }); } } + PointerStart::None => {} } let entry = enter_isolate(cx, self.vm_id); root.handle_event(cx, event, scope); @@ -209,3 +243,35 @@ impl Widget for MpModuleView { DrawStep::done() } } + +#[cfg(test)] +mod tests { + use super::*; + use makepad_platform::event::{TouchPoint, TouchState, TouchUpdateEvent}; + + fn tile() -> Option { + Some(Rect { pos: dvec2(0.0, 42.0), size: dvec2(412.0, 826.0) }) + } + fn touch(abs: Vec2d, state: TouchState) -> Event { + Event::TouchUpdate(TouchUpdateEvent { + time: 0.0, + window_id: WindowId(0, 0), + modifiers: Default::default(), + touches: vec![TouchPoint { state, abs, time: 0.0, uid: 1, rotation_angle: 0.0, force: 0.0, radius: dvec2(1.0, 1.0), handled: Default::default(), sweep_lock: Default::default() }], + }) + } + + /// A finger in the shell's status band or navigation band never starts + /// inside the tile; one on the app does; the rest of its stroke passes. + #[test] + fn only_pointer_starts_inside_the_tile_reach_the_root() { + assert_eq!(pointer_start(&touch(dvec2(200.0, 20.0), TouchState::Start), tile()), PointerStart::Outside, "status band"); + assert_eq!(pointer_start(&touch(dvec2(200.0, 880.0), TouchState::Start), tile()), PointerStart::Outside, "navigation band"); + assert_eq!(pointer_start(&touch(dvec2(200.0, 400.0), TouchState::Start), tile()), PointerStart::Inside); + assert_eq!(pointer_start(&touch(dvec2(200.0, 20.0), TouchState::Move), tile()), PointerStart::None, "a move passes wherever it is"); + assert_eq!(pointer_start(&touch(dvec2(200.0, 20.0), TouchState::Stop), tile()), PointerStart::None); + // Before the first draw there is no rect: nothing is inside it. + assert_eq!(pointer_start(&touch(dvec2(200.0, 400.0), TouchState::Start), None), PointerStart::Outside); + assert_eq!(pointer_start(&Event::Startup, tile()), PointerStart::None); + } +}