diff --git a/AGENTS.md b/AGENTS.md index 50e827c5e..812a8e5c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ On a machine that already has clones of Makepad, OctoScript or Octoscript-Makepa 4. **One kernel per process.** The kernel service starts octos on the first consumer and shares it; consumers never spawn their own. Apps reach the assistant only through `crates/app-peers`, never through the raw kernel protocol. 5. **System apps ship inside the shells.** Their ids start with `os.`, they are packed by digest from each packaging's `system-apps.json`, and they are not released separately or through the store. 6. **Test UI without taking over the screen.** Run hosted apps and shells with hidden windows and drive them over the local control surface: `MAKEPAD_HIDE_WINDOWS=1 MAKEPAD_REMOTE=` (routes under `/help`; end with `/quit`), or `makepad_test`. Use `--test-action`, `MAKEPAD_WM_TEST_APP` and `--module` to reach a state without clicking. -7. **Devices.** Use only a device you were given for the task. Install test builds under a separate package name; never replace the installed Home or flash an image unless asked. Anything not run on a device is **unverified**, and says so. +7. **Devices.** Use only a device you were given for the task. Install test builds under a separate package name; never replace the installed Home or flash an image unless asked. Anything not run on a device is **unverified**, and says so. Launch-time test hooks (the `test_actions` in the `makepad.APP_CONFIG` intent extra and the other `makepad.*` extras or OpenHarmony launch parameters) are the local user's own setting on a desktop, like a command-line flag, and read in every build there; on a phone (Android, OpenHarmony) any app can start Home with extras, so a release build ignores them and only a development build reads them (`cfg(dev_mode)`: debug, or `--features dev-mode`; `rom/scripts/build-home.py --dev-mode` builds one as a test package). Read such a hook only through `crates/shell/src/launch_input.rs` (AppCard: `launch_var`), never with a bare `std::env::var`. 8. **Commit with a public identity.** Author and committer are your GitHub noreply address (for the maintainer, `ymote <151983+ymote@users.noreply.github.com>`) or another address you mean to publish; never a work or machine-local address. Check `git config user.email` before the first commit in a clone: a repository-local identity on a shared machine leaked a work address into this project's history once, and removing it took a history rewrite. 9. **Keep docs honest and bilingual.** Every command in a doc was run; anything not run is marked **unverified**. User-facing docs are `README.md` plus `README.zh-CN.md`, linked by the single switcher line under the title (`English | [简体中文](README.zh-CN.md)`), and change together. `AGENTS.md` files are English only. 10. **Keep signing keys, keystores, tokens and personal paths out of the repository.** `rom/tests/test_no_local_paths.py` checks for local paths. diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 4d995e4ad..6dc737d23 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -30,7 +30,8 @@ app's own agent and `tools.json`, the system toolbox, `glance.publish` and takes `--static photos=`). `--system` lets an `os.*` id and an empty digest through, as the shell does. These flags are on App Hub `main`. `card-host` registers no host services: run Mail in a shell with - `MAKEPAD_APP_CONFIG='{"mail_demo":true}'`. + `MAKEPAD_APP_CONFIG='{"mail_demo":true}'` (a desktop reads it; on a phone it + needs a development build). - Validate on a phone through Home (`phone/`) built as a separate test package; never replace the device's installed Home. - Text on a page follows the host's appearance. On a phone, a page drawn by a diff --git a/apps/README.md b/apps/README.md index 98cb27d3f..7723a09ed 100644 --- a/apps/README.md +++ b/apps/README.md @@ -285,7 +285,7 @@ standalone launcher and ROM image). Each packaging: [`crates/shell`](../crates/shell) (its `app-hub` feature; the photo engine's `octosense-photo-service` comes with the desktop-only `craft-engines`, weighed per engine in [ADR 0013](../docs/adr/0013-craft-engines-as-pinned-services.md)), and registers them at startup (`crates/shell/src/apps.rs`, `register_host_services`): Mail with `register()` for real accounts, or `register_demo()` - when the shell's app config has `mail_demo: true`. Mail, News and, with the photo + when the shell's app config has `mail_demo: true` (a desktop reads it; a phone only in a development build). Mail, News and, with the photo engine, Photos install `on_notify` callbacks to the shell's common notice renderer; Calendar installs its card publisher. The shell registers `NoticeService` for remaining system-app namespaces; `llm` with the octos @@ -460,7 +460,10 @@ MAKEPAD_APP_CONFIG='{"mail_demo":true}' cargo run --release -p octosense MAKEPAD_APP_CONFIG='{"mail_demo":true}' cargo run --release -p octosense-home --features mobile-only ``` -The demo keeps its password in a file, so no keychain prompt appears. +The demo keeps its password in a file, so no keychain prompt appears. These are +desktop builds, so they read `MAKEPAD_APP_CONFIG` as a local command-line +setting; on a real phone the demo needs a development test build +(`rom/scripts/build-home.py --dev-mode`). **Quick Deck's dev fixture.** `card-host` has no `deck` service, and the shipped bundle has no fixture code. Quick Deck's UI tests use diff --git a/apps/README.zh-CN.md b/apps/README.zh-CN.md index 2417fc156..b9b1ef556 100644 --- a/apps/README.zh-CN.md +++ b/apps/README.zh-CN.md @@ -222,7 +222,7 @@ WebView 页面、图库导出、邮件、新闻、Glance 或单次模型调用 path 依赖;photo 引擎的 `octosense-photo-service` 随仅限桌面的 `craft-engines` 引入, 按引擎权衡见 [ADR 0013](../docs/adr/0013-craft-engines-as-pinned-services.zh-CN.md)),并在启动时注册 (`crates/shell/src/apps.rs` 的 `register_host_services`):Mail 服务在真实账户下用 - `register()`,Shell 的应用配置中 `mail_demo: true` 时用 `register_demo()`。 + `register()`,Shell 的应用配置中 `mail_demo: true` 时(桌面端读取;手机端仅限开发版)用 `register_demo()`。 Mail、News 以及(带 photo 引擎时)Photos 安装 `on_notify` 回调,调用 Shell 共用通知渲染器;Calendar 安装卡片发布器。 Shell 为其余系统应用命名空间注册 `NoticeService`;`llm` 服务使用 octos 内核的 core 目录以及 Shell 的二维码扫描器和图片选择器(见 [`llm` 服务](#llm-服务))。App Hub 只在根目录 @@ -379,7 +379,7 @@ MAKEPAD_APP_CONFIG='{"mail_demo":true}' cargo run --release -p octosense MAKEPAD_APP_CONFIG='{"mail_demo":true}' cargo run --release -p octosense-home --features mobile-only ``` -演示邮箱的密码存放在文件中,因此不会弹出钥匙串提示。 +演示邮箱的密码存放在文件中,因此不会弹出钥匙串提示。这些都是桌面构建,会把 `MAKEPAD_APP_CONFIG` 当作本机命令行设置读取;在真实手机上,演示邮箱需要开发版测试构建(`rom/scripts/build-home.py --dev-mode`)。 **Quick Deck 的开发夹具。** `card-host` 没有 `deck` 服务,而随 Shell 发布的 bundle 中没有任何夹具代码。Quick Deck 的 UI 测试使用 bundle 之外的 diff --git a/apps/appcard/app/app/Cargo.toml b/apps/appcard/app/app/Cargo.toml index e19eb19ad..8eb3a8b69 100644 --- a/apps/appcard/app/app/Cargo.toml +++ b/apps/appcard/app/app/Cargo.toml @@ -28,6 +28,13 @@ default = ["standalone"] # dev monitor. A host that mounts the app as a widget (`octos_app::AppShell`) # builds with `default-features = false` and brings its own. standalone = [] +# A development build (build.rs sets `dev_mode`; debug builds are development +# builds without it). Only a development build reads the launch-time hooks: +# the `MAKEPAD_*` settings an Android intent extra or OpenHarmony launch +# parameter can carry (provisioning, seeding, auto prompt, fake GPS, proxy, +# dumps; `launch_var` in src/lib.rs). The shell's `dev-mode` turns it on for +# the hosted app. +dev-mode = ["octosense-kernel/dev-mode"] [dependencies] # One framework source, locked by Octoscript-Makepad/runtime.json. diff --git a/apps/appcard/app/app/build.rs b/apps/appcard/app/app/build.rs index 93a7bdb79..bdaa66a22 100644 --- a/apps/appcard/app/app/build.rs +++ b/apps/appcard/app/app/build.rs @@ -22,6 +22,14 @@ fn main() { { println!("cargo:rustc-cfg=mobile"); } + // `dev_mode` — a development build, as the shell's build.rs decides it: + // debug assertions, or feature `dev-mode`. Only then does the app read + // its launch-time `MAKEPAD_*` hooks (`launch_var` in src/lib.rs): on a + // phone another app can set them when it starts this one. + println!("cargo:rustc-check-cfg=cfg(dev_mode)"); + if env::var_os("CARGO_FEATURE_DEV_MODE").is_some() || env::var_os("CARGO_CFG_DEBUG_ASSERTIONS").is_some() { + println!("cargo:rustc-cfg=dev_mode"); + } } // Pin source contents, resources and lockfiles, including local path patches. diff --git a/apps/appcard/app/app/src/app/l0_card.rs b/apps/appcard/app/app/src/app/l0_card.rs index c7f67de1e..d8af3fc48 100644 --- a/apps/appcard/app/app/src/app/l0_card.rs +++ b/apps/appcard/app/app/src/app/l0_card.rs @@ -319,8 +319,7 @@ fn kit_for(source: &str) -> String { // Measurement only. Nothing sets this in normal operation, and an unreadable // path is ignored rather than fatal — a missing override should render the // shipped look, not a blank screen. - let axis = std::env::var("MAKEPAD_L0_PALETTE_OVERRIDE") - .ok() + let axis = crate::launch_var("MAKEPAD_L0_PALETTE_OVERRIDE") .and_then(|p| std::fs::read_to_string(p).ok()) .unwrap_or_default(); // Axes BEFORE the env override, so a measurement run still wins over what @@ -1856,7 +1855,7 @@ fn render_capturing( // was chased through six layers on the evidence of a screenshot, which can // only ever say "not there" and never says which layer dropped it. Every // other layer already has a probe; this was the gap. - if let Ok(path) = std::env::var("MAKEPAD_DUMP_DSL") { + if let Some(path) = crate::launch_var("MAKEPAD_DUMP_DSL") { let _ = std::fs::write(&path, &dsl); } let captured = LAST_CAPTURED diff --git a/apps/appcard/app/app/src/lib.rs b/apps/appcard/app/app/src/lib.rs index 3bbca2521..7c0492f8a 100644 --- a/apps/appcard/app/app/src/lib.rs +++ b/apps/appcard/app/app/src/lib.rs @@ -30,6 +30,82 @@ use streaming_markdown_kit::{ use crate::backend::OctosUiAgent; +/// A launch-time `MAKEPAD_*` hook: the test, provisioning, seeding and +/// measurement settings this app reads from its environment. On Android +/// Makepad copies the launch extras `makepad.` into `MAKEPAD_`, +/// and OpenHarmony entry abilities pass `makepad.*` launch parameters the +/// same way, so on a phone another app can set them by starting this one. +/// On a phone (`cfg(mobile)`: Android or OpenHarmony) only a development +/// build reads them (`dev_mode`: debug assertions or feature `dev-mode`, +/// build.rs); a release build behaves as if none were set. A desktop reads +/// them, because its environment is the local user's, like a command-line +/// flag. Read every `MAKEPAD_*` hook through this, never `std::env::var`. +pub(crate) fn launch_var(name: &str) -> Option { + launch_set_in(cfg!(mobile), cfg!(dev_mode), name).then(|| std::env::var(name).ok()).flatten() +} + +/// [`launch_var`] for a variable whose presence is the setting. +pub(crate) fn launch_flag(name: &str) -> bool { + launch_set_in(cfg!(mobile), cfg!(dev_mode), name) +} + +/// Whether `name` is set and this build may read it. A phone release build +/// notes once that it ignored a launch-time hook, without its value. +fn launch_set_in(mobile: bool, development: bool, name: &str) -> bool { + if std::env::var_os(name).is_none() { + return false; + } + if mobile && !development { + static NOTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + if !NOTED.swap(true, std::sync::atomic::Ordering::Relaxed) { + log::info!("launch input: {name} ignored; a release build on a phone reads no launch-time hooks"); + } + return false; + } + true +} + +#[cfg(test)] +mod launch_input_tests { + use super::launch_set_in; + + #[test] + fn only_a_phone_release_build_ignores_a_launch_hook() { + // A name no other test sets. + let name = "MAKEPAD_LAUNCH_INPUT_TEST_PROBE"; + std::env::set_var(name, "/sdcard/Download/provision"); + assert!(!launch_set_in(true, false, name), "a phone release build ignores it"); + assert!(launch_set_in(true, true, name), "a phone development build reads it"); + assert!(launch_set_in(false, false, name), "a desktop release build reads it"); + assert!(launch_set_in(false, true, name), "a desktop development build reads it"); + std::env::remove_var(name); + assert!(!launch_set_in(false, false, name), "unset is never read"); + } + + /// Every `MAKEPAD_*` hook goes through `launch_var`/`launch_flag`, so + /// none bypasses the build check. + #[test] + fn no_source_reads_a_makepad_hook_directly() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut offenders = Vec::new(); + let mut stack = vec![root.clone()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).expect("read src") { + let path = entry.expect("entry").path(); + if path.is_dir() { + stack.push(path); + } else if path.extension().is_some_and(|e| e == "rs") { + let text = std::fs::read_to_string(&path).expect("read source"); + if text.contains("env::var(\"MAKEPAD_") || text.contains("env::var_os(\"MAKEPAD_") { + offenders.push(path.strip_prefix(&root).unwrap().display().to_string()); + } + } + } + } + assert!(offenders.is_empty(), "read MAKEPAD_* hooks through launch_var: {offenders:?}"); + } +} + /// Octos profiles supply system prompts server-side, so the client ships an /// empty placeholder. Replaces aichat's `BackendType::system_prompt` (and the /// `splash.md` `include_str!`) which baked huge LLM-shaped diagram preambles @@ -458,7 +534,7 @@ fn refresh_youtube_live_ids() { }; rt.block_on(async { let mut builder = reqwest::Client::builder(); - if let Ok(proxy) = std::env::var("MAKEPAD_OCTOS_PROXY") { + if let Some(proxy) = crate::launch_var("MAKEPAD_OCTOS_PROXY") { let proxy = proxy.trim().to_owned(); if !proxy.is_empty() { if let Ok(p) = reqwest::Proxy::all(&proxy) { @@ -1485,8 +1561,7 @@ fn pin_fullbleed_root_height(body: &str) -> String { let attrs = &body[root_open + 1..attr_end]; // The seed-render harness treats the card as THE SCREEN: a Fill root // means "the design frame", not the chat feed's tall fullbleed canvas. - let pin_h = std::env::var("MAKEPAD_SEED_L0_FILL_HEIGHT") - .ok() + let pin_h = crate::launch_var("MAKEPAD_SEED_L0_FILL_HEIGHT") .and_then(|v| v.parse::().ok()) .unwrap_or(FULLBLEED_FALLBACK_HEIGHT); let fixed = format!("height: {pin_h}"); @@ -5606,7 +5681,7 @@ impl Widget for ChatList { } item_widget.draw_all_unscoped(cx); // LOCAL DEBUG: where did this item actually land? - if std::env::var_os("MAKEPAD_GL_DRAW_TRACE").is_some() { + if crate::launch_flag("MAKEPAD_GL_DRAW_TRACE") { let r = item_widget.area().rect(cx); log::info!( "[CHATLIST] item {item_id} rect pos=({:.1},{:.1}) size=({:.1},{:.1})", @@ -6194,7 +6269,7 @@ impl App { /// silent in M1. W04 follow-up #5 — `/api/version` probe runs /// off-thread so we don't stall the caller. fn connect_transport(&mut self, cx: &mut Cx) { - if std::env::var_os("MAKEPAD_L0_REVIEW").is_some() { return; } + if crate::launch_flag("MAKEPAD_L0_REVIEW") { return; } let transport_config = Self::placeholder_transport_config(); log::info!( "connect transport: base_url={} profile_id={}", @@ -6696,20 +6771,20 @@ impl App { // that embeds this app as a library (`default-features = false`, // no `standalone`) gets neither — nor does a standalone build // launched with `OCTOS_APP_DEVGOAL=0`. An explicit - // `MAKEPAD_DEV_GOAL_FILE` still runs anywhere. + // `MAKEPAD_DEV_GOAL_FILE` still runs anywhere (`launch_var`: on a + // phone a development build, a desktop always). #[cfg(target_os = "android")] - let mission: Option<(String, String)> = match std::env::var("MAKEPAD_DEV_GOAL_FILE") { - Ok(p) => std::fs::read_to_string(&p).ok().map(|g| (g, p)), - Err(_) if Self::bundled_mission_enabled() => { + let mission: Option<(String, String)> = match crate::launch_var("MAKEPAD_DEV_GOAL_FILE") { + Some(p) => std::fs::read_to_string(&p).ok().map(|g| (g, p)), + None if Self::bundled_mission_enabled() => { let p = "/data/data/dev.makepad.octos_app/files/dev_goal.txt".to_owned(); let _ = std::fs::write(&p, BUNDLED_MISSION); Some((BUNDLED_MISSION.to_owned(), p)) } - Err(_) => None, + None => None, }; #[cfg(not(target_os = "android"))] - let mission: Option<(String, String)> = std::env::var("MAKEPAD_DEV_GOAL_FILE") - .ok() + let mission: Option<(String, String)> = crate::launch_var("MAKEPAD_DEV_GOAL_FILE") .and_then(|p| std::fs::read_to_string(&p).ok().map(|g| (g, p))); if let Some((goal, goal_path)) = mission { { @@ -6993,9 +7068,10 @@ impl App { /// live LLM generation can be driven without touching the native composer via /// adb input. Fires once (clears the env var). Session/open and turn/start /// are ordered on the stdio pipe, so submitting right after `clear_chat` is - /// safe. + /// safe. On a phone a development build only; a desktop reads it + /// (`launch_var`). fn fire_auto_prompt(&mut self, cx: &mut Cx) { - if let Ok(p) = std::env::var("MAKEPAD_AUTO_PROMPT") { + if let Some(p) = crate::launch_var("MAKEPAD_AUTO_PROMPT") { std::env::remove_var("MAKEPAD_AUTO_PROMPT"); let p = p.trim().to_string(); if !p.is_empty() { @@ -8279,7 +8355,7 @@ impl MatchEvent for App { target_arch = "wasm32" )))] { - std::env::var_os("MAKEPAD_MOBILE_PREVIEW").is_some() + crate::launch_flag("MAKEPAD_MOBILE_PREVIEW") || modifiers.logo || modifiers.control } }; @@ -8974,13 +9050,19 @@ impl MatchEvent for App { } } + // On a phone the launch-time provisioning below is for development + // builds only (`launch_var`): any app can start this one with these + // extras, so a phone release build provisions only through the + // person's own QR scan and PIN, or the login screen. A desktop reads + // them, the way it reads a command-line flag. + // // Provisioning deploy (non-rooted devices): `makepad.PROVISION_DIR` // (→ env MAKEPAD_PROVISION_DIR) names a world-readable staging dir // (`adb push …/octos-provision`) whose tree is copied into the app's // octos-home BEFORE octos spawns — deploying the GLM profile + a2app // memory tree onto a device that can't be written via su/run-as. #[cfg(target_os = "android")] - if let Ok(src) = std::env::var("MAKEPAD_PROVISION_DIR") { + if let Some(src) = crate::launch_var("MAKEPAD_PROVISION_DIR") { let home = kernel_home(); match deploy_provision(std::path::Path::new(&src), &home) { Ok(n) => log::info!("provision: deployed {n} files from {src}"), @@ -8995,7 +9077,7 @@ impl MatchEvent for App { // BEFORE the boot-auth decision, so a provisioned device lands // straight on the home shell — no LoginScreen typing. A QR-scan // onboarding can feed the same `apply_provision_string` entry later. - if let Ok(prov) = std::env::var("MAKEPAD_APP_CONFIG") { + if let Some(prov) = crate::launch_var("MAKEPAD_APP_CONFIG") { match crate::app::login::apply_provision_string(&prov) { Ok(()) => log::info!("provisioned from launch intent"), Err(e) => log::warn!("provisioning failed: {e}"), @@ -9005,7 +9087,7 @@ impl MatchEvent for App { // payload `{"llm_family":..,"llm_model":..,"llm_key":..}`, the same content // the composer's QR scan yields) writes the provider + key into the octos // profile config BEFORE the kernel spawns below, so the first turn uses it. - if let Ok(cfg) = std::env::var("MAKEPAD_PROVISION_CONFIG") { + if let Some(cfg) = crate::launch_var("MAKEPAD_PROVISION_CONFIG") { match crate::app::login::apply_provision_config_json(&cfg) { Ok(what) => log::info!("provisioned LLM from intent: {what}"), Err(e) => log::warn!("LLM provisioning failed: {e}"), @@ -9017,7 +9099,7 @@ impl MatchEvent for App { // A deployment may explicitly request re-admission after changing the // runtime bundle. Preserve previous receipts before opening any cards. // A stale receipt still fails closed during normal app launches. - if let Ok(migration) = std::env::var("MAKEPAD_REAPPROVE_CARDS") { + if let Some(migration) = crate::launch_var("MAKEPAD_REAPPROVE_CARDS") { std::env::remove_var("MAKEPAD_REAPPROVE_CARDS"); let result = crate::app::login::config_dir() .ok_or_else(|| "card approval directory unavailable".to_owned()) @@ -9076,7 +9158,7 @@ impl MatchEvent for App { // W04 / M2 — make sure the chat_screen / content_screen pair // matches the boot navigation state (defaults to Home → Chat). self.show_screen_for_nav(cx); - if std::env::var_os("MAKEPAD_L0_REVIEW").is_some() { + if crate::launch_flag("MAKEPAD_L0_REVIEW") { // Offline native review uses only the seeded ledger below. } else if authed { // Open the first session immediately so the composer is live. @@ -9090,7 +9172,7 @@ impl MatchEvent for App { // generation. `--es makepad.SEED_CARD_FILE ` surfaces as // MAKEPAD_SEED_CARD_FILE. Push AFTER the boot decision above (clear_chat // wipes CHAT_DATA), then refresh the empty-state + redraw so it shows. - if let Ok(path) = std::env::var("MAKEPAD_SEED_CARD_FILE") { + if let Some(path) = crate::launch_var("MAKEPAD_SEED_CARD_FILE") { match std::fs::read_to_string(&path) { Ok(body) => { let body_trim = body.trim(); @@ -9123,8 +9205,8 @@ impl MatchEvent for App { // source and can re-realize it when a tap arrives. SEED_CARD_FILE above // pushes already-lowered DSL, which is inert by construction: nothing on // device knows what card produced it or what a tap would mean. - if let Ok(card_path) = std::env::var("MAKEPAD_SEED_L0_FILE") { - let data_path = std::env::var("MAKEPAD_SEED_L0_DATA").unwrap_or_default(); + if let Some(card_path) = crate::launch_var("MAKEPAD_SEED_L0_FILE") { + let data_path = crate::launch_var("MAKEPAD_SEED_L0_DATA").unwrap_or_default(); let builtin = card_path.strip_prefix("builtin:"); let card = if let Some(name) = builtin { bundled_l0_source(name) @@ -9171,9 +9253,9 @@ impl MatchEvent for App { // passed these all along and nothing read them, so // every "detail view" capture was silently a capture // of the list. - if let Ok(event) = std::env::var("MAKEPAD_SEED_L0_EVENT") { + if let Some(event) = crate::launch_var("MAKEPAD_SEED_L0_EVENT") { let value = - std::env::var("MAKEPAD_SEED_L0_VALUE").unwrap_or_default(); + crate::launch_var("MAKEPAD_SEED_L0_VALUE").unwrap_or_default(); match app::l0_card::tap(cx, item, "root", &event, &value) { Ok(Some(_)) => { log::info!("SEED_L0 event {event}({value}) applied") @@ -9258,7 +9340,7 @@ impl MatchEvent for App { // by the path a real one does and bumps the same epoch. Nothing in the // language, the kit or the widgets knows the difference — which is the // point: it tests the real chain rather than a parallel one. - if let Some(track_path) = std::env::var("MAKEPAD_FAKE_GPS_FILE").ok().filter(|p| !p.is_empty()) { + if let Some(track_path) = crate::launch_var("MAKEPAD_FAKE_GPS_FILE").filter(|p| !p.is_empty()) { match std::fs::read_to_string(&track_path) { Ok(text) => { let track: Vec<(f64, f64)> = text @@ -9268,8 +9350,7 @@ impl MatchEvent for App { Some((a.trim().parse().ok()?, b.trim().parse().ok()?)) }) .collect(); - let step_ms: u64 = std::env::var("MAKEPAD_FAKE_GPS_MS") - .ok() + let step_ms: u64 = crate::launch_var("MAKEPAD_FAKE_GPS_MS") .and_then(|v| v.parse().ok()) .unwrap_or(1000); log::info!( @@ -9671,7 +9752,7 @@ impl AppMain for App { // phone's shape. Layout is the thing under test, and a card // laid out at 900x700 is a different card — the desktop // default is landscape and the device is not. - if let Ok(spec) = std::env::var("MAKEPAD_WINDOW_SIZE") { + if let Some(spec) = crate::launch_var("MAKEPAD_WINDOW_SIZE") { if let Some((w, h)) = spec.split_once(['x', 'X']) { if let (Ok(w), Ok(h)) = (w.trim().parse::(), h.trim().parse::()) { self.ui.window(cx, ids!(main_window)).resize(cx, dvec2(w, h)); @@ -9707,12 +9788,11 @@ impl AppMain for App { // frames to let the card settle first; sources land asynchronously and // a dump taken too early measures the pending state. if let Event::Draw(_) = event { - if let Ok(path) = std::env::var("MAKEPAD_DUMP_GEOMETRY") { + if let Some(path) = crate::launch_var("MAKEPAD_DUMP_GEOMETRY") { static DRAWS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); let n = DRAWS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; - let wait: u32 = std::env::var("MAKEPAD_DUMP_GEOMETRY_FRAMES") - .ok() + let wait: u32 = crate::launch_var("MAKEPAD_DUMP_GEOMETRY_FRAMES") .and_then(|v| v.parse().ok()) .unwrap_or(90); if n >= wait { diff --git a/apps/appcard/docs/BUILDING-ANDROID.md b/apps/appcard/docs/BUILDING-ANDROID.md index b249fffec..bbd20ac6f 100644 --- a/apps/appcard/docs/BUILDING-ANDROID.md +++ b/apps/appcard/docs/BUILDING-ANDROID.md @@ -64,8 +64,14 @@ Then, if you don't already have the NDK, `cargo makepad android install-toolchai tools/build-android.sh # APK only tools/build-android.sh run # APK, install, launch, stream logcat SKIP_KERNEL=1 tools/build-android.sh # reuse the kernel already built +DEV_MODE=1 tools/build-android.sh # a development build (launch-time extras) ``` +Only a development build (`DEV_MODE=1`, i.e. `--features dev-mode`, or a debug +build) reads the launch-time `makepad.*` extras in step 5: any app on the phone +can start this one with extras, so a release build ignores them (`launch_var` +in `app/app/src/lib.rs`). The `DEV_MODE=1` line is **unverified**. + That script is the supported path and it does both halves: it cross-compiles the `octos` submodule for `aarch64-linux-android` and hands the binary to `cargo makepad` as `liboctos.so`. It discovers the NDK under @@ -114,7 +120,7 @@ cargo makepad android build -p octos-app --release ADB=adb # or /mnt/c/.../adb.exe on WSL $ADB install -r app/target/android/makepad-android-apk/octos_app/apk/octos_app.apk -# launch. Extras (all optional): +# launch. Extras (all optional; read only by a development build, DEV_MODE=1): # makepad.OCTOS_PROXY http proxy for the LLM + data fetches (phones w/o direct net) # makepad.APP_CONFIG 'base_url|profile|token' (headless auth provisioning) # makepad.AUTO_PROMPT auto-submit one prompt on boot (for testing) @@ -171,5 +177,5 @@ The per-profile LLM provider + key live in `_main.json` (`config.llm` + to talk to USB phones; the SDK's Linux `adb` can't see them. - Phones without a direct internet route reach out through a host proxy: run an HTTP CONNECT proxy on the host, `adb reverse tcp:8899 tcp:8899`, and pass - `--es makepad.OCTOS_PROXY 'http://127.0.0.1:8899'`. This routes both the LLM + `--es makepad.OCTOS_PROXY 'http://127.0.0.1:8899'` to a development build. This routes both the LLM (`api.z.ai` / provider) and the card data fetches (open-meteo, Yahoo, HN). diff --git a/apps/appcard/docs/PROVISIONING-LLM.md b/apps/appcard/docs/PROVISIONING-LLM.md index 5ccf5551e..38ece54d6 100644 --- a/apps/appcard/docs/PROVISIONING-LLM.md +++ b/apps/appcard/docs/PROVISIONING-LLM.md @@ -50,7 +50,10 @@ names (`octosense_llm_config::registry`). ## Provisioning without the camera (dev / headless) The same JSON payload can be applied via the launch intent (no scanning), which is -how the flow is tested: +how the flow is tested. Any app on the phone can start this one with extras, so +only a development build (`DEV_MODE=1 tools/build-android.sh`, i.e. +`--features dev-mode`) reads it; a release build ignores it and provisions only +through the scan and its PIN: ```bash adb shell am start -S -n dev.makepad.octos_app/.MakepadApp \ --es makepad.PROVISION_CONFIG '{"llm_family":"zai","llm_model":"glm-5.2","llm_key":"sk-XXXX"}' diff --git a/apps/appcard/docs/WEBVIEW-VIDEO-BLACK-HANDOFF.md b/apps/appcard/docs/WEBVIEW-VIDEO-BLACK-HANDOFF.md index 31e0fc221..fb0bef0e1 100644 --- a/apps/appcard/docs/WEBVIEW-VIDEO-BLACK-HANDOFF.md +++ b/apps/appcard/docs/WEBVIEW-VIDEO-BLACK-HANDOFF.md @@ -214,6 +214,10 @@ $ADB -s bf0a4730 forward tcp:9222 localabstract:$(… webview_devtools_remote_

# record a golden (review it by eye first) device-l0-test.py run [case…] # check against goldens; exit 1 on failure +The installed app must be a development build (`DEV_MODE=1 +tools/build-android.sh`): a release build ignores the launch-time +`makepad.SEED_*` extras these tools seed cards with. + Goldens live in docs/tools/golden/. Regenerate deliberately, never to make a failure go away — a golden updated without looking is a test that asserts whatever the code currently does. diff --git a/apps/appcard/docs/tools/l0-visual.py b/apps/appcard/docs/tools/l0-visual.py index ff2c2e8fc..e9e32e3aa 100755 --- a/apps/appcard/docs/tools/l0-visual.py +++ b/apps/appcard/docs/tools/l0-visual.py @@ -8,6 +8,9 @@ l0-visual.py capture all three and report drift l0-visual.py weather just one + +Needs a development build of the app (`DEV_MODE=1 tools/build-android.sh`), +the only kind that reads the launch-time `makepad.SEED_L0_*` extras. """ import importlib.util, subprocess, sys from pathlib import Path diff --git a/apps/appcard/docs/weather-styles/README.md b/apps/appcard/docs/weather-styles/README.md index 893595660..97d87687d 100644 --- a/apps/appcard/docs/weather-styles/README.md +++ b/apps/appcard/docs/weather-styles/README.md @@ -56,5 +56,7 @@ To make the choices live on-device, rebuild + deploy the app memory: 1. Author one `style-.splash` (copy the closest existing one; keep every number a `sys.weather` binding). 2. Verify on device via `--es makepad.SEED_CARD_FILE /data/local/tmp/x.splash` - (raw DSL → auto-wrapped in `runsplash`; no APK rebuild needed). + (raw DSL → auto-wrapped in `runsplash`; no APK rebuild needed once a + development build, `DEV_MODE=1 tools/build-android.sh`, is installed: a + release build ignores launch-time extras). 3. Add a row to the catalog table + a keyword to the selection rule in `app.md`. diff --git a/apps/appcard/module/Cargo.toml b/apps/appcard/module/Cargo.toml index 4f4453233..d0f431d89 100644 --- a/apps/appcard/module/Cargo.toml +++ b/apps/appcard/module/Cargo.toml @@ -5,6 +5,11 @@ edition = "2021" license = "Apache-2.0" publish = false +[features] +# A development build: the app reads its launch-time test and provisioning +# hooks (octos-app's `dev_mode`). The shell's `dev-mode` turns it on. +dev-mode = ["octos-app/dev-mode"] + [dependencies] makepad-widgets = { workspace = true, features = ["appcard", "maps"] } makepad-app-module = { workspace = true } diff --git a/apps/appcard/tools/build-android.sh b/apps/appcard/tools/build-android.sh index d7d25f853..6a5e9bf8a 100755 --- a/apps/appcard/tools/build-android.sh +++ b/apps/appcard/tools/build-android.sh @@ -17,6 +17,7 @@ # tools/build-android.sh # build the APK # tools/build-android.sh run # build, install and launch # SKIP_KERNEL=1 tools/build-android.sh # reuse an already-built kernel +# DEV_MODE=1 tools/build-android.sh # a development build (launch-time hooks) # set -euo pipefail @@ -96,4 +97,11 @@ cd "$ROOT/app" # Android renders through the Vulkan backend (like the OpenHarmony build): the # map's compact vertex formats only exist there. `MAKEPAD=gles` forces OpenGL. export MAKEPAD="${MAKEPAD:-vulkan}" +# DEV_MODE=1 makes a development build, the only kind that reads the +# launch-time `makepad.*` hooks (provisioning, seeding, the auto prompt; see +# `launch_var` in app/app/src/lib.rs): any app on the phone can start this one +# with extras, so a release build ignores them. +if [[ "${DEV_MODE:-}" == 1 ]]; then + exec "$CARGO_MAKEPAD" makepad android "$ACTION" -p octos-app --release --features dev-mode +fi exec "$CARGO_MAKEPAD" makepad android "$ACTION" -p octos-app --release diff --git a/apps/appcard/tools/octos-macos.py b/apps/appcard/tools/octos-macos.py index fd0cb91ea..9a7a63924 100644 --- a/apps/appcard/tools/octos-macos.py +++ b/apps/appcard/tools/octos-macos.py @@ -92,6 +92,9 @@ def main(): empty_data.write_text('{}\n') env['MAKEPAD_SEED_L0_DATA'] = str(empty_data) if not request.get('skip_build', False): + # A desktop build reads the MAKEPAD_* hooks set above as local + # settings (`launch_var` in app/app/src/lib.rs), so no feature is + # needed; only a phone gates them behind `dev-mode`. subprocess.run(['cargo', 'build', '-p', 'octos-app', '--bin', 'octos-app', '--release', '--message-format=json'], cwd=root / 'app', env=env, check=True) if not Path(env['OCTOS_APP_CORE_BIN']).is_file(): diff --git a/apps/appcard/tools/octos-ohos.py b/apps/appcard/tools/octos-ohos.py index 0e9fa4c59..47dd0e2d8 100644 --- a/apps/appcard/tools/octos-ohos.py +++ b/apps/appcard/tools/octos-ohos.py @@ -56,8 +56,11 @@ def device_run(*args, quiet=False): def build_app(command): log = evidence / f'build-{build}-{command}.log' with log.open('w') as out: + # A development build: the launch parameters below (Studio, + # REAPPROVE_CARDS, AUTO_PROMPT, PROVISION_CONFIG) reach only a + # build with `dev-mode` (`launch_var` in app/app/src/lib.rs). p = subprocess.run([str(tool), 'ohos', '--deveco-home=' + os.environ['DEVECO_HOME'], - command, '-p', 'octos-app', '--release'], + command, '-p', 'octos-app', '--release', '--features', 'dev-mode'], cwd=root / 'app', stdout=out, stderr=subprocess.STDOUT) if p.returncode: raise SystemExit(f'Release build failed; private build log: {log}') diff --git a/crates/ai-host/Cargo.toml b/crates/ai-host/Cargo.toml index 4d7b230eb..a0082436d 100644 --- a/crates/ai-host/Cargo.toml +++ b/crates/ai-host/Cargo.toml @@ -42,6 +42,9 @@ license.workspace = true default = [] octos-core = ["dep:octosense-kernel", "octosense-app-peers/octos-core", "octosense-llm-service?/octos-core"] llm = ["dep:octosense-llm-service"] +# A development build: the kernel then takes launch-time test settings +# (crates/kernel/build.rs `dev_mode`). The shell's `dev-mode` turns it on. +dev-mode = ["octosense-kernel?/dev-mode"] # The system toolbox for app agents (ADR 0002 section 6, ADR 0004 section # 12): `toolbox_peers`, over the octos research engine and the `model` # service's host. diff --git a/crates/kernel/Cargo.toml b/crates/kernel/Cargo.toml index 990493a5c..e39bebac8 100644 --- a/crates/kernel/Cargo.toml +++ b/crates/kernel/Cargo.toml @@ -14,6 +14,11 @@ authors = ["octos-app contributors"] description = "The OctoSense shell's octos kernel: one per process, shared by every consumer over UI Protocol frames" publish = false +[features] +# A development build (build.rs sets `dev_mode`; debug builds are development +# builds without it): only then does an Android kernel take the launch extra +# `makepad.OCTOS_PROXY`. The shells' and AppCard's `dev-mode` turn it on. +dev-mode = [] [dependencies] tokio = { version = "1", features = ["rt-multi-thread", "sync", "io-util", "process", "time", "macros"] } diff --git a/crates/kernel/README.md b/crates/kernel/README.md index 757b4ef02..06a3b4127 100644 --- a/crates/kernel/README.md +++ b/crates/kernel/README.md @@ -33,8 +33,10 @@ How it starts, per platform (`src/launch.rs`): - **Android**: `/liboctos.so serve --stdio`, `HOME=` (the octos home), with AppCard's environment - (`OCTOS_SKILLS_PATH`, `OCTOS_OMIT_WORKSPACE_HINT`, `RUST_LOG`, the - `makepad.OCTOS_PROXY` proxy) and the kernel config's memory budget. The APK + (`OCTOS_SKILLS_PATH`, `OCTOS_OMIT_WORKSPACE_HINT`, `RUST_LOG`, and in a + development build only the launch extra `makepad.OCTOS_PROXY` as the proxy: + feature `dev-mode` or a debug build, since any app can start Home with + extras) and the kernel config's memory budget. The APK must bundle the kernel: `MAKEPAD_ANDROID_EXTRA_LIBS=liboctos.so=` (the shells' build scripts do it). - **OpenHarmony**: the canonical core in-process, diff --git a/crates/kernel/README.zh-CN.md b/crates/kernel/README.zh-CN.md index a35ca843a..f405a189e 100644 --- a/crates/kernel/README.zh-CN.md +++ b/crates/kernel/README.zh-CN.md @@ -31,7 +31,8 @@ Shell(`phone/` 中的 Home、`desktop/` 中的桌面)拥有它;**AI provid - **Android**:`/liboctos.so serve --stdio`,`HOME=` (octos home),使用 AppCard 的环境变量(`OCTOS_SKILLS_PATH`、 - `OCTOS_OMIT_WORKSPACE_HINT`、`RUST_LOG`、`makepad.OCTOS_PROXY` 代理)以及内核配置的 + `OCTOS_OMIT_WORKSPACE_HINT`、`RUST_LOG`,以及仅在开发版中作为代理的启动 extra + `makepad.OCTOS_PROXY`:feature `dev-mode` 或调试构建,因为任何应用都能带着 extra 启动 Home)以及内核配置的 内存预算。APK 必须打包内核:`MAKEPAD_ANDROID_EXTRA_LIBS=liboctos.so=` (Shell 的构建脚本会这样做)。 - **OpenHarmony**:在进程内运行标准内核 `octos_cli::embedded::serve_io(, ..)`, diff --git a/crates/kernel/build.rs b/crates/kernel/build.rs index 2d3c79ff1..ead8c42b2 100644 --- a/crates/kernel/build.rs +++ b/crates/kernel/build.rs @@ -7,10 +7,19 @@ //! it; else `octos-core`, which this crate links). `OCTOSENSE_OCTOS_REVISION` //! at build time overrides it. Empty when neither is found: the desktop then //! refuses a packaged kernel it cannot check (an explicit program still runs). +//! +//! `dev_mode` marks a development build, as the shell's build.rs does: any +//! build with debug assertions, or feature `dev-mode` (a shell's `dev-mode` +//! turns it on). Only then does an Android kernel take the launch extra +//! `makepad.OCTOS_PROXY` (`launch::launch_proxy`). use std::path::Path; fn main() { + println!("cargo:rustc-check-cfg=cfg(dev_mode)"); + if std::env::var_os("CARGO_FEATURE_DEV_MODE").is_some() || std::env::var_os("CARGO_CFG_DEBUG_ASSERTIONS").is_some() { + println!("cargo:rustc-cfg=dev_mode"); + } println!("cargo:rerun-if-env-changed=OCTOSENSE_OCTOS_REVISION"); let lock = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../Cargo.lock"); println!("cargo:rerun-if-changed={}", lock.display()); diff --git a/crates/kernel/src/launch.rs b/crates/kernel/src/launch.rs index d78119918..f18b43972 100644 --- a/crates/kernel/src/launch.rs +++ b/crates/kernel/src/launch.rs @@ -441,18 +441,28 @@ pub(crate) fn phone_stdio(program: PathBuf, core_dir: &Path, extra: &[(String, S env.extend(PHONE_KERNEL_ENV.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))); // Route the kernel's HTTPS through a proxy when the device has no route // of its own (an `adb reverse` tunnel): launch extra `makepad.OCTOS_PROXY`. - if let Ok(proxy) = std::env::var("MAKEPAD_OCTOS_PROXY") { - let proxy = proxy.trim().to_owned(); - if !proxy.is_empty() { - for k in ["HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy", "ALL_PROXY"] { - env.push((k.to_owned(), proxy.clone())); - } + if let Some(proxy) = launch_proxy(cfg!(dev_mode), std::env::var("MAKEPAD_OCTOS_PROXY").ok()) { + for k in ["HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy", "ALL_PROXY"] { + env.push((k.to_owned(), proxy.clone())); } } env.extend(extra.iter().cloned()); Launch::Stdio { program, args, env, cwd: Some(home) } } +/// The kernel's proxy from the launch extra `makepad.OCTOS_PROXY` +/// (`MAKEPAD_OCTOS_PROXY`), in a development build only (`dev_mode`: a +/// debug build or feature `dev-mode`, build.rs). Any app on the phone can +/// start Home with that extra, so a release build never routes the kernel's +/// traffic through it. +#[cfg_attr(not(target_os = "android"), allow(dead_code))] +fn launch_proxy(development: bool, value: Option) -> Option { + if !development { + return None; + } + value.map(|proxy| proxy.trim().to_owned()).filter(|proxy| !proxy.is_empty()) +} + /// Make what a start needs: the core dir, the cwd (a missing cwd fails the /// spawn's chdir with ENOENT, permanently, since the kernel would create it), /// on Android the kernel config's memory budget (as AppCard did), the @@ -678,6 +688,15 @@ mod tests { assert_eq!(args, ["serve", "--stdio", "--data-dir", "/f/core"]); } + #[test] + fn only_a_development_build_takes_the_launch_proxy() { + let proxy = Some(" http://127.0.0.1:8899 ".to_owned()); + assert_eq!(launch_proxy(false, proxy.clone()), None, "a release build ignores the launch extra"); + assert_eq!(launch_proxy(true, proxy).as_deref(), Some("http://127.0.0.1:8899")); + assert_eq!(launch_proxy(true, Some(" ".into())), None); + assert_eq!(launch_proxy(true, None), None); + } + #[test] fn kernel_config_is_merged_not_replaced() { let home = tmp("cfg"); diff --git a/crates/shell/Cargo.toml b/crates/shell/Cargo.toml index 5aa65ddfe..2950de5c8 100644 --- a/crates/shell/Cargo.toml +++ b/crates/shell/Cargo.toml @@ -195,8 +195,11 @@ app-aichat = ["dep:makepad-aichat"] # A development build for developer mode (ADR 0004 §13, src/dev_mode.rs): # Settings and OCTOSENSE_DEV_MODE may turn it on. Debug builds are # development builds without it (build.rs); a release build without it -# honours only the `--dev-grant-all` launch flag. -dev-mode = [] +# honours only the `--dev-grant-all` launch flag. Only a development build +# reads launch input: the app config's test actions and demo settings +# (src/launch_input.rs), the kernel's launch proxy and AppCard's +# provisioning and seeding hooks. +dev-mode = ["octosense-ai-host/dev-mode", "octosense-appcard?/dev-mode"] # The `wasm` host service (ADR 0011), which runs an app's own # WebAssembly functions (crates/wasm-host). On in every standard shell # build (tools/native_apps.py's BASE_DEFAULT); it runs where build.rs sets diff --git a/crates/shell/src/apps.rs b/crates/shell/src/apps.rs index 25084ffa1..5a25f64af 100644 --- a/crates/shell/src/apps.rs +++ b/crates/shell/src/apps.rs @@ -212,6 +212,7 @@ pub(crate) mod test_system_apps { /// `glance` takes the cards apps publish to the glance screen (glance.rs). /// `mail_demo` in MAKEPAD_APP_CONFIG serves a demo mailbox from a file vault /// instead (no keychain, no network): `MAKEPAD_APP_CONFIG='{"mail_demo":true}'`. +/// A desktop reads it; a phone only in a development build (`launch_input`). /// `news` fetches News's feeds on a timer, with no model (ADR 0002), into /// the Card runner's host directory, so it keeps fetching while News is /// closed. Last, every system app no service answers gets the notice @@ -229,12 +230,7 @@ pub fn register_mail_services() { static ONCE: std::sync::Once = std::sync::Once::new(); ONCE.call_once(|| { crate::glance::register(); - let demo = std::env::var("MAKEPAD_APP_CONFIG") - .ok() - .and_then(|text| makepad_strict_json::parse(text.as_bytes()).ok()) - .and_then(|config| config.get("mail_demo").and_then(|v| v.as_bool())) - .unwrap_or(false); - if demo { + if crate::launch_input::flag("mail_demo") { octosense_mail_service::register_demo() } else { octosense_mail_service::register() @@ -684,7 +680,8 @@ pub fn matches_running_app(app: &crate::clients::AppDef, running_id: &str, title /// the launcher row names — a system app by its `os.*` manifest id, an /// installed one by its own. Any other module opens with what /// MAKEPAD_APP_CONFIG gives it (`{"module_open": {"": {...}}}`, or -/// `mail_endpoint` for a linked `mail` module), else empty. +/// `mail_endpoint` for a linked `mail` module; a desktop reads it, a phone +/// only in a development build, `launch_input`), else empty. pub fn module_open(module: &'static dyn AppModule, app: &crate::clients::AppDef) -> Result { let schema = module.open_schema(); if module.id() == "card" { @@ -695,7 +692,7 @@ pub fn module_open(module: &'static dyn AppModule, app: &crate::clients::AppDef) let instance = app.args.iter().find_map(|a| a.strip_prefix("--studio-instance=")).ok_or("Studio launch requires an admitted instance")?; return schema.validate(&format!("{{\"instance_id\":{}}}", makepad_strict_json::Value::Str(instance.into()).to_json()), &[]); } - let config = std::env::var("MAKEPAD_APP_CONFIG").ok().and_then(|text| makepad_strict_json::parse(text.as_bytes()).ok()); + let config = crate::launch_input::app_config(); if let Some(json) = config.as_ref().and_then(|c| c.get("module_open")).and_then(|v| v.get(module.id())).map(|v| v.to_json()) { return schema.validate(&json, &[]); } diff --git a/crates/shell/src/launch_input.rs b/crates/shell/src/launch_input.rs new file mode 100644 index 000000000..00936065c --- /dev/null +++ b/crates/shell/src/launch_input.rs @@ -0,0 +1,164 @@ +//! Launch input: settings that arrive with a launch. +//! +//! **Where it comes from.** On a desktop these variables are the local +//! user's own environment, the same trust as a command-line flag. On a +//! phone they are not: Home's main activity is exported (the app drawer +//! entry, the Home app, a share target and a deep-link target), so any +//! installed app can start it with extras, and Makepad's Android glue copies +//! the extras `makepad.` into the environment as `MAKEPAD_` +//! (`APP_CONFIG`, `PROVISION_CONFIG`, `SEED_*` and others). OpenHarmony passes +//! the Want's `makepad.*` parameters on the same way. +//! +//! **The rule.** On Android and OpenHarmony (`cfg(native_mobile)`) OctoSense +//! reads the app config only in a development build (`cfg(dev_mode)`: a debug +//! build, or `--features dev-mode`; never a store build); a release build +//! there ignores it and says so once in the log, without the value. A desktop +//! reads it in every build, because its environment is the local user's. +//! `--test-action` flags on a desktop command line are arguments only the +//! local user can pass, and work in every build (`App::run_test_actions` in +//! `lib.rs`). +//! +//! A phone build that needs these hooks is a development build under a +//! separate package name (`rom/scripts/build-home.py --dev-mode`). + +use crate::dev_mode::BuildKind; +use makepad_strict_json::Value; + +/// The app config: one JSON object, +/// `{"test_actions": ["", …], "mail_demo": true, +/// "module_open": {"": {…}}, "mail_endpoint": "…"}`. +pub const APP_CONFIG: &str = "MAKEPAD_APP_CONFIG"; + +/// Whether this build reads the app config. A desktop always does; Android +/// and OpenHarmony, where another app can set it, only in a development +/// build. +pub fn honoured(build: BuildKind) -> bool { + honoured_for(cfg!(native_mobile), build) +} + +/// [`honoured`] split out from the target for testing: `native_mobile` is +/// Android and OpenHarmony (and iOS, which has no such channel but is a +/// phone all the same). +fn honoured_for(native_mobile: bool, build: BuildKind) -> bool { + !native_mobile || build == BuildKind::Development +} + +/// The app config, parsed, when this build reads launch input. A phone +/// release build answers `None` whatever the variable holds. +pub fn app_config() -> Option { + let build = BuildKind::current(); + if !honoured(build) { + if std::env::var_os(APP_CONFIG).is_some() { + note_ignored(); + } + return None; + } + std::env::var(APP_CONFIG).ok().and_then(|text| makepad_strict_json::parse(text.as_bytes()).ok()) +} + +/// [`app_config`] for a given target and build, over the given text. +fn app_config_for(native_mobile: bool, build: BuildKind, text: &str) -> Option { + if !honoured_for(native_mobile, build) { + return None; + } + makepad_strict_json::parse(text.as_bytes()).ok() +} + +/// The app config's `test_actions`, in order, as `--test-action` names; none +/// in a phone release build. +pub fn test_actions() -> Vec { + app_config().map(|config| test_actions_in(&config)).unwrap_or_default() +} + +/// [`test_actions`] for a given target and build, over the given text. +fn test_actions_for(native_mobile: bool, build: BuildKind, text: &str) -> Vec { + app_config_for(native_mobile, build, text).map(|config| test_actions_in(&config)).unwrap_or_default() +} + +fn test_actions_in(config: &Value) -> Vec { + config + .get("test_actions") + .and_then(|actions| actions.as_arr()) + .map(|actions| actions.iter().filter_map(|action| action.as_str()).map(str::to_owned).collect()) + .unwrap_or_default() +} + +/// The `true`/`false` value `key` has in the app config, when this build +/// reads it. +pub fn flag(key: &str) -> bool { + app_config().and_then(|config| config.get(key).and_then(|value| value.as_bool())).unwrap_or(false) +} + +fn note_ignored() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + makepad_widgets::log!( + "launch input: {APP_CONFIG} ignored; a release build on a phone takes no test actions or demo settings from a launch" + ) + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + const CONFIG: &str = r#"{"test_actions":["capture:/sdcard/Download/frame.png","launch-mail","system-chat-send:hello"],"mail_demo":true}"#; + const ACTIONS: [&str; 3] = ["capture:/sdcard/Download/frame.png", "launch-mail", "system-chat-send:hello"]; + + #[test] + fn a_phone_release_build_ignores_test_actions_from_the_launch() { + for build in [BuildKind::Release, BuildKind::Store] { + assert!(!honoured_for(true, build), "phone {build:?}"); + assert!(test_actions_for(true, build, CONFIG).is_empty(), "phone {build:?}"); + assert!(app_config_for(true, build, CONFIG).is_none(), "phone {build:?}"); + } + } + + #[test] + fn a_desktop_build_reads_them_in_every_build() { + for build in [BuildKind::Release, BuildKind::Store, BuildKind::Development] { + assert!(honoured_for(false, build), "desktop {build:?}"); + assert_eq!(test_actions_for(false, build, CONFIG), ACTIONS, "desktop {build:?}"); + } + } + + #[test] + fn a_development_build_keeps_them_on_a_phone_too() { + assert!(honoured_for(true, BuildKind::Development)); + assert_eq!(test_actions_for(true, BuildKind::Development, CONFIG), ACTIONS); + let config = app_config_for(true, BuildKind::Development, CONFIG).expect("parsed"); + assert_eq!(config.get("mail_demo").and_then(|v| v.as_bool()), Some(true)); + } + + #[test] + fn malformed_or_missing_actions_give_none() { + for text in ["", "not json", r#"{"test_actions":"launch-mail"}"#, r#"{"mail_demo":true}"#] { + assert!(test_actions_for(false, BuildKind::Release, text).is_empty(), "{text:?}"); + } + // Entries that are not strings are skipped, the rest kept. + assert_eq!(test_actions_for(false, BuildKind::Release, r#"{"test_actions":[1,"launch-news",null]}"#), ["launch-news"]); + } + + /// The shell has one reader of the app config: this module. Anything + /// else reading the variable directly would bypass the build check. + #[test] + fn no_other_shell_source_reads_the_app_config_directly() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut offenders = Vec::new(); + let mut stack = vec![root.clone()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).expect("read src") { + let path = entry.expect("entry").path(); + if path.is_dir() { + stack.push(path); + } else if path.extension().is_some_and(|e| e == "rs") && !path.ends_with("launch_input.rs") { + let text = std::fs::read_to_string(&path).expect("read source"); + if text.contains("env::var(\"MAKEPAD_APP_CONFIG\"") || text.contains("env::var_os(\"MAKEPAD_APP_CONFIG\"") { + offenders.push(path.strip_prefix(&root).unwrap().display().to_string()); + } + } + } + } + assert!(offenders.is_empty(), "read MAKEPAD_APP_CONFIG through launch_input: {offenders:?}"); + } +} diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index 60d5c6263..13b780040 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -19,6 +19,7 @@ pub mod ai_bus; pub mod app_chat; pub mod app_storage; pub mod dev_mode; +pub mod launch_input; pub mod approvals; pub mod apps; pub mod binds; @@ -5443,17 +5444,14 @@ impl App { /// `--test-action ` fires one WmAction at startup, so every /// binding can be driven from a script even where the host OS keeps a - /// chord for itself. + /// chord for itself. Command-line flags, which only the local user can + /// pass, work in every build. The app config's `test_actions` arrive + /// with a launch that another app may have made (an Android intent + /// extra), so only a development build takes them (`launch_input`). fn run_test_actions(&mut self, cx: &mut Cx) { let mut args: Vec = std::env::args().collect(); - if let Ok(config) = std::env::var("MAKEPAD_APP_CONFIG") { - if let Ok(config) = makepad_strict_json::parse(config.as_bytes()) { - if let Some(actions) = config.get("test_actions").and_then(|v| v.as_arr()) { - for action in actions.iter().filter_map(|v| v.as_str()) { - args.extend(["--test-action".into(), action.into()]); - } - } - } + for action in launch_input::test_actions() { + args.extend(["--test-action".into(), action]); } let mut i = 0; while i < args.len() { diff --git a/desktop/docs/android-appcard-build.md b/desktop/docs/android-appcard-build.md index 0d277c2b1..1db7c78c5 100644 --- a/desktop/docs/android-appcard-build.md +++ b/desktop/docs/android-appcard-build.md @@ -117,7 +117,15 @@ developer's own `octos serve` is never touched. ## 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. +In a release build, providers come from **AI providers** (the desktop's QR +code and its PIN, or typed on the host's own sheet). Any app on the phone can +start Home with intent extras, so only a development build that links AppCard +(`--features app-appcard,dev-mode`), installed as its own test package (see +[phone/README.md](../../phone/README.md#build-and-run)), reads the launch-time +provisioning below; a release build ignores it (**unverified** on a device +since that change). + +The quoting-safe way is `PACKAGE=dev.makepad.octosense. scripts/provision-appcard-llm.sh `: it builds the JSON, ships it through both shells intact, restarts that test package 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. The kernel needs an LLM profile before a request can run. The buildtool @@ -126,7 +134,7 @@ environment, and the app reads `MAKEPAD_PROVISION_CONFIG` on startup and writes the profile into the kernel's config: ```sh -"$ADB" shell "am start -S -n dev.makepad.octosense/.MakepadApp \ +"$ADB" shell "am start -S -n dev.makepad.octosense./.MakepadApp \ --es makepad.PROVISION_CONFIG '{\"llm_family\":\"zai\",\"llm_model\":\"glm-5.2\",\"llm_key\":\"\"}'" "$ADB" logcat -d -s Makepad | grep -iE 'provision|turn/start|turn FAILED' ``` diff --git a/desktop/scripts/provision-appcard-llm.sh b/desktop/scripts/provision-appcard-llm.sh index f50e28f84..36fe87dbe 100755 --- a/desktop/scripts/provision-appcard-llm.sh +++ b/desktop/scripts/provision-appcard-llm.sh @@ -1,11 +1,23 @@ #!/usr/bin/env bash -# Provision the LLM for AppCard inside OctoSense on the connected Android phone. -# provision-appcard-llm.sh +# Provision the LLM for AppCard inside a development test build of OctoSense +# Home on the connected Android phone. +# PACKAGE=dev.makepad.octosense. provision-appcard-llm.sh # family: zhipu (bigmodel.cn key) | zai (z.ai key) | deepseek | openai | anthropic | ... # The key goes only to the phone (launch-intent extra); it is not logged here. +# Any app can start Home with extras, so a release Home ignores launch-time +# provisioning: PACKAGE must be a development build that links AppCard +# (`--features app-appcard,dev-mode`), installed as its own test package. A +# release build takes providers from AI providers (QR code and PIN) instead. set -euo pipefail -[ $# -eq 3 ] || { echo "usage: $0 " >&2; exit 2; } +[ $# -eq 3 ] || { echo "usage: PACKAGE=dev.makepad.octosense. $0 " >&2; exit 2; } family=$1 model=$2 key=$3 +package=${PACKAGE:-} +case "$package" in + *[!a-z0-9_.]*|dev.makepad.octosense.bridge|dev.makepad.octosense.quickstep|dev.makepad.octosense.agent) package= ;; + dev.makepad.octosense.?*) ;; + *) package= ;; +esac +[ -n "$package" ] || { echo "set PACKAGE to the development test package (dev.makepad.octosense.), never the installed Home" >&2; exit 2; } ADB=${ADB:-$(command -v adb || true)} [ -n "$ADB" ] || { echo "adb not found; set ADB=/path/to/adb (cargo-makepad installs one under tools/cargo_makepad/android_*/platform-tools)" >&2; exit 1; } [ -x "$ADB" ] || { echo "adb not found; set ADB=/path/to/adb" >&2; exit 1; } @@ -14,8 +26,8 @@ ADB=${ADB:-$(command -v adb || true)} json=$(python3 -c 'import json,sys; print(json.dumps({"llm_family":sys.argv[1],"llm_model":sys.argv[2],"llm_key":sys.argv[3]}))' "$family" "$model" "$key") esc=${json//\"/\\\"} "$ADB" logcat -c >/dev/null 2>&1 || true -"$ADB" shell "am start -S -n dev.makepad.octosense/.MakepadApp --es makepad.PROVISION_CONFIG '$esc'" >/dev/null -echo "launched OctoSense with provisioning extra; open the AppCard tile now (waiting up to 40 s for the app to record it)…" +"$ADB" shell "am start -S -n $package/.MakepadApp --es makepad.PROVISION_CONFIG '$esc'" >/dev/null +echo "launched $package with provisioning extra; open the AppCard tile now (waiting up to 40 s for the app to record it)…" for i in $(seq 1 40); do line=$("$ADB" logcat -d -s Makepad 2>/dev/null | grep -E 'provisioned LLM|LLM provisioning failed' | tail -1 || true) [ -n "$line" ] && break; sleep 1 diff --git a/phone/README.md b/phone/README.md index f2351aad8..b7d1bfbd3 100644 --- a/phone/README.md +++ b/phone/README.md @@ -112,6 +112,33 @@ this setting and silently omit Android Back handling and other platform integration in a custom package. After installing a test build, open News or Photos and verify that an edge Back swipe returns to OctoSense Home. +**Launch-time test hooks.** Any app on the phone can start Home with intent +extras, so a release Home ignores the `makepad.*` test and provisioning +extras: the `test_actions` and demo settings in `makepad.APP_CONFIG`, +AppCard's `makepad.PROVISION_CONFIG` and seeding hooks, and the kernel's +`makepad.OCTOS_PROXY`. Only a development build (a debug build, or +`--features dev-mode`) reads them ([`launch_input.rs`](../crates/shell/src/launch_input.rs)). +`--dev-mode` (above) builds one. Give it a test `--package-name` and install +only its Home APK (**unverified** on a device): + +```sh +rom/scripts/build-home.sh --variant standalone --development --dev-mode \ + --package-name dev.makepad.octosense.devtest \ + --sdk /path/to/makepad-android \ + --android-sdk /path/to/android-sdk \ + --gradle-home /path/to/gradle-8.11.1 \ + --java-home /path/to/full-jdk \ + --packager .sources/makepad/target/release/cargo-makepad +adb install rom/out/home/standalone-dev-mode/dev.makepad.octosense.devtest/OctoSenseHome.apk +adb shell am start -n dev.makepad.octosense.devtest/.MakepadApp \ + --es makepad.APP_CONFIG '{"test_actions":["launch-mail"]}' +``` + +On OpenHarmony, `rom/scripts/build-home-ohos.py --dev-mode` does the same +with a separate `--bundle-id`; a release Home's entry ability passes no +launch parameters on. Desktop `--test-action` flags are command-line +arguments, which only the local user can pass, and work in every build. + **OpenHarmony:** `python3 rom/scripts/build-home-ohos.py --deveco-home ... --packager ... --signing-config ...` builds a normal OpenHarmony app with an existing DevEco signing profile @@ -285,10 +312,14 @@ never holds a socket or a password. For a demo mailbox (password `demo`): ```sh # desktop, from phone/ MAKEPAD_APP_CONFIG='{"mail_demo":true}' cargo run --release -p octosense-home --features mobile-only -# phone -adb shell am start -n /.MakepadApp --es makepad.APP_CONFIG '{"mail_demo":true}' +# phone: a development test package (see Launch-time test hooks) +adb shell am start -n /.MakepadApp --es makepad.APP_CONFIG '{"mail_demo":true}' ``` +The desktop command reads `MAKEPAD_APP_CONFIG` as a local setting; a phone +release build ignores it, so the phone line needs a development test package +(**unverified** since that change). + The earlier native News, Photos and Maps modules are deleted (native-apps ADR 0004 §1, [#113](https://github.com/OctoSense-org/OctoSense/pull/113)); none of the system apps has a native module any more. diff --git a/phone/README.zh-CN.md b/phone/README.zh-CN.md index 2f6173ea7..c2497ae0d 100644 --- a/phone/README.zh-CN.md +++ b/phone/README.zh-CN.md @@ -61,6 +61,23 @@ Home 默认使用 `dev.makepad.octosense`。要在已安装的应用旁构建测 平台集成功能。安装测试版后,打开 News 或 Photos,检查从屏幕边缘向内滑动返回 是否回到 OctoSense Home。 +**启动时的测试钩子。** 手机上的任何应用都能带着 intent extra 启动 Home,因此发布版 Home 会忽略 `makepad.*` 测试和配置 extra:`makepad.APP_CONFIG` 中的 `test_actions` 和演示设置、AppCard 的 `makepad.PROVISION_CONFIG` 和预置卡片钩子,以及内核的 `makepad.OCTOS_PROXY`。只有开发版(调试构建,或使用 `--features dev-mode`)才读取它们([`launch_input.rs`](../crates/shell/src/launch_input.rs))。`--dev-mode`(见上文)构建开发版:请为它指定测试用的 `--package-name`,并且只安装其中的 Home APK(**未在设备上验证**): + +```sh +rom/scripts/build-home.sh --variant standalone --development --dev-mode \ + --package-name dev.makepad.octosense.devtest \ + --sdk /path/to/makepad-android \ + --android-sdk /path/to/android-sdk \ + --gradle-home /path/to/gradle-8.11.1 \ + --java-home /path/to/full-jdk \ + --packager .sources/makepad/target/release/cargo-makepad +adb install rom/out/home/standalone-dev-mode/dev.makepad.octosense.devtest/OctoSenseHome.apk +adb shell am start -n dev.makepad.octosense.devtest/.MakepadApp \ + --es makepad.APP_CONFIG '{"test_actions":["launch-mail"]}' +``` + +在 OpenHarmony 上,`rom/scripts/build-home-ohos.py --dev-mode` 以另一个 `--bundle-id` 做同样的事;发布版 Home 的入口 ability 不转发任何启动参数。桌面上的 `--test-action` 是命令行参数,只有本机用户能传入,所有构建都支持。 + **OpenHarmony:** `python3 rom/scripts/build-home-ohos.py --deveco-home ... --packager ... --signing-config ...` 使用现有的 DevEco 签名配置构建普通的 OpenHarmony 应用([rom/docs/home-build.md(英文)](../rom/docs/home-build.md#openharmony-home))。**iOS 模拟器:** 在 `phone/` 中运行 `../.sources/makepad/target/release/cargo-makepad makepad apple ios --org=dev.makepad --app=octosense run-sim -p octosense-home --features mobile-only`。两者都不在 CI 中构建。 ## 应用图标 @@ -192,10 +209,12 @@ Mail 通过 `mail` 宿主服务([`apps/mail/host-service`](../apps/mail/host-s ```sh # desktop, from phone/ MAKEPAD_APP_CONFIG='{"mail_demo":true}' cargo run --release -p octosense-home --features mobile-only -# phone -adb shell am start -n /.MakepadApp --es makepad.APP_CONFIG '{"mail_demo":true}' +# 手机:开发版测试包(见“启动时的测试钩子”) +adb shell am start -n /.MakepadApp --es makepad.APP_CONFIG '{"mail_demo":true}' ``` +桌面命令会把 `MAKEPAD_APP_CONFIG` 当作本机设置读取;手机发布版会忽略它,所以手机那行需要开发版测试包(这一改动后**未验证**)。 + 早期的原生 News、Photos 和 Maps 模块已删除(原生应用 ADR 0004 §1,[#113](https://github.com/OctoSense-org/OctoSense/pull/113));所有系统应用都不再有原生模块。 ### octos 内核 diff --git a/phone/docs/android-appcard-build.md b/phone/docs/android-appcard-build.md index 5a9aae224..998ebdb72 100644 --- a/phone/docs/android-appcard-build.md +++ b/phone/docs/android-appcard-build.md @@ -174,7 +174,15 @@ 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. +In a release build, providers come from **AI providers** (the desktop's QR +code and its PIN, or typed on the host's own sheet). Any app on the phone can +start Home with intent extras, so only a development build that links AppCard +(`--features app-appcard,dev-mode`), installed as its own test package (see +[phone/README.md](../../phone/README.md#build-and-run)), reads the launch-time +provisioning below; a release build ignores it (**unverified** on a device +since that change). + +The quoting-safe way is `PACKAGE=dev.makepad.octosense. scripts/provision-appcard-llm.sh `: it builds the JSON, ships it through both shells intact, restarts that test package 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. The kernel needs an LLM profile before a request can run. The buildtool @@ -183,7 +191,7 @@ environment, and the app reads `MAKEPAD_PROVISION_CONFIG` on startup and writes the profile into the kernel's config: ```sh -"$ADB" shell "am start -S -n dev.makepad.octosense/.MakepadApp \ +"$ADB" shell "am start -S -n dev.makepad.octosense./.MakepadApp \ --es makepad.PROVISION_CONFIG '{\"llm_family\":\"zai\",\"llm_model\":\"glm-5.2\",\"llm_key\":\"\"}'" "$ADB" logcat -d -s Makepad | grep -iE 'provision|turn/start|turn FAILED' ``` diff --git a/phone/docs/android/perf-home-swipes-oneplus-6.md b/phone/docs/android/perf-home-swipes-oneplus-6.md index 9fc009839..7ce5971dd 100644 --- a/phone/docs/android/perf-home-swipes-oneplus-6.md +++ b/phone/docs/android/perf-home-swipes-oneplus-6.md @@ -29,7 +29,10 @@ scripts can still incur their loading cost. ## Measurement method Use Makepad's `Cx::perf_monitor` and `PerfGraph` for diagnosis, enabled by -`makepad.APP_CONFIG='{"test_actions":["perf:on"]}'`. The retained diagnostic +`makepad.APP_CONFIG='{"test_actions":["perf:on"]}'`. Launch-time test actions +run only in a development build (`--features dev-mode`) installed as a +separate test package (`rom/scripts/build-home.py --dev-mode`); a release Home +ignores them. The retained diagnostic log separates `home`, `event`, `draw`, and `wait` costs. Whole-session monitor FPS includes deliberate idle periods and is not the swipe FPS. diff --git a/phone/ohos/EntryAbility.ets b/phone/ohos/EntryAbility.ets index 29324c2cf..08c84bff4 100644 --- a/phone/ohos/EntryAbility.ets +++ b/phone/ohos/EntryAbility.ets @@ -5,16 +5,25 @@ import { BusinessError } from '@kit.BasicServicesKit'; import makepad from 'libmakepad.so' import {ArkGlue} from '../makepad/makepad' +// Any app can start this exported ability with any parameters, and Makepad +// turns each `makepad.` launch parameter into the environment variables +// MAKEPAD_ and : test hooks, provisioning, a Studio connection, +// or any other variable Home reads. Only a development build passes them on; +// rom/scripts/build-home-ohos.py --dev-mode stages this file with `true`. +const FORWARD_LAUNCH_PARAMETERS: boolean = false; + export default class EntryAbility extends UIAbility { private studioReview: boolean = false; onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { let x = ArkGlue.initInstance(this.context); const parameters: Record = {}; - for (const key of Object.keys(want.parameters ?? {})) { - const value = want.parameters?.[key]; - if (key.startsWith('makepad.') && typeof value === 'string') { - parameters[key] = value; + if (FORWARD_LAUNCH_PARAMETERS) { + for (const key of Object.keys(want.parameters ?? {})) { + const value = want.parameters?.[key]; + if (key.startsWith('makepad.') && typeof value === 'string') { + parameters[key] = value; + } } } x.launchParameters = JSON.stringify(parameters); diff --git a/phone/scripts/provision-appcard-llm.sh b/phone/scripts/provision-appcard-llm.sh index f50e28f84..36fe87dbe 100755 --- a/phone/scripts/provision-appcard-llm.sh +++ b/phone/scripts/provision-appcard-llm.sh @@ -1,11 +1,23 @@ #!/usr/bin/env bash -# Provision the LLM for AppCard inside OctoSense on the connected Android phone. -# provision-appcard-llm.sh +# Provision the LLM for AppCard inside a development test build of OctoSense +# Home on the connected Android phone. +# PACKAGE=dev.makepad.octosense. provision-appcard-llm.sh # family: zhipu (bigmodel.cn key) | zai (z.ai key) | deepseek | openai | anthropic | ... # The key goes only to the phone (launch-intent extra); it is not logged here. +# Any app can start Home with extras, so a release Home ignores launch-time +# provisioning: PACKAGE must be a development build that links AppCard +# (`--features app-appcard,dev-mode`), installed as its own test package. A +# release build takes providers from AI providers (QR code and PIN) instead. set -euo pipefail -[ $# -eq 3 ] || { echo "usage: $0 " >&2; exit 2; } +[ $# -eq 3 ] || { echo "usage: PACKAGE=dev.makepad.octosense. $0 " >&2; exit 2; } family=$1 model=$2 key=$3 +package=${PACKAGE:-} +case "$package" in + *[!a-z0-9_.]*|dev.makepad.octosense.bridge|dev.makepad.octosense.quickstep|dev.makepad.octosense.agent) package= ;; + dev.makepad.octosense.?*) ;; + *) package= ;; +esac +[ -n "$package" ] || { echo "set PACKAGE to the development test package (dev.makepad.octosense.), never the installed Home" >&2; exit 2; } ADB=${ADB:-$(command -v adb || true)} [ -n "$ADB" ] || { echo "adb not found; set ADB=/path/to/adb (cargo-makepad installs one under tools/cargo_makepad/android_*/platform-tools)" >&2; exit 1; } [ -x "$ADB" ] || { echo "adb not found; set ADB=/path/to/adb" >&2; exit 1; } @@ -14,8 +26,8 @@ ADB=${ADB:-$(command -v adb || true)} json=$(python3 -c 'import json,sys; print(json.dumps({"llm_family":sys.argv[1],"llm_model":sys.argv[2],"llm_key":sys.argv[3]}))' "$family" "$model" "$key") esc=${json//\"/\\\"} "$ADB" logcat -c >/dev/null 2>&1 || true -"$ADB" shell "am start -S -n dev.makepad.octosense/.MakepadApp --es makepad.PROVISION_CONFIG '$esc'" >/dev/null -echo "launched OctoSense with provisioning extra; open the AppCard tile now (waiting up to 40 s for the app to record it)…" +"$ADB" shell "am start -S -n $package/.MakepadApp --es makepad.PROVISION_CONFIG '$esc'" >/dev/null +echo "launched $package with provisioning extra; open the AppCard tile now (waiting up to 40 s for the app to record it)…" for i in $(seq 1 40); do line=$("$ADB" logcat -d -s Makepad 2>/dev/null | grep -E 'provisioned LLM|LLM provisioning failed' | tail -1 || true) [ -n "$line" ] && break; sleep 1 diff --git a/rom/docs/home-build.md b/rom/docs/home-build.md index 51c6c775f..e918bc624 100644 --- a/rom/docs/home-build.md +++ b/rom/docs/home-build.md @@ -88,6 +88,24 @@ For a standalone release, replace `--development` with `--sign-key` and `--sign-cert` pointing to the existing application signer outside the checkout. Do not use the ROM platform key for ordinary distribution. +Launch-time test hooks (the `test_actions` and demo settings in the +`makepad.APP_CONFIG` intent extra, AppCard's provisioning and seeding extras, +the kernel's `makepad.OCTOS_PROXY`) work only in a development build: any app +on the phone can start Home with extras, so a release Home ignores them +(`crates/shell/src/launch_input.rs`). `--dev-mode` compiles that feature (see +"Developer options and a separate test Home" below for its rules); give it a +test `--package-name` so it installs beside the real Home. The plan is unit +tested; the build and a device run are **unverified**: + +```sh +rom/scripts/build-home.sh --variant standalone --development --dev-mode \ + --package-name dev.makepad.octosense.devtest \ + --sdk /path/to/makepad-android \ + --android-sdk /path/to/android-sdk \ + --gradle-home /path/to/gradle-8.11.1 \ + --java-home /path/to/full-jdk +``` + ROM Home and Bridge, signed with the existing ROM platform identity: ```sh @@ -261,5 +279,11 @@ pill; those gestures remain available to the host OS. Home paging and the central pull for the app library still work inside the content area. Artifacts and source/hash receipts go to `out/home/ohos/`. The optional `--remote-port` enables app-owned loopback inspection for validation and writes -to `out/home/ohos-validation/`; omit it for the normal package. Existing warnings +to `out/home/ohos-validation/`; omit it for the normal package. +The staged entry ability passes no launch parameters on: any app can start the +exported ability with parameters, and Makepad would turn each `makepad.*` one +into environment variables. `--dev-mode` builds a development HAP instead +(`--features dev-mode`, launch parameters passed on for test hooks) and writes +to a directory ending in `-dev`; it needs a `--bundle-id` other than +`dev.makepad.octosense` (**unverified**: no HAP was built). Existing warnings remain. Android device acceptance still requires an ordinary Android phone. diff --git a/rom/scripts/build-home-ohos.py b/rom/scripts/build-home-ohos.py index 758eef2ac..93ed1b866 100644 --- a/rom/scripts/build-home-ohos.py +++ b/rom/scripts/build-home-ohos.py @@ -22,6 +22,26 @@ def read_json5(path): return json.loads(token.sub(lambda m: m.group() if m.group().startswith('"') else '', path.read_text())) +# The one line of phone/ohos/EntryAbility.ets that decides whether launch +# parameters reach Makepad's environment. Only a development build turns it on. +FORWARD_LAUNCH_PARAMETERS = 'const FORWARD_LAUNCH_PARAMETERS: boolean = false;' + + +def stage_entry_ability(project, home, dev_mode): + """Copy the product's EntryAbility over the template's. Any app can start + the exported ability with parameters, and Makepad turns each `makepad.*` + one into environment variables, so a release build forwards none; a + development build (`--dev-mode`) forwards them for test hooks.""" + source = (home / 'ohos/EntryAbility.ets').read_text() + if source.count(FORWARD_LAUNCH_PARAMETERS) != 1: + raise RuntimeError('EntryAbility.ets must declare FORWARD_LAUNCH_PARAMETERS false exactly once') + if dev_mode: + source = source.replace(FORWARD_LAUNCH_PARAMETERS, FORWARD_LAUNCH_PARAMETERS.replace('false', 'true')) + target = project / 'entry/src/main/ets/entryability/EntryAbility.ets' + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(source) + + def stage_app_icons(project, home): """Replace the pinned template's application, ability and launch icons.""" source = home / 'ohos/icons' @@ -44,6 +64,9 @@ def main(): p.add_argument('--packager', required=True, type=Path) p.add_argument('--compatible-sdk', default='6.0.1(21)') p.add_argument('--remote-port', type=int, help='Validation only: app-owned loopback inspection') + p.add_argument('--dev-mode', action='store_true', + help='Development test build: --features dev-mode, and launch parameters reach the app ' + '(test hooks). Needs a separate --bundle-id') p.add_argument('--offline', action='store_true') args = p.parse_args() for name in ('deveco_home', 'signing_config', 'packager'): @@ -52,6 +75,8 @@ def main(): p.error('--remote-port must be between 1024 and 65535') if not re.fullmatch(r'[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+', args.bundle_id): p.error('Invalid bundle ID') + if args.dev_mode and args.bundle_id == 'dev.makepad.octosense': + p.error('A --dev-mode build takes launch-time test hooks; give it a separate --bundle-id') if args.signing_config.is_relative_to(ROOT): p.error('Keep signing configuration outside the checkout') configs = json.loads(args.signing_config.read_text()) @@ -84,6 +109,8 @@ def main(): if args.remote_port: env['MAKEPAD_REMOTE'] = str(args.remote_port) cargo_args = ['-p', 'octosense-home', '--release', '--locked'] + if args.dev_mode: + cargo_args += ['--features', 'dev-mode'] if args.offline: cargo_args.append('--offline') command = [str(args.packager), 'makepad', 'ohos', '--deveco-home=' + str(args.deveco_home), '--arch=aarch64'] @@ -95,9 +122,9 @@ def main(): shutil.copytree(REPO / '.sources/makepad/tools/open_harmony/deveco', project, dirs_exist_ok=True) stage_app_icons(project, home) # Product-owned window policy: keep native system navigation visible and - # keep the floating controls inside its safe area. - shutil.copy2(home / 'ohos/EntryAbility.ets', - project / 'entry/src/main/ets/entryability/EntryAbility.ets') + # keep the floating controls inside its safe area. A release build passes + # no launch parameters on. + stage_entry_ability(project, home, args.dev_mode) subprocess.run(['patch', '--batch', '--forward', '-p1', '-i', str(home / 'ohos/keyboard.patch')], cwd=project, check=True) app_path = project / 'AppScope/app.json5' @@ -130,12 +157,13 @@ def main(): build['app']['signingConfigs'] = [] build_path.write_text(json.dumps(build, indent=2) + '\n') hap = project / 'entry/build/default/outputs/default/makepad-default-signed.hap' - output = ROOT / 'out/home' / ('ohos-validation' if args.remote_port else 'ohos') + output = ROOT / 'out/home' / (('ohos-validation' if args.remote_port else 'ohos') + ('-dev' if args.dev_mode else '')) output.mkdir(parents=True, exist_ok=True) shutil.copy2(hap, output / 'OctoSenseHome.hap') receipt = { 'schema_version': 1, 'variant': 'ohos', 'bundle_id': args.bundle_id, 'validation_remote': bool(args.remote_port), + 'dev_mode': args.dev_mode, 'source_revision': subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip(), 'source_dirty': bool(subprocess.check_output(['git', 'status', '--porcelain'], cwd=ROOT, text=True)), 'packager': str(args.packager), diff --git a/rom/tests/test_home_build.py b/rom/tests/test_home_build.py index 5fd692895..73a9669b6 100644 --- a/rom/tests/test_home_build.py +++ b/rom/tests/test_home_build.py @@ -7,6 +7,7 @@ import json from pathlib import Path import re +import sys import tempfile import unittest from unittest import mock @@ -51,6 +52,37 @@ def test_product_icons_replace_the_template_on_every_build(self): self.assertEqual((entry_media / "unrelated.png").read_bytes(), b"keep") +class OpenHarmonyLaunchParameterTests(unittest.TestCase): + """Any app can start Home's exported ability with parameters, and Makepad + turns each `makepad.*` one into environment variables: only a development + build passes them on.""" + + def test_the_product_ability_forwards_none_by_default(self): + source = (ROOT.parent / "phone/ohos/EntryAbility.ets").read_text() + self.assertEqual(source.count(ohos.FORWARD_LAUNCH_PARAMETERS), 1) + self.assertLess(source.index("if (FORWARD_LAUNCH_PARAMETERS)"), source.index("want.parameters"), + "the parameters are read only behind the switch") + + def test_only_a_dev_mode_build_forwards_them(self): + with tempfile.TemporaryDirectory() as temp: + project = Path(temp) + staged = project / "entry/src/main/ets/entryability/EntryAbility.ets" + ohos.stage_entry_ability(project, ROOT.parent / "phone", False) + self.assertEqual(staged.read_text(), (ROOT.parent / "phone/ohos/EntryAbility.ets").read_text()) + ohos.stage_entry_ability(project, ROOT.parent / "phone", True) + text = staged.read_text() + self.assertIn("const FORWARD_LAUNCH_PARAMETERS: boolean = true;", text) + self.assertNotIn(ohos.FORWARD_LAUNCH_PARAMETERS, text) + + def test_a_dev_mode_build_needs_its_own_bundle_id(self): + argv = ["build-home-ohos.py", "--deveco-home", "/deveco", "--signing-config", "/private/signing.json", + "--packager", "/tools/cargo-makepad", "--dev-mode"] + with mock.patch.object(sys, "argv", argv), contextlib.redirect_stderr(io.StringIO()) as err, \ + self.assertRaises(SystemExit): + ohos.main() + self.assertIn("separate --bundle-id", err.getvalue()) + + class BuildTests(unittest.TestCase): def args(self, *extra): return build.arguments(["--sdk", "/sdk with spaces", "--android-sdk", "/android", *extra]) diff --git a/runtime-patches.lock.json b/runtime-patches.lock.json index 8b5c0021c..35933d1d4 100644 --- a/runtime-patches.lock.json +++ b/runtime-patches.lock.json @@ -4,7 +4,7 @@ "base_revision": "d653bc4493619ab979ccff165cf48c0c63b77d19", "patch": "tools/runtime-patches/makepad-settings.patch", "sha256": "3a79cddddc89dc4d064a9f0bdd17dc8111dcab9c796a334d95f61ecb9d5011aa", - "tree": "3f4fd959f1ee43445bc7ef13cf72465bb1951829", + "tree": "d047e32fdfb91aa26b95a62464645020bfa6a4f7", "product_fixes": [ "Settings integration: Android IME synchronization, accessibility and reduced motion, GLES program-cache validation, obscured-gesture cancellation and scroll-position observation.", "Contained-app isolation and secret-input restrictions come from the pinned upstream runtime; the Settings overlay preserves those gates.", @@ -48,7 +48,8 @@ "A native host may stage up to 64 MiB into an app's jail (MAX_IMPORT_BYTES; prepare_import and commit_import), so files.import can take documents above the 1 MiB script write limit. Staged bytes never enter the script heap; the jail's byte quota and entry cap still apply, and script writes, photo picks and exports keep 1 MiB.", "An OS resume restarts only the Video playback its pause stopped, including a start it cancelled; a player paused by the person, or paused or stopped by its app during the pause, stays paused (#406).", "Camera capture and recording use explicit per-call boolean audio/library options, defaulting to silent app-local output. Permission availability never opts into recording audio or exporting captures; async microphone checks retain capture intent and background provenance. Unsupported library export fails before capture.", - "A hidden Android native composer remembers animated IME insets without requesting a root layout every frame; showing it applies the latest inset before its first visible frame." + "A hidden Android native composer remembers animated IME insets without requesting a root layout every frame; showing it applies the latest inset before its first visible frame.", + "Launch-input hardening: the Studio connection and the OCTOS_PROXY app HTTP proxy, which Makepad itself acts on from an Android Intent extra, are honoured only when the build opts into launch-time development input (debuggable, or the manifest meta-data dev.makepad.LAUNCH_STUDIO). A shipped build ignores them and drops any Studio host a development build saved. The exported activity would otherwise let another installed app point it at a host or a proxy." ], "stacked": [ { @@ -190,6 +191,10 @@ { "patch": "tools/runtime-patches/makepad-hidden-composer-insets.patch", "sha256": "75935d129cfe4adf09555d3a94b0a7ad387f2ea97cf357f9d60c728d87239037" + }, + { + "patch": "tools/runtime-patches/makepad-launch-input-hardening.patch", + "sha256": "8eaafef3ef0591bec2a25c05d5187564d5dfb87a32e6b937b6d9c4e9f6fa5b84" } ] } diff --git a/tools/connected-e2e/ANDROID-BACKEND.md b/tools/connected-e2e/ANDROID-BACKEND.md index 5d9ffc257..edb827874 100644 --- a/tools/connected-e2e/ANDROID-BACKEND.md +++ b/tools/connected-e2e/ANDROID-BACKEND.md @@ -23,7 +23,7 @@ python3 tools/connected-e2e/android_backend_lab.py deploy \ --registrations "$LAB/prepared/backend-registrations.json" ``` -`JAVA_HOME` must point to the existing JDK for `apksigner`. For multiple attached devices, supply the assigned `--serial` to deploy; never publish it. Deployment refuses an already installed package. It transfers only the fresh signed fixture profile, configures one ADB loopback reverse, and passes the one-shot `makepad.APP_CONFIG` Intent extra. Android intentionally ignores a startup environment-only app config. A fresh suffix can be used consistently at APK build, packaging and deployment. +`JAVA_HOME` must point to the existing JDK for `apksigner`. For multiple attached devices, supply the assigned `--serial` to deploy; never publish it. Deployment refuses an already installed package. It transfers only the fresh signed fixture profile, configures one ADB loopback reverse, and passes the one-shot `makepad.APP_CONFIG` Intent extra. Android intentionally ignores a startup environment-only app config. Its `test_actions` run only because step 1 builds a development build (`--features dev-mode`): a release Home ignores launch-time test actions, since any app can start it with extras (`crates/shell/src/launch_input.rs`). A fresh suffix can be used consistently at APK build, packaging and deployment. The tracked `backend-login/glance.splash` is embedded by the preparation helper. Its publication is subject to the normal app's Glance grant. This source is test artwork and fixture UI, not a production listing. diff --git a/tools/connected-e2e/ANDROID-BACKEND.zh-CN.md b/tools/connected-e2e/ANDROID-BACKEND.zh-CN.md index 4a1924156..dcdc9c40c 100644 --- a/tools/connected-e2e/ANDROID-BACKEND.zh-CN.md +++ b/tools/connected-e2e/ANDROID-BACKEND.zh-CN.md @@ -10,7 +10,7 @@ 2. 在单独终端运行 `python3 tools/backend-login-fixture.py --directory "$LAB/server"` 并保持运行。metadata 只有本地公开端点;不要整体发布运行目录。 3. 按英文页的三个命令运行 `android_backend_lab.py prepare`、`android_backend.py` 和 `android_backend_lab.py deploy`。准备过程通过普通签名目录/安装校验,包含两个固定测试应用 ID。设置已有 JDK 的 `JAVA_HOME` 供 apksigner 使用;多设备时只选择指定的 `--serial`,不得公开设备编号。 -部署拒绝已存在的包,只传输新建的已签名测试目录,建立一个 ADB loopback reverse,并通过一次性 `makepad.APP_CONFIG` Intent extra 启动。Android 会清除只有环境变量的 app config,因此不能省略 Intent。需要新后缀时,APK 构建、封装和部署必须使用同一包名。准备脚本会嵌入已跟踪的 `backend-login/glance.splash`,发布仍经过普通应用 Glance 权限校验。测试插图和界面不是生产应用素材。 +部署拒绝已存在的包,只传输新建的已签名测试目录,建立一个 ADB loopback reverse,并通过一次性 `makepad.APP_CONFIG` Intent extra 启动。Android 会清除只有环境变量的 app config,因此不能省略 Intent。其中的 `test_actions` 之所以会执行,是因为第 1 步构建的是开发版(`--features dev-mode`):任何应用都能带着 extra 启动 Home,所以发布版 Home 会忽略启动时的测试动作(`crates/shell/src/launch_input.rs`)。需要新后缀时,APK 构建、封装和部署必须使用同一包名。准备脚本会嵌入已跟踪的 `backend-login/glance.splash`,发布仍经过普通应用 Glance 权限校验。测试插图和界面不是生产应用素材。 实际手机操作,不重放结果未知的输入: diff --git a/tools/connected-e2e/android-notes.md b/tools/connected-e2e/android-notes.md index 5b59f4cbe..ee3e786a0 100644 --- a/tools/connected-e2e/android-notes.md +++ b/tools/connected-e2e/android-notes.md @@ -73,6 +73,10 @@ the test package's `files/apps` using `run-as`, and launch `.MakepadApp` with th {"test_actions":["launch-hub:org.octosense.samples.githubnotes"]} ``` +The test action runs because the package above is a development build +(`--features dev-mode`); a release Home ignores launch-time test actions, +since any app can start it with extras (`crates/shell/src/launch_input.rs`). + Never install over the regular Home package or transfer an existing personal profile. The pinned Makepad remote implementation is a no-op on Android; `MAKEPAD_REMOTE` and ADB port forwarding do not enable it. Phone verification diff --git a/tools/connected-e2e/android-notes.zh-CN.md b/tools/connected-e2e/android-notes.zh-CN.md index d01a7d57a..763407d27 100644 --- a/tools/connected-e2e/android-notes.zh-CN.md +++ b/tools/connected-e2e/android-notes.zh-CN.md @@ -64,6 +64,10 @@ python3 tools/connected-e2e/android_notes.py \ {"test_actions":["launch-hub:org.octosense.samples.githubnotes"]} ``` +测试动作之所以会执行,是因为上面的测试包是开发版(`--features dev-mode`);任何应用 +都能带着 extra 启动 Home,所以发布版 Home 会忽略启动时的测试动作 +(`crates/shell/src/launch_input.rs`)。 + 不能覆盖日常 Home 或传输个人配置。固定版本的 Makepad remote 在 Android 是空实现, 设置 `MAKEPAD_REMOTE` 或 ADB 端口转发不会启用它。手机使用 ADB 输入和原始设备截图; Mac 浸泡使用 Makepad instrument,分别记录证据。 diff --git a/tools/runtime-patches/makepad-launch-input-hardening.patch b/tools/runtime-patches/makepad-launch-input-hardening.patch new file mode 100644 index 000000000..71447f6e7 --- /dev/null +++ b/tools/runtime-patches/makepad-launch-input-hardening.patch @@ -0,0 +1,322 @@ +diff --git a/platform/src/app_main.rs b/platform/src/app_main.rs +index a4b48f3d5..242649c56 100644 +--- a/platform/src/app_main.rs ++++ b/platform/src/app_main.rs +@@ -195,6 +195,19 @@ pub(crate) fn resolve_studio_crate() -> Option { + }) + } + ++/// Whether a build honours launch-time development input that Makepad itself ++/// acts on: the Studio connection and the `OCTOS_PROXY` app HTTP proxy, both ++/// carried in an Android Intent extra (or an OpenHarmony launch parameter). ++/// An activity that handles those is usually exported, so another installed ++/// app can start it with extras; a shipped app must not act on them. True for ++/// a debuggable build, or one that opts in through its manifest (on Android, ++/// the `dev.makepad.LAUNCH_STUDIO` meta-data). The Android glue ++/// (`os::linux::android::android_jni`) passes both signals in. ++#[cfg_attr(not(target_os = "android"), allow(dead_code))] ++pub(crate) fn launch_dev_input_allowed(debuggable: bool, manifest_opt_in: bool) -> bool { ++ debuggable || manifest_opt_in ++} ++ + fn build_studio_http( + studio_host: &str, + studio_build: Option<&str>, +@@ -239,6 +252,14 @@ pub fn resolve_studio_http() -> String { + mod tests { + use super::*; + ++ #[test] ++ fn only_debuggable_or_opted_in_builds_honour_launch_dev_input() { ++ assert!(launch_dev_input_allowed(true, false), "a debuggable build"); ++ assert!(launch_dev_input_allowed(false, true), "a manifest opt-in"); ++ assert!(launch_dev_input_allowed(true, true)); ++ assert!(!launch_dev_input_allowed(false, false), "a shipped build ignores launch dev input"); ++ } ++ + #[test] + fn extract_studio_build_id_handles_app_paths() { + assert_eq!( +diff --git a/platform/src/os/linux/android/android_jni.rs b/platform/src/os/linux/android/android_jni.rs +index 1cfc4d0a1..6b7c9468c 100644 +--- a/platform/src/os/linux/android/android_jni.rs ++++ b/platform/src/os/linux/android/android_jni.rs +@@ -526,6 +526,136 @@ unsafe fn persist_string_pref( + true + } + ++/// Remove a persisted preference. Used to drop a Studio host a development ++/// build saved, so it cannot survive into a shipped build. ++unsafe fn remove_persisted_pref( ++ env: *mut jni_sys::JNIEnv, ++ activity: jni_sys::jobject, ++ key: &str, ++) -> bool { ++ let prefs = match get_prefs_object(env, activity) { ++ Some(v) => v, ++ None => return false, ++ }; ++ let editor = ndk_utils::call_object_method!( ++ env, ++ prefs, ++ "edit", ++ "()Landroid/content/SharedPreferences$Editor;" ++ ); ++ if editor.is_null() { ++ return false; ++ } ++ let key = match new_jstring(env, key) { ++ Some(v) => v, ++ None => return false, ++ }; ++ let editor = ndk_utils::call_object_method!( ++ env, ++ editor, ++ "remove", ++ "(Ljava/lang/String;)Landroid/content/SharedPreferences$Editor;", ++ key ++ ); ++ if editor.is_null() { ++ return false; ++ } ++ ndk_utils::call_void_method!(env, editor, "apply", "()V"); ++ true ++} ++ ++/// Whether this build is debuggable (`android:debuggable="true"`): the ++/// ApplicationInfo `FLAG_DEBUGGABLE` bit. ++unsafe fn activity_is_debuggable(env: *mut jni_sys::JNIEnv, activity: jni_sys::jobject) -> bool { ++ let app_info = ndk_utils::call_object_method!( ++ env, ++ activity, ++ "getApplicationInfo", ++ "()Landroid/content/pm/ApplicationInfo;" ++ ); ++ if app_info.is_null() { ++ return false; ++ } ++ let class = ((**env).GetObjectClass.unwrap())(env, app_info); ++ if class.is_null() { ++ return false; ++ } ++ let field = ((**env).GetFieldID.unwrap())(env, class, b"flags\0".as_ptr() as _, b"I\0".as_ptr() as _); ++ if field.is_null() { ++ return false; ++ } ++ const FLAG_DEBUGGABLE: i32 = 2; // ApplicationInfo.FLAG_DEBUGGABLE ++ ((**env).GetIntField.unwrap())(env, app_info, field) & FLAG_DEBUGGABLE != 0 ++} ++ ++/// A boolean `` on the application element, default false. Lets a ++/// specific signed build opt into launch-time development input without being ++/// debuggable. ++unsafe fn application_meta_bool( ++ env: *mut jni_sys::JNIEnv, ++ activity: jni_sys::jobject, ++ key: &str, ++) -> bool { ++ let pm = ndk_utils::call_object_method!( ++ env, ++ activity, ++ "getPackageManager", ++ "()Landroid/content/pm/PackageManager;" ++ ); ++ if pm.is_null() { ++ return false; ++ } ++ let package = ndk_utils::call_object_method!( ++ env, ++ activity, ++ "getPackageName", ++ "()Ljava/lang/String;" ++ ); ++ if package.is_null() { ++ return false; ++ } ++ const GET_META_DATA: i32 = 128; // PackageManager.GET_META_DATA ++ let app_info = ndk_utils::call_object_method!( ++ env, ++ pm, ++ "getApplicationInfo", ++ "(Ljava/lang/String;I)Landroid/content/pm/ApplicationInfo;", ++ package, ++ GET_META_DATA ++ ); ++ // getApplicationInfo for our own package never throws, but clear any ++ // pending exception so the next JNI call is not poisoned. ++ if (**env).ExceptionCheck.map(|f| f(env)).unwrap_or(0) != 0 { ++ ((**env).ExceptionClear.unwrap())(env); ++ return false; ++ } ++ if app_info.is_null() { ++ return false; ++ } ++ let class = ((**env).GetObjectClass.unwrap())(env, app_info); ++ if class.is_null() { ++ return false; ++ } ++ let field = ((**env).GetFieldID.unwrap())( ++ env, ++ class, ++ b"metaData\0".as_ptr() as _, ++ b"Landroid/os/Bundle;\0".as_ptr() as _, ++ ); ++ if field.is_null() { ++ return false; ++ } ++ let bundle = ((**env).GetObjectField.unwrap())(env, app_info, field); ++ if bundle.is_null() { ++ return false; ++ } ++ let key = match new_jstring(env, key) { ++ Some(v) => v, ++ None => return false, ++ }; ++ ndk_utils::call_bool_method!(env, bundle, "getBoolean", "(Ljava/lang/String;Z)Z", key, 0i32) != 0 ++} ++ + pub unsafe fn apply_studio_env_from_activity(activity: *const std::ffi::c_void) { + if activity.is_null() { + return; +@@ -533,32 +663,55 @@ pub unsafe fn apply_studio_env_from_activity(activity: *const std::ffi::c_void) + let env = attach_jni_env(); + let activity = activity as jni_sys::jobject; + ++ // A launch Intent's extras are untrusted: this activity is usually ++ // exported (an app's launcher entry, and OctoSense Home is a Home app and ++ // a share/deep-link target), so any installed app can start it with ++ // extras. The Studio connection and the OCTOS_PROXY JVM proxy below are ++ // things Makepad itself acts on, so they are honoured from launch input ++ // only when the build opts into launch-time development input: ++ // `android:debuggable="true"`, or the manifest meta-data ++ // `dev.makepad.LAUNCH_STUDIO` set true. A shipped (non-debuggable) app ++ // ignores them, and drops any Studio host it saved under an earlier ++ // development build. The other `MAKEPAD_*` passthroughs below are inert ++ // until an app reads them; the app decides whether to. ++ let allow_launch_dev = crate::app_main::launch_dev_input_allowed( ++ activity_is_debuggable(env, activity), ++ application_meta_bool(env, activity, "dev.makepad.LAUNCH_STUDIO"), ++ ); ++ + std::env::remove_var("STUDIO"); + std::env::remove_var("STUDIO_BUILD"); + std::env::remove_var("STUDIO_HOST"); + std::env::remove_var("STUDIO_CRATE"); + +- let intent_studio_host = get_intent_string_extra(env, activity, "makepad.STUDIO_HOST") +- .filter(|v| !v.trim().is_empty()); +- let intent_studio_crate = get_intent_string_extra(env, activity, "makepad.STUDIO_CRATE") +- .filter(|v| !v.trim().is_empty()); +- +- if let Some(studio_host) = intent_studio_host { +- let _ = persist_string_pref(env, activity, MAKEPAD_STUDIO_HOST_PREF_KEY, &studio_host); +- std::env::set_var("STUDIO_HOST", &studio_host); +- } else if let Some(studio_host) = +- get_persisted_string_pref(env, activity, MAKEPAD_STUDIO_HOST_PREF_KEY) +- { +- std::env::set_var("STUDIO_HOST", &studio_host); +- } ++ if allow_launch_dev { ++ let intent_studio_host = get_intent_string_extra(env, activity, "makepad.STUDIO_HOST") ++ .filter(|v| !v.trim().is_empty()); ++ let intent_studio_crate = get_intent_string_extra(env, activity, "makepad.STUDIO_CRATE") ++ .filter(|v| !v.trim().is_empty()); ++ ++ if let Some(studio_host) = intent_studio_host { ++ let _ = persist_string_pref(env, activity, MAKEPAD_STUDIO_HOST_PREF_KEY, &studio_host); ++ std::env::set_var("STUDIO_HOST", &studio_host); ++ } else if let Some(studio_host) = ++ get_persisted_string_pref(env, activity, MAKEPAD_STUDIO_HOST_PREF_KEY) ++ { ++ std::env::set_var("STUDIO_HOST", &studio_host); ++ } + +- if let Some(studio_crate) = intent_studio_crate { +- let _ = persist_string_pref(env, activity, MAKEPAD_STUDIO_CRATE_PREF_KEY, &studio_crate); +- std::env::set_var("STUDIO_CRATE", &studio_crate); +- } else if let Some(studio_crate) = +- get_persisted_string_pref(env, activity, MAKEPAD_STUDIO_CRATE_PREF_KEY) +- { +- std::env::set_var("STUDIO_CRATE", &studio_crate); ++ if let Some(studio_crate) = intent_studio_crate { ++ let _ = persist_string_pref(env, activity, MAKEPAD_STUDIO_CRATE_PREF_KEY, &studio_crate); ++ std::env::set_var("STUDIO_CRATE", &studio_crate); ++ } else if let Some(studio_crate) = ++ get_persisted_string_pref(env, activity, MAKEPAD_STUDIO_CRATE_PREF_KEY) ++ { ++ std::env::set_var("STUDIO_CRATE", &studio_crate); ++ } ++ } else { ++ // Never let a Studio host survive into a shipped build: an app built ++ // debuggable once, then released, must not keep dialing it. ++ let _ = remove_persisted_pref(env, activity, MAKEPAD_STUDIO_HOST_PREF_KEY); ++ let _ = remove_persisted_pref(env, activity, MAKEPAD_STUDIO_CRATE_PREF_KEY); + } + + // Generic app-config passthrough: `--es makepad.APP_CONFIG ` +@@ -673,10 +826,18 @@ pub unsafe fn apply_studio_env_from_activity(activity: *const std::ffi::c_void) + // `direct` / `none` / `off` / empty (or no extra at all) => NO proxy: both the + // octos LLM leg and the app-side sys.* fetches go straight out over the + // device's own network (Wi-Fi). Any other value is a proxy URL to tunnel. +- let proxy = get_intent_string_extra(env, activity, "makepad.OCTOS_PROXY").filter(|v| { +- let t = v.trim().to_ascii_lowercase(); +- !t.is_empty() && t != "direct" && t != "none" && t != "off" +- }); ++ // Makepad acts on this itself (the app-side JVM proxy below), so like the ++ // Studio connection it is honoured from launch input only when the build ++ // opts into launch-time development input; a shipped app always clears ++ // the proxy. ++ let proxy = allow_launch_dev ++ .then(|| { ++ get_intent_string_extra(env, activity, "makepad.OCTOS_PROXY").filter(|v| { ++ let t = v.trim().to_ascii_lowercase(); ++ !t.is_empty() && t != "direct" && t != "none" && t != "off" ++ }) ++ }) ++ .flatten(); + if let Some(proxy) = proxy { + std::env::set_var("MAKEPAD_OCTOS_PROXY", &proxy); + // App-side leg: MakepadNetwork opens connections with a bare +diff --git a/tools/cargo_makepad/src/android/java/dev/makepad/android/MakepadActivity.java b/tools/cargo_makepad/src/android/java/dev/makepad/android/MakepadActivity.java +index e5042071b..78d377fc0 100644 +--- a/tools/cargo_makepad/src/android/java/dev/makepad/android/MakepadActivity.java ++++ b/tools/cargo_makepad/src/android/java/dev/makepad/android/MakepadActivity.java +@@ -1521,6 +1521,24 @@ public class MakepadActivity + clearWarmResumeSurfaceSnapshot(); + } + ++ // Whether this build acts on launch-time development input that Makepad ++ // itself handles (the OCTOS_PROXY app proxy here, the Studio connection ++ // on the native side): a debuggable build, or one that opts in with the ++ // `dev.makepad.LAUNCH_STUDIO` manifest meta-data. A shipped app does ++ // neither, so an Intent extra from another app cannot point it at a proxy. ++ private boolean launchDevInputAllowed() { ++ try { ++ ApplicationInfo info = getPackageManager().getApplicationInfo( ++ getPackageName(), PackageManager.GET_META_DATA); ++ boolean debuggable = (info.flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0; ++ boolean optIn = info.metaData != null ++ && info.metaData.getBoolean("dev.makepad.LAUNCH_STUDIO", false); ++ return debuggable || optIn; ++ } catch (Exception e) { ++ return false; ++ } ++ } ++ + @Override + public void onCreate(Bundle savedInstanceState) { + if (mWebSocketsThread == null || !mWebSocketsThread.isAlive()) { +@@ -1544,9 +1562,12 @@ public class MakepadActivity + // is set — needed when the device has no direct internet route (e.g. an + // adb-reverse tunnel to the dev host). HttpURLConnection honours these + // JVM proxy system properties, so no per-request change is needed. ++ // This activity is usually exported, so the extra is untrusted: only a ++ // build that opts into launch-time development input acts on it (the ++ // native side keeps the same rule and clears the properties otherwise). + try { + Intent launchIntent = getIntent(); +- String proxy = launchIntent != null ++ String proxy = launchDevInputAllowed() && launchIntent != null + ? launchIntent.getStringExtra("makepad.OCTOS_PROXY") : null; + if (proxy != null && proxy.length() > 0) { + String hp = proxy.replaceFirst("^https?://", "").replaceAll("/.*$", "");