Skip to content
Closed
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
86 changes: 86 additions & 0 deletions apps/appcard/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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-<build_id>` beside itself, the same name the
/// app uses, and the id is remembered in `<config>/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<Option<std::path::PathBuf>, 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 `<home>/.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()`, `<files>` 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<String>) -> Option<std::path::PathBuf> {
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
Expand Down Expand Up @@ -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);
}
}
16 changes: 15 additions & 1 deletion build.rs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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");
}
39 changes: 39 additions & 0 deletions docs/android-appcard-build.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,45 @@ On a desktop the app spawns a local kernel only when BOTH `OCTOS_APP_CORE_BIN`
`OCTOS_HOME=<dir>`) 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=<digits>` renames the store to
`l0-approvals-before-studio-<digits>`). 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
`<files>/.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 <family> <model> <key>`: 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.
Expand Down
23 changes: 23 additions & 0 deletions src/dock_warp.rs
Original file line number Diff line number Diff line change
@@ -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! {
Expand Down Expand Up @@ -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<OverlayScope>,
texture: Texture,
_depth: Texture,
frozen: bool,
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions src/mobile_app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
84 changes: 75 additions & 9 deletions src/module_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Rect>) -> 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 {
Expand All @@ -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);
Expand Down Expand Up @@ -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<Rect> {
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);
}
}