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/Cargo.lock b/Cargo.lock index f7c58b7cf..a9fe5e435 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11803,6 +11803,7 @@ dependencies = [ "pdfcraft-annot", "pdfcraft-automation", "pdfcraft-cos", + "pdfcraft-edit", "pdfcraft-xfa", "serde_json", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index e88a29e52..fa5c96c50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -288,6 +288,9 @@ pdfcraft-automation = { git = "https://github.com/ymote/pdfcraft.git", rev = "61 # reader, which keep `/M` with its zone (the engine's listing drops it). pdfcraft-cos = { git = "https://github.com/ymote/pdfcraft.git", rev = "615ff308173a3dd7c595fc598508b519a895c0e9" } pdfcraft-annot = { git = "https://github.com/ymote/pdfcraft.git", rev = "615ff308173a3dd7c595fc598508b519a895c0e9" } +# The pdf service's edit check: the engine's own text editor, run on a copy of +# the open document to see what an edit would do before PDF Tools makes it. +pdfcraft-edit = { git = "https://github.com/ymote/pdfcraft.git", rev = "615ff308173a3dd7c595fc598508b519a895c0e9" } # The pdf service's tests only: the engine's own scripted XFA form, the # hostile fixture of the scripts-off fence (ADR 0013). pdfcraft-xfa = { git = "https://github.com/ymote/pdfcraft.git", rev = "615ff308173a3dd7c595fc598508b519a895c0e9" } diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 4d995e4ad..bcb526aa5 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 @@ -119,8 +120,18 @@ app's own agent and `tools.json`, the system toolbox, `glance.publish` and cleared before a write would fail with `storage_full:`. `pdf.comments` reads each date with its zone from the file once per version of the document (`src/review.rs`). `pdf.fields` marks - read-only fields, and `pdf.fill` refuses one. Errors start with a stable - code (`src/codes.rs`). + read-only fields, and `pdf.fill` refuses one. `pdf.edit_check` takes + `pdf.edit_text`'s arguments and says what that edit would do before PDF + Tools makes it (the font it would be drawn in, the lines of text it would + run into, text off the page), by running the engine's own editor + (pdfcraft-edit) on a copy opened from the open document's working bytes + and dropping it (`src/fit.rs`): no engine command, nothing written. Both + refuse an edit the engine won't make in plain words (text no available + font shows, such as Chinese, Japanese or Korean without the engine's + fallback face; a read-only or locked document; a stale paragraph number), + still `invalid:` (`codes::edit_refused`); the `--edit-sample` page of + `pdftools_fixture` reproduces the real-world cases. Errors start with a + stable code (`src/codes.rs`). - Declare an app's agent in its manifest and `bundle/tools.json`. Keep the input/output schemas consistent with the executor (octos requires an object output schema), and select the actual risk, sharing and confirmation policy. 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/apps/pdf/host-service/Cargo.toml b/apps/pdf/host-service/Cargo.toml index f8cbd2972..432dc07da 100644 --- a/apps/pdf/host-service/Cargo.toml +++ b/apps/pdf/host-service/Cargo.toml @@ -29,6 +29,10 @@ pdfcraft-automation = { workspace = true } # reader, on the open document's working bytes, read only (src/review.rs). pdfcraft-cos = { workspace = true } pdfcraft-annot = { workspace = true } +# What an edit would do before it is made (`pdf.edit_check`, src/fit.rs): the +# engine's own paragraph and line rewriting, on a copy of the open document's +# working bytes that is then dropped. +pdfcraft-edit = { workspace = true } # This device's offset from UTC, from where the shell's status clock reads it # (crates/shell/src/shell/bar.rs): localtime_r on Unix, chrono elsewhere. diff --git a/apps/pdf/host-service/examples/pdftools_fixture.rs b/apps/pdf/host-service/examples/pdftools_fixture.rs index ed9f6259a..d2f7ddcc7 100644 --- a/apps/pdf/host-service/examples/pdftools_fixture.rs +++ b/apps/pdf/host-service/examples/pdftools_fixture.rs @@ -13,13 +13,17 @@ //! With `--import-sample ` it writes one more PDF, a three-page garden //! plan, to ``: what a test places in the library as `Imported PDF.pdf`, //! the name PDF Tools gives the first PDF it opens from the device, to drive -//! the path an imported PDF takes without the host's file dialog. The PDFs are -//! written by hand below, so they are the same on every run and need no -//! files of their own. Nothing here calls the engine. +//! the path an imported PDF takes without the host's file dialog. With +//! `--edit-sample ` it writes a page as a web browser prints it, whose +//! fonts break text editing the way real-world PDFs do (`printed_flyer` +//! below), for PDF Tools' Edit. The PDFs are written by hand below, so they +//! are the same on every run and need no files of their own. Nothing here +//! calls the engine. //! //! ```sh //! cargo run --locked -p octosense-pdf-service --example pdftools_fixture -- //! cargo run --locked -p octosense-pdf-service --example pdftools_fixture -- --import-sample +//! cargo run --locked -p octosense-pdf-service --example pdftools_fixture -- --edit-sample //! ``` use std::path::{Path, PathBuf}; @@ -36,8 +40,9 @@ fn main() { let args: Vec = std::env::args().skip(1).collect(); let written = match args.as_slice() { [flag, file] if flag == "--import-sample" => write(Path::new(file), &garden_plan()).map(|_| PathBuf::from(file)), + [flag, file] if flag == "--edit-sample" => write(Path::new(file), &printed_flyer()).map(|_| PathBuf::from(file)), [root] if !root.starts_with('-') => samples(Path::new(root)), - _ => Err("usage: pdftools_fixture | --import-sample ".into()), + _ => Err("usage: pdftools_fixture | --import-sample | --edit-sample ".into()), }; match written { Ok(path) => println!("Wrote {}", path.display()), @@ -558,3 +563,362 @@ pub(crate) fn garden_plan() -> Vec { pages.push(calendar); pdf(&info(title, "Allotment 14", "What grows where, and when to sow it", "garden, plan, allotment"), pages) } + +// ---------------------------------------------------- a page printed from the web +// +// Real-world PDFs break text editing in ways the samples above never do (a +// page a web browser printed, 11 Oct 2026). This sample has all three, in +// fonts made here, so the service's tests and PDF Tools' Edit can be held to +// honest answers: +// +// - A designed title in pieces. "Studio2026" is in an unnamed Type 3 font, +// then 春, 季, 发布 and 会 alternate between two more, each piece in its own +// text object, as a browser prints a title whose glyphs span several fonts. +// The engine reads five paragraphs where a person sees one title, and a +// longer "Studio2026" runs into the pieces after it. +// - Chinese text in an embedded subset font: a CID-keyed TrueType program +// with only the glyphs its paragraph uses (Identity-H, FontFile2 and a +// ToUnicode map), as office programs and browsers embed one. A Chinese +// character it lacks needs a Chinese face the engine doesn't have. +// - Subsets everywhere: a character outside a piece's or the paragraph's font +// makes the engine draw the edit in a stand-in (Helvetica), with other +// widths than the original. +// +// The glyphs are blocky placeholders drawn from bitmaps here (no one's font +// is copied); the text layer, the widths and the font dictionaries are what a +// browser writes. + +/// The page `--edit-sample` writes (the notes above): US Letter, as a +/// browser prints one. +pub(crate) fn printed_flyer() -> Vec { + const TITLE: f64 = 27.8; + const BODY: f64 = 12.65; + let mut objs: Vec> = vec![Vec::new(), Vec::new(), Vec::new()]; // the catalog, the pages, the page: last + let helvetica = add(&mut objs, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec()); + let latin = ['S', 't', 'u', 'd', 'i', 'o', '2', '0', '6']; + let first = ['春', '发', '布']; + let second = ['季', '会']; + let t1 = type3(&mut objs, &latin, latin_glyph); + let t2 = type3(&mut objs, &first, cjk_glyph); + let t3 = type3(&mut objs, &second, cjk_glyph); + let lines = ["春季发布会展示新的应用和工具", "报名截止 2026 年 4 月 30 日"]; + let mut body: Vec = Vec::new(); + for c in lines.iter().flat_map(|l| l.chars()) { + if !body.contains(&c) { + body.push(c); + } + } + let c1 = cid_subset(&mut objs, "ABCDEF+FixtureSans-SC", &body); + + let mut ops = String::new(); + ops.push_str("BT /F1 9.14 Tf 0.35 0.37 0.4 rg 1 0 0 1 44.62 708 Tm (OctoSense sample - a page as a web browser prints it) Tj ET\n"); + // The title, a piece at a time: each in its own text object and font. + let mut x = 44.62; + for (font, chars, text) in [("T1", &latin[..], "Studio2026"), ("T2", &first[..], "春"), ("T3", &second[..], "季"), ("T2", &first[..], "发布"), ("T3", &second[..], "会")] { + let codes: String = text.chars().map(|c| format!("{:02X}", chars.iter().position(|k| *k == c).unwrap() + 1)).collect(); + ops.push_str(&format!("BT /{font} {TITLE} Tf 0.07 0.09 0.15 rg 1 0 0 1 {x:.2} 670 Tm <{codes}> Tj ET\n")); + let em: i32 = text.chars().map(|c| i32::from(if c.is_ascii() { latin_glyph(c) } else { cjk_glyph(c) }.advance)).sum(); + x += f64::from(em) / 1000.0 * TITLE; + } + // The Chinese paragraph: two lines in the embedded subset. + let cids = |line: &str| -> String { line.chars().map(|c| format!("{:04X}", body.iter().position(|k| *k == c).unwrap() + 1)).collect() }; + ops.push_str(&format!("BT /C1 {BODY} Tf 0.12 0.14 0.16 rg 1 0 0 1 44.62 630 Tm <{}> Tj 0 -22.5 Td <{}> Tj ET\n", cids(lines[0]), cids(lines[1]))); + // A paragraph in Helvetica just below it. + ops.push_str("BT /F1 12 Tf 0.12 0.14 0.16 rg 1 0 0 1 44.62 588 Tm (Doors open at nine in the main hall. Bring a) Tj 0 -16.5 Td (laptop and the badge we send you by email.) Tj ET\n"); + + let contents = add(&mut objs, pdf_stream(ops.as_bytes(), "")); + let info = add( + &mut objs, + b"<< /Title (Studio2026 spring launch) /Creator (PDF Tools sample generator: a page as a web browser prints it) \ + /Producer (octosense-pdf-service pdftools_fixture) /CreationDate (D:20261011090000Z) >>" + .to_vec(), + ); + objs[0] = b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(); + objs[1] = b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(); + objs[2] = format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 {helvetica} 0 R /T1 {t1} 0 R /T2 {t2} 0 R \ + /T3 {t3} 0 R /C1 {c1} 0 R >> >> /Contents {contents} 0 R >>" + ) + .into_bytes(); + let mut out = b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend(format!("{} 0 obj\n", i + 1).bytes()); + out.extend(body); + out.extend(b"\nendobj\n"); + } + let xref = out.len(); + out.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).bytes()); + for offset in offsets { + out.extend(format!("{offset:010} 00000 n \n").bytes()); + } + out.extend(format!("trailer\n<< /Size {} /Root 1 0 R /Info {info} 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).bytes()); + out +} + +/// Add an object: its number. +fn add(objs: &mut Vec>, body: Vec) -> usize { + objs.push(body); + objs.len() +} + +/// A stream object of `data`, with `extra` entries in its dictionary. +fn pdf_stream(data: &[u8], extra: &str) -> Vec { + let mut out = format!("<< /Length {}{extra} >>\nstream\n", data.len()).into_bytes(); + out.extend_from_slice(data); + out.extend_from_slice(b"\nendstream"); + out +} + +/// A glyph: its advance in thousandths of an em, and the filled boxes +/// `[x, y, w, h]` that draw it, y up from the baseline. +struct Glyph { + advance: i16, + boxes: Vec<[i16; 4]>, +} + +impl Glyph { + /// Its bounding box `[x0, y0, x1, y1]` (all zero for a blank glyph). + fn bounds(&self) -> [i16; 4] { + self.boxes.iter().fold(None, |b: Option<[i16; 4]>, r| { + let r = [r[0], r[1], r[0] + r[2], r[1] + r[3]]; + Some(b.map_or(r, |b| [b[0].min(r[0]), b[1].min(r[1]), b[2].max(r[2]), b[3].max(r[3])])) + }) + .unwrap_or([0; 4]) + } +} + +/// A 5 × 7 bitmap (top row first, the leftmost pixel the high bit) of each +/// Latin character the sample draws in its own fonts. +fn latin_bitmap(c: char) -> [u8; 7] { + match c { + 'S' => [0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110], + 't' => [0b00100, 0b00100, 0b11111, 0b00100, 0b00100, 0b00100, 0b00011], + 'u' => [0b00000, 0b00000, 0b10001, 0b10001, 0b10001, 0b10011, 0b01101], + 'd' => [0b00001, 0b00001, 0b01101, 0b10011, 0b10001, 0b10011, 0b01101], + 'i' => [0b00100, 0b00000, 0b01100, 0b00100, 0b00100, 0b00100, 0b01110], + 'o' => [0b00000, 0b00000, 0b01110, 0b10001, 0b10001, 0b10001, 0b01110], + '0' => [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110], + '2' => [0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111], + '3' => [0b11110, 0b00001, 0b00001, 0b01110, 0b00001, 0b00001, 0b11110], + '4' => [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010], + '6' => [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110], + _ => [0; 7], + } +} + +/// A Latin character in 100-unit pixels from its bitmap, each row's runs one +/// box; a space is blank. Advances: 600, a space 300. +fn latin_glyph(c: char) -> Glyph { + if c == ' ' { + return Glyph { advance: 300, boxes: Vec::new() }; + } + let mut boxes = Vec::new(); + for (row, bits) in latin_bitmap(c).iter().enumerate() { + let y = (6 - row as i16) * 100; + let mut col = 0; + while col < 5 { + if bits >> (4 - col) & 1 == 0 { + col += 1; + continue; + } + let start = col; + while col < 5 && bits >> (4 - col) & 1 == 1 { + col += 1; + } + boxes.push([50 + start as i16 * 100, y, (col - start) as i16 * 100, 100]); + } + } + Glyph { advance: 600, boxes } +} + +/// A Chinese character's placeholder: a top stroke and a spine, and strokes +/// picked by its code point, so the sample's characters look different. +fn cjk_glyph(c: char) -> Glyph { + let n = c as u32; + let mut boxes = vec![[120, 700, 760, 70], [465, -40, 70, 800]]; + let strokes = [[120, 470, 760, 60], [120, 240, 760, 60], [120, 20, 760, 60], [200, 120, 60, 520], [740, 120, 60, 520], [300, 560, 400, 50]]; + for (bit, stroke) in strokes.iter().enumerate() { + if n >> bit & 1 == 1 { + boxes.push(*stroke); + } + } + Glyph { advance: 1000, boxes } +} + +/// A ToUnicode CMap: code `i + 1` (`bytes` long) is `chars[i]`. +fn to_unicode(chars: &[char], bytes: usize) -> String { + let digits = bytes * 2; + let (lo, hi) = if bytes == 1 { ("00", "FF") } else { ("0000", "FFFF") }; + let mut s = format!( + "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n\ + /CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n1 begincodespacerange\n<{lo}> <{hi}>\nendcodespacerange\n{} beginbfchar\n", + chars.len() + ); + for (i, c) in chars.iter().enumerate() { + let mut units = [0u16; 2]; + let utf16: String = c.encode_utf16(&mut units).iter().map(|u| format!("{u:04X}")).collect(); + s.push_str(&format!("<{:0digits$X}> <{utf16}>\n", i + 1)); + } + s.push_str("endbfchar\nendcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n"); + s +} + +/// An unnamed Type 3 font as a browser embeds one: `chars` get the codes 1, +/// 2, … in order, each drawn by `glyph`, with a ToUnicode map. Its number. +fn type3(objs: &mut Vec>, chars: &[char], glyph: fn(char) -> Glyph) -> usize { + let (mut procs, mut widths) = (Vec::new(), Vec::new()); + for (i, &c) in chars.iter().enumerate() { + let g = glyph(c); + let [x0, y0, x1, y1] = g.bounds(); + let mut draw = format!("{} 0 {x0} {y0} {x1} {y1} d1\n", g.advance); + for [x, y, w, h] in &g.boxes { + draw.push_str(&format!("{x} {y} {w} {h} re\n")); + } + draw.push_str("f\n"); + procs.push(format!("/g{} {} 0 R", i + 1, add(objs, pdf_stream(draw.as_bytes(), "")))); + widths.push(g.advance.to_string()); + } + let cmap = add(objs, pdf_stream(to_unicode(chars, 1).as_bytes(), "")); + let names: Vec = (1..=chars.len()).map(|i| format!("/g{i}")).collect(); + add( + objs, + format!( + "<< /Type /Font /Subtype /Type3 /FontBBox [0 -120 1000 880] /FontMatrix [0.001 0 0 0.001 0 0] /CharProcs << {} >> \ + /Encoding << /Type /Encoding /Differences [1 {}] >> /FirstChar 1 /LastChar {} /Widths [{}] /ToUnicode {cmap} 0 R /Resources << >> >>", + procs.join(" "), + names.join(" "), + chars.len(), + widths.join(" ") + ) + .into_bytes(), + ) +} + +/// A CID-keyed TrueType subset (Type0, Identity-H) named `name`, with a glyph +/// for each of `chars` and no other: CID `i + 1` is `chars[i]`, its program +/// embedded (FontFile2) and its widths in `/W`. Its number. +fn cid_subset(objs: &mut Vec>, name: &str, chars: &[char]) -> usize { + let glyphs: Vec = chars.iter().map(|&c| if c.is_ascii() { latin_glyph(c) } else { cjk_glyph(c) }).collect(); + let program = truetype(&glyphs); + let file = add(objs, pdf_stream(&program, &format!(" /Length1 {}", program.len()))); + let descriptor = add( + objs, + format!( + "<< /Type /FontDescriptor /FontName /{name} /Flags 4 /FontBBox [0 -120 1000 880] /ItalicAngle 0 /Ascent 880 /Descent -120 \ + /CapHeight 700 /StemV 80 /FontFile2 {file} 0 R >>" + ) + .into_bytes(), + ); + let widths: Vec = glyphs.iter().map(|g| g.advance.to_string()).collect(); + let cid = add( + objs, + format!( + "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /{name} /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> \ + /FontDescriptor {descriptor} 0 R /DW 1000 /W [1 [{}]] /CIDToGIDMap /Identity >>", + widths.join(" ") + ) + .into_bytes(), + ); + let cmap = add(objs, pdf_stream(to_unicode(chars, 2).as_bytes(), "")); + add(objs, format!("<< /Type /Font /Subtype /Type0 /BaseFont /{name} /Encoding /Identity-H /DescendantFonts [{cid} 0 R] /ToUnicode {cmap} 0 R >>").into_bytes()) +} + +/// A TrueType program whose glyph 0 is blank (.notdef) and glyph `i + 1` +/// draws `glyphs[i]`'s boxes, one contour each: the tables a renderer needs +/// to draw outlines by glyph id (`glyf`, `head`, `hhea`, `hmtx`, `loca`, +/// `maxp`), which is how a CID-keyed font with an identity CIDToGIDMap reaches +/// its glyphs. +fn truetype(glyphs: &[Glyph]) -> Vec { + let blank = Glyph { advance: 500, boxes: Vec::new() }; + let all: Vec<&Glyph> = std::iter::once(&blank).chain(glyphs).collect(); + let push16 = |v: &mut Vec, x: i32| v.extend_from_slice(&(x as u16).to_be_bytes()); + let (mut glyf, mut loca, mut hmtx) = (Vec::new(), Vec::new(), Vec::new()); + let (mut most_points, mut most_contours) = (0, 0); + for g in &all { + loca.extend_from_slice(&(glyf.len() as u32).to_be_bytes()); + let [x0, y0, x1, y1] = g.bounds(); + push16(&mut hmtx, i32::from(g.advance)); + push16(&mut hmtx, i32::from(x0)); + if g.boxes.is_empty() { + continue; + } + let n = g.boxes.len() as i32; + most_points = most_points.max(n * 4); + most_contours = most_contours.max(n); + for v in [n, i32::from(x0), i32::from(y0), i32::from(x1), i32::from(y1)] { + push16(&mut glyf, v); + } + for k in 0..n { + push16(&mut glyf, k * 4 + 3); + } + push16(&mut glyf, 0); // no instructions + glyf.extend(std::iter::repeat_n(1u8, (n * 4) as usize)); // every point on the curve + // Each box clockwise from its lower left, as deltas from the point before. + let points: Vec<(i32, i32)> = g + .boxes + .iter() + .flat_map(|&[x, y, w, h]| { + let (x, y, w, h) = (i32::from(x), i32::from(y), i32::from(w), i32::from(h)); + [(x, y), (x, y + h), (x + w, y + h), (x + w, y)] + }) + .collect(); + for axis in 0..2 { + let mut last = 0; + for p in &points { + let v = if axis == 0 { p.0 } else { p.1 }; + push16(&mut glyf, v - last); + last = v; + } + } + while glyf.len() % 4 != 0 { + glyf.push(0); + } + } + loca.extend_from_slice(&(glyf.len() as u32).to_be_bytes()); + let count = all.len() as i32; + let mut head = Vec::new(); + for v in [1, 0] { + push16(&mut head, v); // version 1.0 + } + head.extend_from_slice(&0x0001_0000u32.to_be_bytes()); // font revision + head.extend_from_slice(&0u32.to_be_bytes()); // checksum adjustment + head.extend_from_slice(&0x5F0F_3CF5u32.to_be_bytes()); // magic number + push16(&mut head, 3); // flags: baseline and left side bearing at 0 + push16(&mut head, 1000); // units per em + head.extend_from_slice(&[0; 16]); // created, modified + for v in [0, -120, 1000, 880, 0, 8, 2, 1, 0] { + push16(&mut head, v); // bounds, mac style, lowest ppem, direction, long loca, glyph format + } + let mut hhea = 0x0001_0000u32.to_be_bytes().to_vec(); + for v in [880, -120, 0, 1000, 0, 0, 1000, 1, 0, 0, 0, 0, 0, 0, 0, count] { + push16(&mut hhea, v); // ascender … caret, reserved, metric format, metrics count + } + let mut maxp = 0x0001_0000u32.to_be_bytes().to_vec(); + for v in [count, most_points, most_contours, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0] { + push16(&mut maxp, v); + } + let tables: [(&[u8; 4], Vec); 6] = [(b"glyf", glyf), (b"head", head), (b"hhea", hhea), (b"hmtx", hmtx), (b"loca", loca), (b"maxp", maxp)]; + let mut out = 0x0001_0000u32.to_be_bytes().to_vec(); + for v in [6, 64, 2, 32] { + push16(&mut out, v); // tables, search range, entry selector, range shift + } + let mut offset = 12 + 16 * tables.len(); + let mut data = Vec::new(); + for (tag, table) in &tables { + let mut padded = table.clone(); + while padded.len() % 4 != 0 { + padded.push(0); + } + let sum = padded.chunks(4).fold(0u32, |s, w| s.wrapping_add(u32::from_be_bytes([w[0], w[1], w[2], w[3]]))); + out.extend_from_slice(*tag); + out.extend_from_slice(&sum.to_be_bytes()); + out.extend_from_slice(&(offset as u32).to_be_bytes()); + out.extend_from_slice(&(table.len() as u32).to_be_bytes()); + offset += padded.len(); + data.extend(padded); + } + out.extend(data); + out +} diff --git a/apps/pdf/host-service/src/change.rs b/apps/pdf/host-service/src/change.rs index 9f3390021..015c2c341 100644 --- a/apps/pdf/host-service/src/change.rs +++ b/apps/pdf/host-service/src/change.rs @@ -178,44 +178,82 @@ pub(crate) fn pages(a: &Json, cx: &Ctx) -> Result { } } -/// `pdf.edit_text {doc, page, paragraph?, line?, text, align?, color?}`: -/// `align` and `color` format a paragraph (the module doc's review). -pub(crate) fn edit_text(a: &Json, cx: &Ctx) -> Result { - const M: &str = "pdf.edit_text"; - args::only(a, &["doc", "page", "paragraph", "line", "text", "align", "color"], M)?; - let page = args::positive(a, "page", M)?; - let text = args::opt_str(a, "text", M, MAX_EDIT)?.ok_or_else(|| invalid(format!("{M} needs `text`")))?; - let paragraph = args::opt_int(a, "paragraph", M)?; - let line = args::opt_int(a, "line", M)?; - let align = match args::opt_str(a, "align", M, 16)? { - Some(al) if ALIGNS.contains(&al) => Some(al), - Some(al) => return Err(invalid(format!("{M}: `align` is {}, not {al:?}", ALIGNS.join(", ")))), - None => None, - }; - let color = match args::opt_str(a, "color", M, 16)? { - Some(c) => Some(hex_color(c).ok_or_else(|| invalid(format!("{M}: `color` is #rrggbb, not {c:?}")))?), - None => None, - }; - let mut call = json!({ "page": page, "text": text }); - match (paragraph, line) { - (Some(n), None) if n >= 1 => { - call["paragraph"] = json!(n); - if let Some(align) = align { - call["align"] = json!(align); +/// What an edit names: a paragraph or a line, by its 1-based number from +/// `pdf.lines`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Target { + Paragraph(u64), + Line(u64), +} + +/// One edit as `pdf.edit_text` and `pdf.edit_check` take it, checked: the +/// page, what it names, the new text, and a paragraph's alignment (one of +/// [`ALIGNS`]) and colour (`#rrggbb`, as the service writes it). +pub(crate) struct EditCall<'a> { + pub page: u64, + pub target: Target, + pub text: &'a str, + pub align: Option<&'a str>, + pub color: Option, +} + +impl<'a> EditCall<'a> { + /// Read `{doc, page, paragraph?, line?, text, align?, color?}` for method + /// `m`: `align` and `color` format a paragraph (the module doc's review). + pub(crate) fn read(a: &'a Json, m: &str) -> Result { + args::only(a, &["doc", "page", "paragraph", "line", "text", "align", "color"], m)?; + let page = args::positive(a, "page", m)?; + let text = args::opt_str(a, "text", m, MAX_EDIT)?.ok_or_else(|| invalid(format!("{m} needs `text`")))?; + let paragraph = args::opt_int(a, "paragraph", m)?; + let line = args::opt_int(a, "line", m)?; + let align = match args::opt_str(a, "align", m, 16)? { + Some(al) if ALIGNS.contains(&al) => Some(al), + Some(al) => return Err(invalid(format!("{m}: `align` is {}, not {al:?}", ALIGNS.join(", ")))), + None => None, + }; + let color = match args::opt_str(a, "color", m, 16)? { + Some(c) => Some(hex_color(c).ok_or_else(|| invalid(format!("{m}: `color` is #rrggbb, not {c:?}")))?), + None => None, + }; + let target = match (paragraph, line) { + (Some(n), None) if n >= 1 => Target::Paragraph(n as u64), + (None, Some(_)) if align.is_some() || color.is_some() => { + return Err(invalid(format!("{m}: `align` and `color` format a paragraph: give `paragraph`, a number from pdf.lines, not `line`"))); } - if let Some(color) = color { - call["color"] = json!(color); + (None, Some(n)) if n >= 1 => Target::Line(n as u64), + _ => return Err(invalid(format!("{m}: give exactly one of `paragraph` or `line`, a number from pdf.lines"))), + }; + Ok(EditCall { page, target, text, align, color }) + } + + /// The engine's `text_edit` arguments, built from the checked values. + fn engine_args(&self) -> Json { + let mut call = json!({ "page": self.page, "text": self.text }); + match self.target { + Target::Paragraph(n) => { + call["paragraph"] = json!(n); + if let Some(align) = self.align { + call["align"] = json!(align); + } + if let Some(color) = &self.color { + call["color"] = json!(color); + } } + Target::Line(n) => call["line"] = json!(n), } - (None, Some(_)) if align.is_some() || color.is_some() => { - return Err(invalid(format!("{M}: `align` and `color` format a paragraph: give `paragraph`, a number from pdf.lines, not `line`"))); - } - (None, Some(n)) if n >= 1 => call["line"] = json!(n), - _ => return Err(invalid(format!("{M}: give exactly one of `paragraph` or `line`, a number from pdf.lines"))), + call } +} + +/// `pdf.edit_text {doc, page, paragraph?, line?, text, align?, color?}`. A +/// refusal is in plain words ([`codes::edit_refused`]), the ones +/// `pdf.edit_check` gives for the same edit. +pub(crate) fn edit_text(a: &Json, cx: &Ctx) -> Result { + const M: &str = "pdf.edit_text"; + let edit = EditCall::read(a, M)?; docs::with_doc(a, cx, M, |doc| { - doc.size(page)?; - doc.call("text_edit", call).map_err(|e| codes::refused(&e))?; + doc.size(edit.page)?; + doc.call("text_edit", edit.engine_args()).map_err(|e| codes::edit_refused(&e.to_string(), edit.text))?; doc.changed(); Ok(json!({ "edited": true })) }) diff --git a/apps/pdf/host-service/src/codes.rs b/apps/pdf/host-service/src/codes.rs index 7a0e3c9aa..f5b265f2a 100644 --- a/apps/pdf/host-service/src/codes.rs +++ b/apps/pdf/host-service/src/codes.rs @@ -114,3 +114,160 @@ pub(crate) fn area(words: impl Display) -> String { invalid(words) } } + +/// The engine's refusal of a text edit (`pdf.edit_text`, and `pdf.edit_check` +/// asking the same), in words for a person, still `invalid:` as SERVICE.md +/// has it: the engine's own words name a build setting, a font file or a +/// Unicode code point, never what to do. +/// +/// - Text the paragraph's font can't show, which the engine then draws in a +/// stand-in font: Helvetica, Times or Courier show only Latin text, and its +/// fallback for everything else is a Japanese face this build leaves out +/// ("needs PdfCraft's Japanese fallback font, which this build doesn't +/// include"), lacks a character ("has no glyph for U+…"), or holds at most +/// 240 different characters. +/// - A document the engine opened for reading only, or whose security +/// settings forbid changes (PDF Tools takes no passwords). +/// - A paragraph or line number that is no longer on the page. +/// +/// Anything else keeps the engine's words. +pub(crate) fn edit_refused(words: &str, text: &str) -> String { + let has = |s: &str| words.contains(s); + if has("Japanese fallback font has no glyph for U+") || has("Japanese fallback glyph U+") { + // One character the fallback face lacks (a build with the face). + let missing = words.split("U+").nth(1).and_then(|hex| u32::from_str_radix(hex.get(..hex.find(|c: char| !c.is_ascii_hexdigit()).unwrap_or(hex.len()))?, 16).ok()); + if let Some(c) = missing.and_then(char::from_u32) { + return invalid(format!("PDF Tools has no font that can draw {} yet, so it can't type it into this PDF.", quoted(&[c]))); + } + return invalid(unshowable(text)); + } + if has("Japanese replacement has too many unique characters") { + return invalid("That's more different characters than PDF Tools can type into one paragraph: shorten the text, or edit it in parts."); + } + if has("Japanese fallback") || has("can show") || has("can't be shown") { + return invalid(unshowable(text)); + } + if has("can't be edited") { + return invalid("This PDF opens for reading only, so PDF Tools can't change its text."); + } + if has("security settings don't allow") { + return invalid("This PDF's security settings don't allow changes, so PDF Tools can't edit it."); + } + if has("is out of range") || has("has no paragraph") || has("has no line") || has("the page's content changed") { + return invalid("That text isn't on the page any more: click it again."); + } + if has("has no font") { + return invalid("PDF Tools can't edit this text: the PDF doesn't say which font draws it."); + } + if needs_password(words) { + return fail(Code::Protected, "that PDF is protected with a password, and PDF Tools does not open those yet"); + } + invalid(words) +} + +/// Why `text` can't be typed into the PDF in a font PDF Tools has: Chinese, +/// Japanese or Korean text, or the characters no stand-in font shows. +fn unshowable(text: &str) -> String { + if text.chars().any(is_cjk) { + return "PDF Tools can't edit Chinese, Japanese or Korean text yet.".into(); + } + let mut odd: Vec = Vec::new(); + for c in text.chars().filter(|c| !c.is_whitespace() && !is_win_ansi(*c)) { + if !odd.contains(&c) { + odd.push(c); + } + } + if odd.is_empty() { + return "PDF Tools can't type this text into this PDF yet.".into(); + } + format!("PDF Tools can't type {} into a PDF yet.", quoted(&odd)) +} + +/// Characters quoted for a sentence: “a”, “b” or “c”; more than five as the +/// first five “and others like them”. +fn quoted(chars: &[char]) -> String { + let each: Vec = chars.iter().take(5).map(|c| format!("“{c}”")).collect(); + if chars.len() > 5 { + return format!("{} and others like them", each.join(", ")); + } + match each.split_last() { + None => String::new(), + Some((last, [])) => last.clone(), + Some((last, rest)) => format!("{} or {last}", rest.join(", ")), + } +} + +/// Chinese, Japanese or Korean script, or its punctuation and full-width +/// forms. +pub(crate) fn is_cjk(c: char) -> bool { + matches!(c as u32, + 0x1100..=0x11ff // Hangul jamo + | 0x2e80..=0x2fdf // radicals + | 0x3000..=0x303f // CJK symbols and punctuation + | 0x3040..=0x30ff // Hiragana, Katakana + | 0x3100..=0x318f // Bopomofo, Hangul compatibility jamo + | 0x31a0..=0x31ff // Bopomofo extended, strokes, Katakana extensions + | 0x3400..=0x4dbf // CJK extension A + | 0x4e00..=0x9fff // CJK unified ideographs + | 0xa960..=0xa97f // Hangul jamo extended A + | 0xac00..=0xd7ff // Hangul syllables, jamo extended B + | 0xf900..=0xfaff // compatibility ideographs + | 0xfe30..=0xfe4f // compatibility forms + | 0xff00..=0xffef // half-width and full-width forms + | 0x20000..=0x3134f) // extensions B to G +} + +/// The characters the engine's stand-in fonts show (WinAnsiEncoding), as +/// pdfcraft-edit's `is_win_ansi_char` lists them. +fn is_win_ansi(c: char) -> bool { + matches!(c, '\u{20}'..='\u{7e}' | '\u{a0}'..='\u{ff}' | '€' | '‚' | '„' | '…' | '‘' | '’' | '“' | '”' | '•' | '–' | '—' | '™' | '\t') +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The engine's own refusals of an edit, as pdfcraft-edit and + /// pdfcraft-engine word them at the pinned revision, each in words + /// for a person, still `invalid:`. + #[test] + fn an_edits_refusal_is_in_plain_words() { + let no_face = "this text needs PdfCraft's Japanese fallback font, which this build doesn't include \ + (official releases do; to build it in, set CRAFT_FONTS_DIR to a craft-fonts checkout)"; + let cases = [ + (no_face, "测试中文", "invalid: PDF Tools can't edit Chinese, Japanese or Korean text yet."), + (no_face, "こんにちは", "invalid: PDF Tools can't edit Chinese, Japanese or Korean text yet."), + (no_face, "안녕하세요", "invalid: PDF Tools can't edit Chinese, Japanese or Korean text yet."), + (no_face, "Привет", "invalid: PDF Tools can't type “П”, “р”, “и”, “в”, “е” and others like them into a PDF yet."), + (no_face, "x → y", "invalid: PDF Tools can't type “→” into a PDF yet."), + (no_face, "x → y ↔ z", "invalid: PDF Tools can't type “→” or “↔” into a PDF yet."), + (no_face, "Ωmega ≈ ∞", "invalid: PDF Tools can't type “Ω”, “≈” or “∞” into a PDF yet."), + ("Japanese fallback font has no glyph for U+6D4B", "测试", "invalid: PDF Tools has no font that can draw “测” yet, so it can't type it into this PDF."), + ("Japanese replacement has too many unique characters", "…", "invalid: That's more different characters than PDF Tools can type into one paragraph: shorten the text, or edit it in parts."), + ("\"x ☃\" has characters neither ABCDEF+Arial nor Helvetica can show", "x ☃", "invalid: PDF Tools can't type “☃” into a PDF yet."), + ("this document can't be edited: the document structure could not be read for editing", "x", "invalid: This PDF opens for reading only, so PDF Tools can't change its text."), + ( + "the document's security settings don't allow changes to the document; open it with the owner password to make this change", + "x", + "invalid: This PDF's security settings don't allow changes, so PDF Tools can't edit it.", + ), + ("paragraph 9 is out of range: page 1 has 3 paragraphs", "x", "invalid: That text isn't on the page any more: click it again."), + ("page 1 has no line 4", "x", "invalid: That text isn't on the page any more: click it again."), + ("the paragraph has no font", "x", "invalid: PDF Tools can't edit this text: the PDF doesn't say which font draws it."), + ("Edit text failed unexpectedly (boom); the document was not changed", "x", "invalid: Edit text failed unexpectedly (boom); the document was not changed"), + ]; + for (engine, text, plain) in cases { + assert_eq!(edit_refused(engine, text), plain, "{engine}"); + } + } + + #[test] + fn cjk_is_told_from_other_scripts() { + for c in ['中', '文', '。', '「', 'あ', 'カ', '한', 'ㄅ', '!', '𠀀'] { + assert!(is_cjk(c), "{c}"); + } + for c in ['a', 'é', 'Ж', 'α', '→', 'ا', '€', ' '] { + assert!(!is_cjk(c), "{c}"); + } + } +} diff --git a/apps/pdf/host-service/src/doc_tests.rs b/apps/pdf/host-service/src/doc_tests.rs index c159f0095..24157f06e 100644 --- a/apps/pdf/host-service/src/doc_tests.rs +++ b/apps/pdf/host-service/src/doc_tests.rs @@ -878,6 +878,189 @@ fn a_paragraph_takes_an_alignment_and_a_colour() { assert_eq!(w.ok("state", json!({"doc": doc})), edits, "the refusals reached nothing"); } +// ------------------------------------------------------------ edits on a page as a browser prints it + +/// The page `fixture::printed_flyer` writes, open (its handle), with a +/// paragraph's number by its text. +fn flyer() -> (World, String) { + let w = World::new(); + w.put("flyer.pdf", &fixture::printed_flyer()); + let doc = w.open("flyer.pdf"); + (w, doc) +} + +/// Two `[x, y, w, h]` answers within rounding (each is rounded to 1/100 pt +/// on its own). +fn same_box(a: &Json, b: &Json) -> bool { + xywh4(a).iter().zip(xywh4(b)).all(|(x, y)| (x - y).abs() < 0.025) +} + +fn paragraph_n(w: &World, doc: &str, text: &str) -> Json { + let lines = w.ok("lines", json!({"doc": doc, "page": 1})); + lines["paragraphs"].as_array().unwrap().iter().find(|p| p["text"] == text).unwrap_or_else(|| panic!("no paragraph {text:?}: {lines}"))["n"].clone() +} + +/// The engine reads a title a browser printed in pieces as one paragraph a +/// piece, in fonts it can't type most text in: what PDF Tools' Edit must be +/// honest about. (The pieces, the fonts and the joined Chinese lines are the +/// engine's own reading, at the pinned revision.) +#[test] +fn a_printed_title_reads_as_pieces_in_subset_fonts() { + let (w, doc) = flyer(); + let lines = w.ok("lines", json!({"doc": doc, "page": 1})); + let paragraphs: Vec<(String, String)> = lines["paragraphs"].as_array().unwrap().iter().map(|p| (p["text"].as_str().unwrap().to_string(), p["font"].as_str().unwrap().to_string())).collect(); + let text: Vec<&str> = paragraphs.iter().map(|(t, _)| t.as_str()).collect(); + assert_eq!( + text, + [ + "OctoSense sample - a page as a web browser prints it", + "Studio2026", + "春", + "季", + "发布", + "会", + "春季发布会展示新的应用和工具 报名截止 2026 年 4 月 30 日", + "Doors open at nine in the main hall. Bring a laptop and the badge we send you by email.", + ], + "{lines}" + ); + for (t, font) in ¶graphs[1..6] { + assert_eq!(font, "", "{t}: an unnamed Type 3 font, as a browser embeds one"); + } + assert_eq!(paragraphs[6].1, "ABCDEF+FixtureSans-SC", "an embedded subset"); + let fonts = w.ok("info", json!({"doc": doc}))["fonts"].clone(); + let kind = |k: &str| fonts.as_array().unwrap().iter().find(|f| f["kind"] == k).unwrap_or_else(|| panic!("{fonts}")).clone(); + assert_eq!((kind("Type3")["embedded"].clone(), kind("Type3")["name"].clone()), (json!(true), json!("(unnamed)")), "{fonts}"); + assert_eq!((kind("Type0")["embedded"].clone(), kind("Type0")["subset"].clone()), (json!(true), json!(true)), "{fonts}"); +} + +/// `pdf.edit_check` says what an edit would do before PDF Tools makes it: the +/// font it would be drawn in, and the room it would take. It changes +/// nothing. +#[test] +fn an_edit_check_says_when_the_font_or_the_room_changes() { + let (w, doc) = flyer(); + let check = |n: &Json, text: &str| w.ok("edit_check", json!({"doc": doc, "page": 1, "paragraph": n, "text": text})); + let title = paragraph_n(&w, &doc, "Studio2026"); + // Its own text, or its own glyphs in another order: its font, its room. + let same = check(&title, "Studio2026"); + assert_eq!((same["keeps_font"].clone(), same["font"].clone(), same["covers"].clone(), same["off_page"].clone()), (json!(true), json!(""), json!(0), json!(false)), "{same}"); + assert_eq!(xywh4(&same["box"]), [44.62, 97.54, 166.8, 27.8], "{same}"); + let reordered = check(&title, "2026Studio"); + assert_eq!((reordered["keeps_font"].clone(), reordered["covers"].clone()), (json!(true), json!(0)), "{reordered}"); + // Letters the piece's font doesn't have: Helvetica, wider, over 春 and 季. + let longer = check(&title, "Studio2026 Beta"); + assert_eq!((longer["keeps_font"].clone(), longer["font"].clone(), longer["covers"].clone()), (json!(false), json!("Helvetica"), json!(2)), "{longer}"); + assert!(xywh4(&longer["box"])[2] > 166.8 + 30.0, "{longer}"); + // A line takes the same check. + let line = w.ok("lines", json!({"doc": doc, "page": 1}))["lines"].as_array().unwrap().iter().find(|l| l["text"] == "Studio2026").unwrap()["n"].clone(); + let by_line = w.ok("edit_check", json!({"doc": doc, "page": 1, "line": line, "text": "Studio2026 Beta"})); + assert_eq!((by_line["keeps_font"].clone(), by_line["covers"].clone()), (json!(false), json!(2)), "{by_line}"); + + // The Chinese paragraph's embedded subset: its own glyphs keep it… + let chinese = paragraph_n(&w, &doc, "春季发布会展示新的应用和工具 报名截止 2026 年 4 月 30 日"); + let digits = check(&chinese, "2026 4 30"); + assert_eq!((digits["keeps_font"].clone(), digits["font"].clone(), digits["covers"].clone()), (json!(true), json!("ABCDEF+FixtureSans-SC"), json!(0)), "{digits}"); + // …Latin letters it lacks don't, + let latin = check(&chinese, "Hello world"); + assert_eq!((latin["keeps_font"].clone(), latin["font"].clone(), latin["covers"].clone()), (json!(false), json!("Helvetica"), json!(0)), "{latin}"); + // more words wrap into more lines, down over the paragraph below, + let more = check(&chinese, &["2026 4 30"; 12].join(" ")); + assert_eq!((more["keeps_font"].clone(), more["off_page"].clone()), (json!(true), json!(false)), "{more}"); + assert!(more["covers"].as_u64().unwrap() >= 1, "runs into the Helvetica paragraph: {more}"); + // and Chinese without spaces never wraps: one line, off the page. + let long = check(&chinese, &"春季发布会展示新的应用和工具".repeat(4)); + assert_eq!((long["keeps_font"].clone(), long["off_page"].clone()), (json!(true), json!(true)), "{long}"); + + assert_eq!(w.ok("state", json!({"doc": doc})), json!({"edited": false, "can_undo": false, "can_redo": false, "pages": 1}), "a check changes nothing"); +} + +/// What `pdf.edit_check` says is what `pdf.edit_text` then does: the same +/// font and the same box. It reads the open document as it is, unsaved +/// edits included, and counts only text an edit newly runs into. +#[test] +fn an_edit_check_foretells_the_edit() { + let (w, doc) = flyer(); + let title = paragraph_n(&w, &doc, "Studio2026"); + let said = w.ok("edit_check", json!({"doc": doc, "page": 1, "paragraph": title, "text": "Studio2026 Beta"})); + w.ok("edit_text", json!({"doc": doc, "page": 1, "paragraph": title, "text": "Studio2026 Beta"})); + let lines = w.ok("lines", json!({"doc": doc, "page": 1})); + let edited = lines["paragraphs"].as_array().unwrap().iter().find(|p| p["text"] == "Studio2026 Beta").unwrap_or_else(|| panic!("{lines}")).clone(); + assert_eq!(edited["font"], said["font"], "{edited} {said}"); + assert!(same_box(&edited["box"], &said["box"]), "{edited} {said}"); + // Checked again, the edited title is in Helvetica now, already over 春 + // and 季: nothing it newly runs into. + let again = w.ok("edit_check", json!({"doc": doc, "page": 1, "paragraph": edited["n"], "text": "Studio2026 Beta"})); + assert_eq!((again["keeps_font"].clone(), again["font"].clone(), again["covers"].clone()), (json!(true), json!("Helvetica"), json!(0)), "{again}"); + + let chinese = paragraph_n(&w, &doc, "春季发布会展示新的应用和工具 报名截止 2026 年 4 月 30 日"); + let words = ["2026 4 30"; 12].join(" "); + let said = w.ok("edit_check", json!({"doc": doc, "page": 1, "paragraph": chinese, "text": words})); + w.ok("edit_text", json!({"doc": doc, "page": 1, "paragraph": chinese, "text": words})); + let lines = w.ok("lines", json!({"doc": doc, "page": 1})); + let edited = lines["paragraphs"].as_array().unwrap().iter().find(|p| p["text"] == words.as_str()).unwrap_or_else(|| panic!("{lines}")).clone(); + assert_eq!(edited["font"], said["font"], "{edited} {said}"); + assert!(same_box(&edited["box"], &said["box"]), "{edited} {said}"); +} + +/// Text no font PDF Tools has can show is refused, by the check and the +/// edit alike, in words for a person, and the page stays as it was. +#[test] +fn an_edit_the_engine_refuses_is_refused_in_plain_words() { + let (w, doc) = flyer(); + let piece = paragraph_n(&w, &doc, "季"); + let chinese = paragraph_n(&w, &doc, "春季发布会展示新的应用和工具 报名截止 2026 年 4 月 30 日"); + let title = paragraph_n(&w, &doc, "Studio2026"); + for (n, text, words) in [ + (&piece, "夏", "invalid: PDF Tools can't edit Chinese, Japanese or Korean text yet."), + (&chinese, "春季发布会 Hello", "invalid: PDF Tools can't edit Chinese, Japanese or Korean text yet."), + (&chinese, "夏季发布会", "invalid: PDF Tools can't edit Chinese, Japanese or Korean text yet."), + (&title, "Привет", "invalid: PDF Tools can't type “П”, “р”, “и”, “в”, “е” and others like them into a PDF yet."), + (&json!(99), "x", "invalid: That text isn't on the page any more: click it again."), + ] { + let args = json!({"doc": doc, "page": 1, "paragraph": n, "text": text}); + assert_eq!(w.err("edit_check", args.clone()), words, "check {text}"); + assert_eq!(w.err("edit_text", args), words, "edit {text}"); + } + assert_eq!(w.ok("state", json!({"doc": doc}))["edited"], false, "nothing changed"); + assert!(w.ok("text", json!({"doc": doc}))["pages"][0]["text"].as_str().unwrap().contains("春季发布会展示新的应用和工具")); +} + +/// A PDF whose security settings forbid changes (PDF Tools takes no +/// passwords) opens, and an edit says so in plain words. +#[test] +fn a_pdf_locked_against_changes_says_so() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("plain.pdf"), paragraph_pdf()).unwrap(); + let mut a = Automation::new().with_root(dir.path()).unwrap(); + let (id, _) = open(&mut a, "plain.pdf").unwrap(); + run(&mut a, "doc_protect", &json!({ "doc": id, "permissions_password": "the owner's", "changes": "none" })).unwrap(); + run(&mut a, "doc_save", &json!({ "doc": id, "path": "locked.pdf" })).unwrap(); + let w = World::new(); + w.put("locked.pdf", &std::fs::read(dir.path().join("locked.pdf")).unwrap()); + let doc = w.open("locked.pdf"); + let n = w.ok("lines", json!({"doc": doc, "page": 1}))["paragraphs"][1]["n"].clone(); + let args = json!({"doc": doc, "page": 1, "paragraph": n, "text": "Changed"}); + let words = "invalid: This PDF's security settings don't allow changes, so PDF Tools can't edit it."; + assert_eq!(w.err("edit_check", args.clone()), words); + assert_eq!(w.err("edit_text", args), words); +} + +/// A plain PDF in a standard font keeps working as before: an edit keeps the +/// font and its room. +#[test] +fn an_edit_check_on_a_plain_pdf_finds_nothing_to_say() { + let w = World::new().samples(); + let doc = w.open("Apartment lease.pdf"); + let lines = w.ok("lines", json!({"doc": doc, "page": 1})); + let clause = lines["paragraphs"].as_array().unwrap().iter().find(|p| p["text"].as_str().unwrap().starts_with("This agreement is made")).unwrap().clone(); + let check = w.ok("edit_check", json!({"doc": doc, "page": 1, "paragraph": clause["n"], "text": "This agreement is made between the landlord and the tenant.", "align": "center", "color": "#c01c28"})); + assert_eq!((check["keeps_font"].clone(), check["font"].clone(), check["covers"].clone(), check["off_page"].clone()), (json!(true), json!("Times-Roman"), json!(0), json!(false)), "{check}"); + let b = xywh4(&check["box"]); + let was = xywh4(&clause["box"]); + assert!(b[1] >= was[1] - 1.0 && b[1] + b[3] <= was[1] + was[3] + 1.0, "within the clause's own lines: {b:?} in {was:?}"); +} + #[test] fn undo_and_redo_across_calls() { let w = World::new().samples(); @@ -1733,6 +1916,7 @@ fn every_v2_method_refuses_arguments_it_does_not_take() { ("pages", json!({"doc": doc, "op": "extract", "pages": [1], "out": "x.pdf", "delete": true})), ("pages", json!({"doc": doc, "op": "rotate", "pages": [1], "angle": 90, "subset": "odd"})), ("edit_text", json!({"doc": doc, "page": 1, "line": 1, "text": "x", "font": "times"})), + ("edit_check", json!({"doc": doc, "page": 1, "line": 1, "text": "x", "size": 20})), ("undo", json!({"doc": doc, "steps": 2})), ("save", json!({"doc": doc, "full": true})), ("export", json!({"doc": doc, "kind": "images", "out_dir": "x", "quality": 50})), @@ -1777,6 +1961,7 @@ fn timings_on_a_typical_page() { time("fill_sign (date)", "fill_sign", json!({"doc": doc, "page": 4, "kind": "date", "at": [60, 300]})); time("pages rotate", "pages", json!({"doc": doc, "op": "rotate", "pages": [1], "angle": 90})); time("page 1 at 96 dpi, after an edit", "page", json!({"doc": doc, "page": 1})); + time("edit_check (a line)", "edit_check", json!({"doc": doc, "page": 2, "line": 1, "text": "Overview"})); time("edit_text (a line)", "edit_text", json!({"doc": doc, "page": 2, "line": 1, "text": "Overview"})); time("undo", "undo", json!({"doc": doc})); time("redo", "redo", json!({"doc": doc})); @@ -1793,5 +1978,11 @@ fn timings_on_a_typical_page() { let big = time("open (112 pages)", "open", json!({"path": "guides.pdf"}))["doc"].clone(); time("its page 1 at 96 dpi", "page", json!({"doc": big, "page": 1})); time("find in 112 pages, first", "find", json!({"doc": big, "query": "garden"})); + // A page as a browser prints it: a title in pieces, Chinese in a subset. + w.put("flyer.pdf", &fixture::printed_flyer()); + let flyer = time("open (a printed page)", "open", json!({"path": "flyer.pdf"}))["doc"].clone(); + time("its lines", "lines", json!({"doc": flyer, "page": 1})); + time("edit_check (a title piece)", "edit_check", json!({"doc": flyer, "page": 1, "paragraph": 2, "text": "Studio2026 Beta"})); + time("edit_check (the Chinese subset)", "edit_check", json!({"doc": flyer, "page": 1, "paragraph": 7, "text": "2026 4 30"})); eprintln!("pdf service timings:\n{}", report.join("\n")); } diff --git a/apps/pdf/host-service/src/fit.rs b/apps/pdf/host-service/src/fit.rs new file mode 100644 index 000000000..11eb88959 --- /dev/null +++ b/apps/pdf/host-service/src/fit.rs @@ -0,0 +1,205 @@ +//! What an edit would do, before PDF Tools makes it (`pdf.edit_check`). +//! +//! Real-world PDFs draw text in fonts the engine can't always type new text +//! in. An embedded subset holds only the glyphs the document uses, and so do +//! a browser's Type 3 fonts; a designed title may come in pieces, one font +//! each. Text the paragraph's font can't show, the engine sets in a stand-in +//! (Helvetica, Times or Courier), with other widths; text no stand-in shows +//! (Chinese, Japanese or Korean, without the fallback face this build leaves +//! out) it refuses. An edited one-line paragraph grows to the right up to the +//! page's margin, and a longer one grows down, over whatever is there. So +//! PDF Tools asks first, and says what will happen before Apply. +//! +//! `pdf.edit_check` takes `pdf.edit_text`'s arguments and answers +//! `{keeps_font, font, box, covers, off_page}`, or the refusal +//! `pdf.edit_text` would give, in the same words ([`codes::edit_refused`]): +//! +//! - `keeps_font`: the new text is drawn in the paragraph's (or line's) own +//! font. `font` names the font it would be drawn in: the stand-in, or the +//! paragraph's own name (`""` for an unnamed one, as `pdf.lines` says). +//! - `box`: where the edited text would be, `[x, y, w, h]` in points from the +//! top left of the displayed page; `null` when no text would show. +//! - `covers`: how many other lines of text on the page the edited text would +//! run into that the paragraph doesn't touch now. +//! - `off_page`: part of the edited text would fall outside the page. +//! +//! **How.** The engine's own editor makes the edit (pdfcraft-edit's +//! `rewrite_block` for a paragraph and `replace_line` for a line, what the +//! engine's `text_edit` runs), on a private copy: a `pdfcraft_cos` document +//! opened on the open document's working bytes (its unsaved edits included), +//! as `review.rs` reads comment dates, and dropped after. The edit's lines are +//! the page's lines after it that weren't on the page before, or that were +//! the paragraph's own; every other line is compared with them. +//! +//! **Review.** No engine command runs and nothing is written: the copy lives +//! in memory, no file is opened or named, and the editor runs no script (a +//! fallback font it would make goes into the copy only). The session is +//! borrowed, never called. The arguments and the page are checked as +//! `pdf.edit_text` checks them, the text is at most 10,000 characters, and +//! the document's read-only state and its security settings refuse as the +//! engine would. A panic in the editor is this call's `damaged:` error and +//! leaves the open document as it was. The cost is the engine's own on the +//! UI thread (#399): opening the working bytes (the object table; objects are +//! read as they are needed) and reading the page's text before and after. + +use std::collections::HashMap; +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use pdfcraft_edit::{Align, BlockStyle, TextLine}; +use serde_json::{json, Value as Json}; + +use crate::args::xywh; +use crate::change::{EditCall, Target}; +use crate::codes::{self, fail, Code}; +use crate::docs::{self, OpenDoc}; +use crate::Ctx; + +/// How far (points) the edited text may cross another line's box, or the +/// page's edge, before it counts: less is rounding. +const SLACK: f64 = 0.5; + +/// `pdf.edit_check {doc, page, paragraph?, line?, text, align?, color?}`. +pub(crate) fn edit_check(a: &Json, cx: &Ctx) -> Result { + const M: &str = "pdf.edit_check"; + let edit = EditCall::read(a, M)?; + docs::with_doc(a, cx, M, |doc| { + doc.size(edit.page)?; + check(doc, &edit) + }) +} + +/// A line's identity across the edit: its text, its box to 1/100 pt and its +/// font's resource name. +type Key = (String, [i64; 4], String); + +fn key(l: &TextLine) -> Key { + (l.text.clone(), l.rect.map(|v| (v * 100.0).round() as i64), l.font.clone()) +} + +/// The edit `edit` on a copy of `doc` (module doc). +fn check(doc: &OpenDoc, edit: &EditCall) -> Result { + let refused = |words: &str| codes::edit_refused(words, edit.text); + let page = (edit.page - 1) as usize; + let engine = doc.engine.session().docs().iter().find(|d| d.id.0 == doc.id).ok_or_else(|| fail(Code::Damaged, "the engine no longer holds this document"))?; + if let Some(reason) = &engine.read_only_reason { + return Err(refused(&format!("this document can't be edited: {reason}"))); + } + let view = engine.info.pages.get(page).cloned().ok_or_else(|| refused(&format!("page {} is out of range", edit.page)))?; + let mut copy = pdfcraft_cos::Document::open_with_password(engine.bytes.clone(), engine.password.as_deref()) + .map_err(|e| refused(&format!("this document can't be edited: {e}")))?; + if copy.permissions().is_some_and(|p| !p.modify()) { + return Err(refused("the document's security settings don't allow changes to the document")); + } + let before = pdfcraft_edit::text_lines(©, page).map_err(|e| refused(&e.to_string()))?; + // The paragraph's (or line's) own lines, and its font as pdf.lines names it. + let (own, font) = match edit.target { + Target::Paragraph(n) => { + let blocks = pdfcraft_edit::text_blocks(©, page).map_err(|e| refused(&e.to_string()))?; + let b = blocks.get((n - 1) as usize).ok_or_else(|| refused(&format!("paragraph {n} is out of range: page {} has {} paragraphs", edit.page, blocks.len())))?; + (b.lines.clone(), b.base_font.clone()) + } + Target::Line(n) => { + let l = before.get((n - 1) as usize).ok_or_else(|| refused(&format!("line {n} is out of range: page {} has {} lines", edit.page, before.len())))?; + (vec![(n - 1) as usize], l.base_font.clone()) + } + }; + let style = BlockStyle { align: edit.align.map(align), color: edit.color.as_deref().map(rgb), ..BlockStyle::default() }; + let made = catch_unwind(AssertUnwindSafe(|| match edit.target { + Target::Paragraph(n) => pdfcraft_edit::rewrite_block(&mut copy, page, (n - 1) as usize, Some(edit.text), &style), + Target::Line(n) => pdfcraft_edit::replace_line(&mut copy, page, (n - 1) as usize, edit.text), + })) + .map_err(|_| fail(Code::Damaged, "the PDF engine failed unexpectedly on this edit; the document was not changed"))? + .map_err(|e| refused(&e.to_string()))?; + let after = pdfcraft_edit::text_lines(©, page).map_err(|e| refused(&e.to_string()))?; + + // Each of the page's other lines found again is still there; what is + // left is the edited text. + let mut others: HashMap = HashMap::new(); + for (i, l) in before.iter().enumerate() { + if !own.contains(&i) { + *others.entry(key(l)).or_default() += 1; + } + } + let (mut edited, mut kept) = (Vec::new(), Vec::new()); + for l in &after { + match others.get_mut(&key(l)) { + Some(n) if *n > 0 => { + *n -= 1; + kept.push(l.rect); + } + _ => edited.push(l.rect), + } + } + let was: Vec<[f64; 4]> = own.iter().filter_map(|i| before.get(*i)).map(|l| l.rect).collect(); + let covers = kept.iter().filter(|k| edited.iter().any(|e| runs_into(**k, *e)) && !was.iter().any(|w| runs_into(**k, *w))).count(); + let crop = view.crop.map(f64::from); + let off_page = edited.iter().any(|r| r[0] < crop[0] - SLACK || r[1] < crop[1] - SLACK || r[2] > crop[2] + SLACK || r[3] > crop[3] + SLACK); + let whole = edited.iter().copied().reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]); + let shown = match whole { + // Into the displayed page's points, as pdf.lines gives boxes. + Some(b) => { + let (p, q) = (view.user_to_view(b[0] as f32, b[1] as f32), view.user_to_view(b[2] as f32, b[3] as f32)); + xywh(&json!([f64::from(p[0]), f64::from(p[1]), f64::from(q[0]), f64::from(q[1])])) + } + None => Json::Null, + }; + Ok(json!({ + "keeps_font": made.substituted.is_none(), + "font": made.substituted.unwrap_or(font), + "box": shown, + "covers": covers, + "off_page": off_page, + })) +} + +/// Whether two line boxes overlap enough to show: more than [`SLACK`] +/// across, and by more than a quarter of the smaller line's height (lines +/// of a tightly set paragraph touch by less). +fn runs_into(a: [f64; 4], b: [f64; 4]) -> bool { + let across = a[2].min(b[2]) - a[0].max(b[0]); + let down = a[3].min(b[3]) - a[1].max(b[1]); + let tall = (a[3] - a[1]).min(b[3] - b[1]).max(0.01); + across > SLACK && down > tall * 0.25 +} + +/// An alignment as [`crate::change`] checked it. +fn align(name: &str) -> Align { + match name { + "center" => Align::Center, + "right" => Align::Right, + "justify" => Align::Justify, + _ => Align::Left, + } +} + +/// `#rrggbb` (as [`crate::change`] wrote it) as RGB from 0 to 1, as the +/// engine reads a colour. +fn rgb(hex: &str) -> [f64; 3] { + let byte = |i: usize| hex.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok()).map_or(0.0, |b| f64::from(b) / 255.0); + [byte(1), byte(3), byte(5)] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lines_run_into_each_other_only_when_it_shows() { + let line = [10.0, 100.0, 110.0, 112.0]; + assert!(runs_into(line, [100.0, 101.0, 150.0, 113.0]), "the same baseline, ten points over"); + assert!(!runs_into(line, [110.0, 100.0, 150.0, 112.0]), "touching ends"); + assert!(!runs_into(line, [110.3, 100.0, 150.0, 112.0]), "within rounding"); + assert!(!runs_into(line, [10.0, 89.0, 110.0, 101.0]), "a tightly set line below touches by a point"); + assert!(runs_into(line, [10.0, 94.0, 110.0, 106.0]), "a line half under it"); + assert!(!runs_into(line, [10.0, 60.0, 110.0, 72.0]), "far below"); + } + + #[test] + fn colours_and_alignments_reach_the_editor_as_checked() { + assert_eq!(rgb("#c0392b"), [192.0 / 255.0, 57.0 / 255.0, 43.0 / 255.0]); + assert_eq!(rgb("#000000"), [0.0, 0.0, 0.0]); + assert_eq!(align("center"), Align::Center); + assert_eq!(align("justify"), Align::Justify); + assert_eq!(align("left"), Align::Left); + } +} diff --git a/apps/pdf/host-service/src/lib.rs b/apps/pdf/host-service/src/lib.rs index 32291f687..339c90532 100644 --- a/apps/pdf/host-service/src/lib.rs +++ b/apps/pdf/host-service/src/lib.rs @@ -12,9 +12,11 @@ //! `open` keeps the engine's document, its edits and undo history, //! between calls under a handle that later calls name as `doc`; `close`, //! `state`, `page`, `find`, `lines`, `comments`, `comment`, `fields`, -//! `fill`, `fill_sign`, `pages`, `edit_text`, `undo`, `redo`, `save` and -//! `export` work on it, and `info` and `text` take `{doc}` too. Who holds -//! an open document, on which thread, and what releases it: [`docs`]. +//! `fill`, `fill_sign`, `pages`, `edit_text`, `edit_check`, `undo`, +//! `redo`, `save` and `export` work on it, and `info` and `text` take +//! `{doc}` too. Who holds an open document, on which thread, and what +//! releases it: [`docs`]. What an edit would do before it is made (the +//! font it would be drawn in, the room it would take): [`fit`]. //! //! **Where a call works** (ADR 0013, 2026-10-08): the caller's own folder, //! the [`Area`] the shell's resolver gives it ([`set_area_resolver`]): an @@ -62,6 +64,7 @@ mod codes; mod covers; mod dates; mod docs; +mod fit; mod reading; mod review; @@ -188,6 +191,7 @@ fn dispatch_in(method: &str, args: &Json, cx: &Ctx) -> Result { "fill_sign" => review::fill_sign(args, cx), "pages" => change::pages(args, cx), "edit_text" => change::edit_text(args, cx), + "edit_check" => fit::edit_check(args, cx), "undo" => change::history(args, cx, false), "redo" => change::history(args, cx, true), "save" => change::save(args, cx), diff --git a/apps/pdftools/README.md b/apps/pdftools/README.md index 7f291ece9..fc5246a6a 100644 --- a/apps/pdftools/README.md +++ b/apps/pdftools/README.md @@ -65,7 +65,7 @@ page fits the canvas, up to 100%. | Fill & Sign | The form's fields outlined (required empty ones in red) and listed with their values; choose one to type its value; Add text, Add date and Add initials place a mark where you tap; initials are kept | `pdf.fields`, `pdf.fill`, `pdf.fill_sign` | | Pages | Every page as a large thumbnail: choose pages, Rotate left or right, Delete, Extract (a new PDF in the library), Insert from file (another PDF here), drag a page to move it | `pdf.pages` | | Combine | This PDF and others in order (drag the grip to reorder), each with its pages ("1-4, 9" or All), "One PDF of 31 pages", a name, Combine; the new PDF opens. When the list does not fit the window, it scrolls inside the card and the total, name and Combine stay in view | `pdf.merge` | -| Edit | Click a paragraph: its text becomes editable in place; the right panel shows its font and size, its Alignment (left, centre, right or justify: the one its lines show is chosen, since the engine sets an edited paragraph flush left unless told) and its Colour (its own until you pick one of six); Apply or Cancel | `pdf.lines`, `pdf.edit_text` | +| Edit | Click a paragraph: its text becomes editable in place; the right panel shows its font and size, its Alignment (left, centre, right or justify: the one its lines show is chosen, since the engine sets an edited paragraph flush left unless told) and its Colour (its own until you pick one of six); before Apply it says what the edit would do besides changing the words, or why it can't be made ([Editing text](#editing-text)); Apply (or Apply anyway) or Cancel | `pdf.lines`, `pdf.edit_check`, `pdf.edit_text` | Undo and Redo use the engine's history; Save writes the PDF back (`pdf.undo`, `pdf.redo`, `pdf.save`, `pdf.state`). Closing a tab with unsaved @@ -78,6 +78,51 @@ host sheets and there is none for PDFs yet, so it is not opened), storage full ("Remove a PDF to make room"), eight PDFs already open, unsaved changes when closing, and no engine on the device. +## Editing text + +Edit redraws a paragraph in place with the engine's own editor. Real-world +PDFs limit what that can do, and the right panel says so before Apply. As +you click a paragraph, and again when your typing pauses, the app asks the +engine what the edit would do (`pdf.edit_check`, which makes the edit on a +copy of the document and drops it), so no edit garbles a page unannounced: + +- **Another font.** A PDF usually embeds only the glyphs it uses (a subset + font), and a web browser often prints text in unnamed Type 3 fonts. When + the paragraph's own font can't show the new text, the engine sets the + paragraph in Helvetica, Times or Courier instead, which looks different and + takes another width. The panel names the font it will use. +- **More room.** An edited one-line paragraph grows to the right up to the + page's margin, and a longer one grows down; nothing around it moves. When + the new text would run into other lines of text, or fall off the page, the + panel says so. +- **Chinese, Japanese and Korean.** The engine draws characters a + paragraph's font lacks from a fallback face this build doesn't include, so + it refuses them, and the panel says so plainly: "PDF Tools can't edit + Chinese, Japanese or Korean text yet." Characters the font already has + (the same ones in another order, say) still work. Other text no standard + font shows, such as Cyrillic or Greek, is refused the same way, naming the + characters. +- **A line in pieces.** A browser prints a title whose glyphs span several + fonts as one piece per font, and the engine reads each piece as a + paragraph of its own, so a click selects only that piece. The panel says + how many pieces the line has. + +While the panel has a warning, Apply reads "Apply anyway"; an edit that +hasn't been checked as it stands yet is checked first, and is made at once +only if there is nothing to say. A refusal shows in red and Apply only +repeats it. With notes showing, the card takes the panel's height: its +fields scroll, and Apply and Cancel stay at its foot, in view even in the +desktop's default window. A document whose security settings forbid changes, or that opens +for reading only, is refused the same way. + +The pdf service's `pdftools_fixture` example writes a page as a web browser +prints it, with all four (a title in five pieces in unnamed Type 3 fonts, +Chinese text in an embedded subset font), to try Edit on: + +```sh +cargo run --locked -p octosense-pdf-service --example pdftools_fixture -- --edit-sample +``` + ## Opening a PDF from this device "Open a PDF from this device" asks the shell's files service to copy one @@ -150,7 +195,13 @@ evaluating the bundle's functions in a script VM part's title, what the library index keeps across a restart, the pages a range names, a find snippet's match in bold, an avatar's initials, a comment's date, the storage line's words, a paragraph's alignment read from -its lines, and what an edit sends. +its lines, what an edit sends, and what the Edit panel says an edit would do +(a font's name, the warnings and "Apply anyway", a line in pieces). They +also parse the whole script, views and handlers included, which the VM there +doesn't run: one parse error fails the whole app when it loads. The +pdf service's own tests hold `pdf.edit_check`'s answers to what the engine +then does, on the `--edit-sample` page (`cargo test --locked -p +octosense-pdf-service`). ```sh cargo test --locked -p octosense-shell --lib pdftools_model @@ -233,6 +284,15 @@ that refusal. `--budget-ms 1000`: at 64 ms, under load averages of 54 to 74, the `shell` run passed every step but overran the budget after UI-thread stalls in the pdf service's file renames. +- **Verified on 11 Oct 2026, Edit on real-world PDFs** (the Linux build host: + a private headless weston and Makepad's Wayland backend, not macOS, where + another session was running OctoSense; + [tests/evidence/edit-20261011](tests/evidence/edit-20261011/README.md)): + the model tests; the pdf service's tests; in a desktop shell with the real + engine at the runtime's 64 ms budget, light and dark, on the + `--edit-sample` page: the five-pieces note, the font and room warnings with + Apply anyway, the plain refusal of Chinese text, an edit made anyway and + one made at once. Edit on macOS since this change is **unverified**. - **Not verified**: the host's file dialog and a real import (a hidden window cannot open it); with the real engine, form fields, outlines, Redo, the other comment and page operations, password-protected PDFs, the eight-PDF diff --git a/apps/pdftools/README.zh-CN.md b/apps/pdftools/README.zh-CN.md index 1026fce9f..ad0bb3a50 100644 --- a/apps/pdftools/README.zh-CN.md +++ b/apps/pdftools/README.zh-CN.md @@ -54,7 +54,7 @@ PDF Tools 以 1296 x 703 点打开,应用区域为 1292 x 667,即它的宽 | Fill & Sign | 用框线标出表单字段(未填的必填字段为红色)并列出其值;选中一个字段即可输入值;Add text、Add date 和 Add initials 会在点按处放置标记;姓名缩写会被保存 | `pdf.fields`、`pdf.fill`、`pdf.fill_sign` | | Pages | 每页一张大缩略图:选择页面,向左或向右旋转、删除、提取(在资料库中生成新 PDF)、从文件插入(这里的另一个 PDF),拖动页面以移动 | `pdf.pages` | | Combine | 当前 PDF 与其他 PDF 按顺序排列(拖动手柄调整顺序),每个都可指定页面(“1-4, 9”或 All),“One PDF of 31 pages”,名称,Combine;新 PDF 随即打开。列表放不下时在卡片内滚动,总页数、名称和 Combine 保持可见 | `pdf.merge` | -| Edit | 点击一个段落:它的文字可以就地编辑;右侧面板显示字体和字号、Alignment(左对齐、居中、右对齐或两端对齐:默认选中各行所显示的那种,因为引擎在未指定时会把编辑后的段落设为左对齐)和 Colour(在选择六种颜色之一前保持段落原有颜色);Apply 或 Cancel | `pdf.lines`、`pdf.edit_text` | +| Edit | 点击一个段落:它的文字可以就地编辑;右侧面板显示字体和字号、Alignment(左对齐、居中、右对齐或两端对齐:默认选中各行所显示的那种,因为引擎在未指定时会把编辑后的段落设为左对齐)和 Colour(在选择六种颜色之一前保持段落原有颜色);在 Apply 之前,面板会说明这次编辑除了改字之外还会带来什么变化,或者为什么无法进行(见[编辑文字](#编辑文字));Apply(或 Apply anyway)或 Cancel | `pdf.lines`、`pdf.edit_check`、`pdf.edit_text` | Undo 和 Redo 使用引擎的历史记录;Save 把 PDF 写回(`pdf.undo`、`pdf.redo`、`pdf.save`、 `pdf.state`)。关闭有未保存更改的标签时会询问:Save、Don't save 或 Cancel。 @@ -64,6 +64,39 @@ Undo 和 Redo 使用引擎的历史记录;Save 把 PDF 写回(`pdf.undo`、` 宿主自己的面板上处理密码,目前还没有 PDF 的密码面板,因此不打开它)、存储已满(“Remove a PDF to make room”)、已打开八个 PDF、关闭时有未保存的更改,以及设备上没有引擎。 +## 编辑文字 + +Edit 用引擎自带的编辑器在原处重新绘制一个段落。真实世界中的 PDF 限制了它能做到的程度, +右侧面板会在 Apply 之前把这些限制说出来。点击段落时,以及输入停顿时,应用都会询问引擎这次 +编辑会产生什么结果(`pdf.edit_check`:它在文档的一份副本上进行编辑,然后丢弃副本),因此 +任何编辑都不会在没有提示的情况下弄乱页面: + +- **换用其他字体。** PDF 通常只嵌入它用到的字形(子集字体),网页浏览器打印的文字也常常使用 + 没有名字的 Type 3 字体。当段落自己的字体无法显示新文字时,引擎会改用 Helvetica、Times 或 + Courier 来排这个段落,外观不同,宽度也不同。面板会写明将使用哪种字体。 +- **需要更多空间。** 编辑后的单行段落会向右延伸,最远到页面边距;较长的段落会向下延伸;周围 + 的内容都不会移动。如果新文字会压到其他文字行上,或者超出页面,面板会说明。 +- **中文、日文和韩文。** 段落字体中没有的字符,引擎要从一个后备字体中绘制,而本版本不包含这个 + 字体,所以引擎会拒绝这样的文字,面板会直接说明:“PDF Tools can't edit Chinese, Japanese + or Korean text yet.”(PDF Tools 目前还不能编辑中文、日文或韩文文字。)字体中已有的字符(例如 + 同样的字换个顺序)仍然可以使用。西里尔字母、希腊字母等其他标准字体无法显示的文字也会以同样 + 的方式被拒绝,并列出这些字符。 +- **一行文字被拆成几块。** 如果一个标题的字形分布在几种字体中,浏览器会按字体把它打印成几块, + 引擎则把每一块读作一个独立的段落,所以点击只会选中那一块。面板会说明这一行被拆成了几块。 + +只要面板上有警告,Apply 就显示为“Apply anyway”;还没有按当前内容检查过的编辑会先检查, +只有在没有任何需要说明的情况时才会立即进行。拒绝的原因以红色显示,此时按 Apply 只会再次 +显示它。面板显示说明时,卡片占满面板的高度:其中的字段可以滚动,Apply 和 Cancel 固定在卡片 +底部,即使在桌面的默认窗口中也始终可见。安全设置禁止修改的文档,或者只能以只读方式打开的文档,也会以同样的方式被拒绝。 + +pdf 服务的 `pdftools_fixture` 示例可以写出一页“网页浏览器打印出来”的页面,包含上述全部四种 +情况(一个以无名 Type 3 字体分成五块的标题,以及使用嵌入式子集字体的中文文字),可用来试用 +Edit: + +```sh +cargo run --locked -p octosense-pdf-service --example pdftools_fixture -- --edit-sample +``` + ## 从设备打开 PDF “Open a PDF from this device”请 Shell 的文件服务把用户在宿主自己的对话框中选择的一个文件 @@ -118,7 +151,12 @@ PDF to make room”)、已打开八个 PDF、关闭时有未保存的更改, 资料库的规则是 `main.splash` 中的纯函数,由 Rust 在脚本虚拟机中执行包内函数来测试 (`crates/shell/src/pdftools_model_tests.rs`):导入的 PDF 的标题、拆分出的部分的标题、 资料库索引在重启后保留的内容、页面范围所指的页面、查找摘要中加粗的匹配、头像中的姓名缩写、 -评论的日期、状态栏的存储用量文字、从各行读出的段落对齐方式,以及一次编辑发送的内容。 +评论的日期、状态栏的存储用量文字、从各行读出的段落对齐方式、一次编辑发送的内容,以及 Edit +面板对一次编辑会产生什么结果的说明(字体的名称、警告和“Apply anyway”、被拆成几块的一行)。 +它们还会解析整个脚本,包括那里的虚拟机不会运行的界面和处理函数:只要有一处解析错误,整个应用 +在加载时就会失败。 +pdf 服务自己的测试在 `--edit-sample` 页面上核对 `pdf.edit_check` 的回答与引擎随后实际所做的 +是否一致(`cargo test --locked -p octosense-pdf-service`)。 ```sh cargo test --locked -p octosense-shell --lib pdftools_model @@ -187,6 +225,12 @@ python3 apps/pdftools/dev-fixture/make_fixture.py /os.pdftoo 运行时 64 ms 的预算通过。10 月 11 日它们使用 `--budget-ms 1000`:在 64 ms 下、54 到 74 的 平均负载中,`shell` 运行通过了每个步骤,但在 pdf 服务的文件重命名造成 UI 线程停顿之后超出了 预算。 +- **已于 2026 年 10 月 11 日验证真实世界 PDF 上的 Edit**(在 Linux 构建主机上,使用私有的无头 + weston 和 Makepad 的 Wayland 后端,而不是 macOS:当时另一个会话正在 Mac 上运行 OctoSense; + [tests/evidence/edit-20261011](tests/evidence/edit-20261011/README.zh-CN.md)):模型测试; + pdf 服务的测试;在桌面 Shell 中使用真实引擎、按运行时 64 ms 的预算,在 `--edit-sample` + 页面上进行的浅色和深色运行:五块的说明、字体和空间的警告以及 Apply anyway、对中文文字的 + 直白拒绝、一次仍然进行的编辑和一次立即生效的编辑。本次改动后 macOS 上的 Edit **未验证**。 - **未验证**:宿主的文件对话框和真实的导入(隐藏窗口无法打开它);使用真实引擎时的表单 字段、大纲、Redo、其他评论和页面操作、受密码保护的 PDF、八个 PDF 的上限以及未保存更改 的询问(开发夹具覆盖了它们的界面);其他屏幕尺寸;Linux、Windows、手机。 diff --git a/apps/pdftools/bundle/main.splash b/apps/pdftools/bundle/main.splash index de7746091..d1b787054 100644 --- a/apps/pdftools/bundle/main.splash +++ b/apps/pdftools/bundle/main.splash @@ -399,6 +399,80 @@ fn colour_name(hex){ for c in TEXT_COLOURS { if c.hex == hex { return c.name } } hex } +// A font's name for a person. The engine names a font as the PDF does: a +// subset with its six-letter tag ("ABCDEF+Menlo-Regular"), an unnamed one +// (a web browser's Type 3 fonts) as "", and its own Japanese fallback as +// "Shippori Mincho Type3". +fn font_label(font){ + let f = "" + font + if f == "" { return "Unnamed font" } + f.replace(regex("^[A-Z][A-Z][A-Z][A-Z][A-Z][A-Z]\\+"), "").replace(regex(" Type3$"), "").replace(regex("-", "g"), " ") +} +// The Edit panel's Font box: the paragraph's font and its size. +fn font_text(e){ + let size = pick(e.size > 0, "" + round(e.size * 10) / 10 + " pt", "") + if size == "" { return font_label(e.font) } + font_label(e.font) + " · " + size +} +// What an edit is checked for (pdf.edit_check): its text, alignment and +// colour as they stand. +fn edit_key(e){ e.draft + "\n" + e.align + "\n" + pick(e.color == nil, "", e.color) } +// What an edit would change besides its words, from what pdf.edit_check +// answered (`check`; nil before it answers, or from an engine without the +// check): another font, text over its neighbours, text off the page. +fn edit_risks(check){ + let out = [] + if check == nil { return out } + if !optional(check, "keeps_font", true) { + out.push("Set in " + font_label(optional(check, "font", "")) + ": this paragraph's font can't show all of this text.") + } + let covers = optional(check, "covers", 0) + if covers > 0 { out.push("Longer than its box: it runs into " + plural(covers, "line", "lines") + " of text nearby.") } + if optional(check, "off_page", false) { out.push("Part of it falls off the page.") } + out +} +// Apply says "Apply anyway" while the check has something to say. +fn apply_label(e){ pick(e.check_error == "" && edit_risks(e.check).len() > 0, "Apply anyway", "Apply") } +// How many pieces the PDF keeps the line of paragraph `n` in: the +// paragraphs (from pdf.lines) on its line (their boxes overlap by half their +// height) that follow on from it end to end (a gap under half the text's +// size), it included. A web browser prints a title whose glyphs span several +// fonts as one piece per font, and the engine reads each piece as a +// paragraph. +fn line_pieces(paragraphs, n){ + let own = nil + for p in paragraphs { if p.n == n { own = p } } + if own == nil { return 1 } + let row = [] + for p in paragraphs { + let top = max(p.box[1], own.box[1]) + let bottom = min(p.box[1] + p.box[3], own.box[1] + own.box[3]) + if bottom - top > min(p.box[3], own.box[3]) * 0.5 { row.push(p) } + } + let lo = own.box[0] + let hi = own.box[0] + own.box[2] + let taken = [own.n] + let grew = true + loop { + if !grew { break } + grew = false + for p in row { + let gap = max(optional(p, "size", 0), optional(own, "size", 0)) * 0.5 + if !has(taken, p.n) && p.box[0] <= hi + gap && p.box[0] + p.box[2] >= lo - gap { + taken.push(p.n) + lo = min(lo, p.box[0]) + hi = max(hi, p.box[0] + p.box[2]) + grew = true + } + } + } + taken.len() +} +// The note for a paragraph that is one piece of a line, or "". +fn pieces_note(pieces){ + if pieces < 2 { return "" } + "The PDF stores this line in " + pieces + " pieces: only this one changes." +} start_timeout(0.05, || boot()) @@ -2201,12 +2275,59 @@ fn edit_tap(t, p, x, y){ for l in optional(lines, "lines", []) { if l.n == n { boxes.push(l.box) } } } let align = guess_align(boxes) + // `check`: what pdf.edit_check said for the edit as it was + // (`checked`, its edit_key), or why it can't be made (`check_error`). t.edit = {page: p n: hit.n box: hit.box text: hit.text draft: hit.text font: optional(hit, "font", "") size: optional(hit, "size", 0) - align: align align0: align color: nil colours: false} + align: align align0: align color: nil colours: false + pieces: line_pieces(optional(lines, "paragraphs", []), hit.n) + check: nil checked: "" check_error: "" check_n: 0 apply_after: ""} render_canvas() render_right() + check_edit(t) + }) +} +// Ask the engine what the edit would do as it stands (pdf.edit_check): the +// right panel says it before Apply. A check that comes back for an older +// draft is dropped. When Apply is waiting on it (`apply_after`), the edit is +// made if there is nothing to say; otherwise the panel says it, and Apply +// (now "Apply anyway") makes it. +fn check_edit(t){ + let e = t.edit + if e == nil { return } + let key = edit_key(e) + engine("edit_check", edit_args(t.doc, e), fn(a){ + let now = t.edit + if now == nil || now.page != e.page || now.n != e.n || edit_key(now) != key { return } + now.checked = key + now.check = nil + now.check_error = "" + if a.is_ok { now.check = a.data } else { now.check_error = check_refusal(a.error) } + let go = now.apply_after == key && now.check_error == "" && edit_risks(now.check).len() == 0 + now.apply_after = "" + render_right() + if go { make_edit(t) } + }) +} +// A check's refusal in the engine's plain words (SERVICE.md: the code, then +// words for a person); nothing for an engine without the check (an older +// shell) or no engine, where Apply finds out. +fn check_refusal(error){ + if missing(error) || outdated(error) { return "" } + err_words(error) +} +// Check again once typing (or a change of alignment or colour) pauses. +fn schedule_check(t){ + let e = t.edit + if e == nil { return } + e.check_n = e.check_n + 1 + let n = e.check_n + start_timeout(0.6, || { + let now = t.edit + if now != nil && now.page == e.page && now.n == e.n && now.check_n == n { check_edit(t) } }) } +// Apply: an edit checked as it stands is made (the panel has said what it +// would do); one not checked yet is checked first. fn apply_edit(t){ let e = t.edit if e == nil { return } @@ -2216,6 +2337,21 @@ fn apply_edit(t){ render_right() return } + let key = edit_key(e) + if e.checked != key { + e.apply_after = key + check_edit(t) + return + } + if e.check_error != "" { + note("Couldn't change the text: " + e.check_error) + return + } + make_edit(t) +} +fn make_edit(t){ + let e = t.edit + if e == nil { return } engine("edit_text", edit_args(t.doc, e), fn(a){ wrote(a) if !a.is_ok { @@ -2239,6 +2375,7 @@ fn set_align(t, a){ if t == nil || t.edit == nil { return } t.edit.align = a render_right() + schedule_check(t) } // `hex` nil: the paragraph keeps its own colour. fn set_colour(t, hex){ @@ -2246,18 +2383,13 @@ fn set_colour(t, hex){ t.edit.color = hex t.edit.colours = false render_right() + schedule_check(t) } fn toggle_colours(t){ if t == nil || t.edit == nil { return } t.edit.colours = !t.edit.colours render_right() } -fn font_text(e){ - let size = pick(e.size > 0, "" + round(e.size * 10) / 10 + " pt", "") - if e.font == "" { return size } - if size == "" { return e.font } - e.font + " · " + size -} // ---- modes and panels --------------------------------------------------------- @@ -2992,7 +3124,7 @@ fn edit_box(t, k){ View{margin: Inset{left: b[0] * k - 8 top: b[1] * k - 8} width: b[2] * k + 16 height: edit_height(e, k) flow: Overlay RoundedView{width: Fill height: Fill show_bg: true draw_bg +: {color: #xffffff border_size: 1.5 border_color: #x2171ef border_radius: 0.0}} TextInput{width: Fill height: Fill margin: 6 padding: 2 is_multiline: true text: e.draft - on_change: |text| { let x = cur_tab() if x != nil && x.edit != nil { x.edit.draft = text } } + on_change: |text| { let x = cur_tab() if x != nil && x.edit != nil { x.edit.draft = text schedule_check(x) } } draw_bg +: {color: #xffffff color_hover: #xffffff color_focus: #xffffff color_empty: #xffffff border_color: #x00000000 border_color_hover: #x00000000 border_color_focus: #x00000000 border_color_empty: #x00000000 border_size: 0.0} @@ -3502,39 +3634,101 @@ fn place_editor(t){ } fn edit_panel(t){ View{width: right_w(t) height: Fill flow: Down padding: 14 - RoundedView{width: Fill height: Fit flow: Down show_bg: true draw_bg +: {color: pt_card border_size: 1.0 border_color: pt_line border_radius: 6.0} - View{width: Fill height: 54 flow: Right align: Align{y: 0.5} padding: Inset{left: 16 right: 8} - Label{padding: 0 width: Fill text: "Edit text" draw_text +: {color: pt_ink text_style: theme.font_regular{font_size: 12.5}}} - if t.edit != nil { IconBtn{icon_walk: Walk{width: 15 height: 15} draw_icon +: {svg: icon("close") color: pt_icon} on_click: || cancel_edit(cur_tab())} } - } - Line{draw_bg.color: pt_line_soft} - if t.edit == nil { - View{width: Fill height: Fit padding: 16 Label{padding: 0 width: Fill flow: Flow.Right{wrap: true} text: pick(t.can_edit, "Click a paragraph on the page to edit its text.", "This PDF doesn't allow editing.") draw_text +: {color: pt_muted text_style: theme.font_regular{font_size: 12 line_spacing: 1.4}}}} - } - if t.edit != nil { - View{width: Fill height: 52 flow: Right align: Align{y: 0.5} padding: Inset{left: 16} - Label{padding: 0 text: "Paragraph " + t.edit.n draw_text +: {color: pt_ink2 text_style: theme.font_regular{font_size: 12.5}}} + // With something to say before Apply, the card takes the panel's + // height: its fields scroll and Apply and Cancel stay in view at its + // foot (the notes would push them out of a short window). Otherwise + // it is design 08's card, as tall as what it holds. + if t.edit != nil && edit_says(t.edit) { + RoundedView{width: Fill height: Fill flow: Down show_bg: true draw_bg +: {color: pt_card border_size: 1.0 border_color: pt_line border_radius: 6.0} + edit_head(t) + edit_paragraph(t.edit) + ScrollYView{width: Fill height: Fill flow: Down + View{width: Fill height: Fit flow: Down padding: Inset{left: 16 right: 16 top: pick(short(), 12, 18) bottom: 12} spacing: pick(short(), 8, 10) + edit_fields(t.edit) + } } Line{draw_bg.color: pt_line_soft} - View{width: Fill height: Fit flow: Down padding: Inset{left: 16 right: 16 top: pick(short(), 12, 18) bottom: pick(short(), 12, 20)} spacing: pick(short(), 8, 10) - Label{padding: 0 text: "Font" draw_text +: {color: pt_ink2 text_style: theme.font_regular{font_size: 12}}} - RoundedView{width: Fill height: 40 flow: Right align: Align{y: 0.5} padding: Inset{left: 12 right: 12} show_bg: true draw_bg +: {color: pt_field border_size: 1.0 border_color: pt_field_line border_radius: 4.0} - Label{padding: 0 width: Fill max_lines: 1 text_overflow: TextOverflow.Ellipsis text: font_text(t.edit) draw_text +: {color: pt_ink2 text_style: theme.font_regular{font_size: 12}}} - } - View{width: Fill height: Fit padding: Inset{top: pick(short(), 4, 14)} Label{padding: 0 text: "Alignment" draw_text +: {color: pt_ink2 text_style: theme.font_regular{font_size: 12}}}} - align_segments(t.edit) - View{width: Fill height: Fit padding: Inset{top: pick(short(), 4, 14)} Label{padding: 0 text: "Colour" draw_text +: {color: pt_ink2 text_style: theme.font_regular{font_size: 12}}}} - colour_box(t.edit) - View{width: Fill height: Fit flow: Right margin: Inset{top: pick(short(), 12, 22)} spacing: 14 - Primary{width: Fill height: 37 text: "Apply" on_click: || apply_edit(cur_tab())} - Outline{width: Fill height: 37 text: "Cancel" on_click: || cancel_edit(cur_tab())} + View{width: Fill height: Fit flow: Down padding: Inset{left: 16 right: 16 top: 12 bottom: pick(short(), 12, 16)} + edit_buttons(t.edit) + } + } + } + if t.edit == nil || !edit_says(t.edit) { + RoundedView{width: Fill height: Fit flow: Down show_bg: true draw_bg +: {color: pt_card border_size: 1.0 border_color: pt_line border_radius: 6.0} + edit_head(t) + if t.edit == nil { + View{width: Fill height: Fit padding: 16 Label{padding: 0 width: Fill flow: Flow.Right{wrap: true} text: pick(t.can_edit, "Click a paragraph on the page to edit its text.", "This PDF doesn't allow editing.") draw_text +: {color: pt_muted text_style: theme.font_regular{font_size: 12 line_spacing: 1.4}}}} + } + if t.edit != nil { + edit_paragraph(t.edit) + View{width: Fill height: Fit flow: Down padding: Inset{left: 16 right: 16 top: pick(short(), 12, 18) bottom: pick(short(), 12, 20)} spacing: pick(short(), 8, 10) + edit_fields(t.edit) + View{width: Fill height: Fit margin: Inset{top: pick(short(), 12, 22)} edit_buttons(t.edit)} } } } } } } +// The Edit card's head: its title, and while a paragraph is open, its close cross. +fn edit_head(t){ + View{width: Fill height: Fit flow: Down + View{width: Fill height: 54 flow: Right align: Align{y: 0.5} padding: Inset{left: 16 right: 8} + Label{padding: 0 width: Fill text: "Edit text" draw_text +: {color: pt_ink text_style: theme.font_regular{font_size: 12.5}}} + if t.edit != nil { IconBtn{icon_walk: Walk{width: 15 height: 15} draw_icon +: {svg: icon("close") color: pt_icon} on_click: || cancel_edit(cur_tab())} } + } + Line{draw_bg.color: pt_line_soft} + } +} +fn edit_paragraph(e){ + View{width: Fill height: Fit flow: Down + View{width: Fill height: 52 flow: Right align: Align{y: 0.5} padding: Inset{left: 16} + Label{padding: 0 text: "Paragraph " + e.n draw_text +: {color: pt_ink2 text_style: theme.font_regular{font_size: 12.5}}} + } + Line{draw_bg.color: pt_line_soft} + } +} +// Font (and what the edit would do, under it), Alignment and Colour. +fn edit_fields(e){ + View{width: Fill height: Fit flow: Down spacing: pick(short(), 8, 10) + Label{padding: 0 text: "Font" draw_text +: {color: pt_ink2 text_style: theme.font_regular{font_size: 12}}} + RoundedView{width: Fill height: 40 flow: Right align: Align{y: 0.5} padding: Inset{left: 12 right: 12} show_bg: true draw_bg +: {color: pt_field border_size: 1.0 border_color: pt_field_line border_radius: 4.0} + Label{padding: 0 width: Fill max_lines: 1 text_overflow: TextOverflow.Ellipsis text: font_text(e) draw_text +: {color: pt_ink2 text_style: theme.font_regular{font_size: 12}}} + } + if edit_says(e) { edit_notes(e) } + View{width: Fill height: Fit padding: Inset{top: pick(short(), 4, 14)} Label{padding: 0 text: "Alignment" draw_text +: {color: pt_ink2 text_style: theme.font_regular{font_size: 12}}}} + align_segments(e) + View{width: Fill height: Fit padding: Inset{top: pick(short(), 4, 14)} Label{padding: 0 text: "Colour" draw_text +: {color: pt_ink2 text_style: theme.font_regular{font_size: 12}}}} + colour_box(e) + } +} +fn edit_buttons(e){ + View{width: Fill height: Fit flow: Right spacing: 14 + Primary{width: Fill height: 37 text: apply_label(e) on_click: || apply_edit(cur_tab())} + Outline{width: Fill height: 37 text: "Cancel" on_click: || cancel_edit(cur_tab())} + } +} +// Whether the Edit panel has something to say about the edit before Apply. +fn edit_says(e){ e.check_error != "" || edit_risks(e.check).len() > 0 || e.pieces > 1 } +// What the edit would do, under the paragraph's font (pdf.edit_check): why +// the engine won't make it, or what changes besides the words (another font, +// text over its neighbours or off the page); and a line the PDF keeps in +// pieces. Short lines in small type, so Apply stays in view in a short +// window. +fn edit_notes(e){ + View{width: Fill height: Fit flow: Down spacing: 6 padding: Inset{top: pick(short(), 2, 6)} + if e.check_error != "" { edit_note("warning", pt_red, e.check_error) } + if e.check_error == "" { for r in edit_risks(e.check) { edit_note("warning", pt_orange, r) } } + if e.pieces > 1 { edit_note("info", pt_icon, pieces_note(e.pieces)) } + } +} +fn edit_note(ic, colour, text){ + View{width: Fill height: Fit flow: Right spacing: 7 + Ico{width: 14 height: 14 margin: Inset{top: 1} draw_svg +: {svg: icon(ic) color: colour}} + Label{padding: 0 width: Fill flow: Flow.Right{wrap: true} text: text draw_text +: {color: pt_ink2 text_style: theme.font_regular{font_size: 11.5 line_spacing: 1.35}}} + } +} // Alignment: four segments (left, centre, right, justify), the paragraph's // own (guess_align) chosen until another is tapped. fn align_segments(e){ diff --git a/apps/pdftools/design/SERVICE.md b/apps/pdftools/design/SERVICE.md index a6f9899a1..f8624ea08 100644 --- a/apps/pdftools/design/SERVICE.md +++ b/apps/pdftools/design/SERVICE.md @@ -130,11 +130,36 @@ Caps: at most 512 pages named in one call. | Method | Arguments | Answer | | --- | --- | --- | | `pdf.edit_text` | `{doc, page, paragraph?, line?, text, align?, color?}` | `{edited: true}`; exactly one of `paragraph` or `line`, numbers from `pdf.lines` | +| `pdf.edit_check` | `{doc, page, paragraph?, line?, text, align?, color?}`, as `pdf.edit_text` | `{keeps_font, font, box, covers, off_page}`: what that edit would do, without making it | `align` (`left`, `center`, `right` or `justify`) and `color` (`#rrggbb`) format -a paragraph and are refused with `line`; the paragraph keeps its font, size -and place. Without `align` its lines are set flush left, and without `color` -it keeps its own; `pdf.lines` reports neither. +a paragraph and are refused with `line`. The paragraph keeps its size and +place, and its own font when that font can show the new text; otherwise the +engine draws it in a stand-in (Helvetica, Times or Courier, bold or italic to +match), which `pdf.edit_check` reports before the edit. Without `align` its +lines are set flush left, and without `color` it keeps its own; `pdf.lines` +reports neither. + +`pdf.edit_check` makes the edit on a copy of the open document (its unsaved +edits included) with the engine's own editor, and drops the copy, so nothing +changes: + +- `keeps_font`: whether the new text is drawn in the paragraph's (or line's) + own font. +- `font`: the font it would be drawn in: its own name (`""` when the PDF + names none, as `pdf.lines` says) or the stand-in. +- `box`: where the edited text would be, `[x, y, w, h]`; `null` when none + would show. +- `covers`: how many other lines of text on the page it would run into that + the paragraph does not touch now. +- `off_page`: whether part of it would fall outside the page. + +It refuses as `pdf.edit_text` would, in the same words. Both refuse an edit +the engine will not make with `invalid:` and words for a person: text no font +the engine has can show ("PDF Tools can't edit Chinese, Japanese or Korean +text yet.", other characters named); a document that opens for reading only, +or whose security settings forbid changes; a paragraph or line number that is +no longer on the page. ## History and saving diff --git a/apps/pdftools/tests/evidence/edit-20261011/README.md b/apps/pdftools/tests/evidence/edit-20261011/README.md new file mode 100644 index 000000000..66f38666c --- /dev/null +++ b/apps/pdftools/tests/evidence/edit-20261011/README.md @@ -0,0 +1,50 @@ +# PDF Tools' Edit on a page as a web browser prints it — 2026-10-11 + +English | [简体中文](README.zh-CN.md) + +Before Apply, the Edit panel says what an edit would do besides changing +the words (`pdf.edit_check`), and it refuses what the engine won't do in +plain words. A desktop shell built from this branch at `c50104da` showed +it with the real `pdf` engine, on the page `pdftools_fixture --edit-sample` +writes. That page has a title a web browser printed in five pieces, in +unnamed Type 3 fonts, and Chinese text in an embedded subset font. + +The shell ran on the Linux build host, not on the Mac, because another +session was running OctoSense on the Mac at the time. + +- **Build:** `cargo build --locked -p octosense --no-default-features --features app-hub,craft-engines`. +- **Display:** a private headless weston 14.0.2 (pixman renderer, never + idle), with Makepad's Wayland backend. +- **Home:** a fresh `OCTOSENSE_HOME` with the sample alone in PDF Tools' + library, plus `MAKEPAD_REMOTE` and `MAKEPAD_WM_TEST_APP=pdftools`. +- **Run:** one instance, driven over the remote bridge, ended with `/quit`. + The driver used the Linux launch of [ui.py](../../ui.py) from #495 (its + `Shell` and `click_page`) and isn't in the repository yet; its steps are + below. + +Frames are `/g` grabs at one pixel per point, cropped to the app's area +(1292 x 662, the shell's default window for PDF Tools). Every step passed +at the runtime's 64 ms script budget, with no script errors in the log. +[receipt.json](receipt.json) holds the commit, the binary's digest and what +each step found. + +| Frame | What it shows | +| --- | --- | +| [e01-piece.png](e01-piece.png) | Edit, the title's first piece clicked ("Studio2026"): Paragraph 2, Font "Unnamed font · 27.8 pt", and "The PDF stores this line in 5 pieces: only this one changes." Its own text keeps its font, so Apply reads Apply | +| [e02-warned.png](e02-warned.png) | " Beta" typed: once typing pauses, "Set in Helvetica: this paragraph's font can't show all of this text." and "Longer than its box: it runs into 2 lines of text nearby." Apply reads "Apply anyway", and stays in view at the card's foot while the fields scroll | +| [e03-refused.png](e03-refused.png) | The piece 季 with 夏 typed (a character its font lacks), refused in red: "PDF Tools can't edit Chinese, Japanese or Korean text yet." | +| [e04-apply-refused.png](e04-apply-refused.png) | Apply then only repeats it: "Couldn't change the text: PDF Tools can't edit Chinese, Japanese or Korean text yet." Nothing changed (the status line still says Saved) | +| [de02-warned.png](de02-warned.png), [de03-refused.png](de03-refused.png) | The same two steps after the shell's switch to OctoSense dark | +| [e05-applied-anyway.png](e05-applied-anyway.png) | Apply anyway on "Studio2026 Beta": the engine's own result, in Helvetica over 春 and 季, as the panel said | +| [e06-applied-at-once.png](e06-applied-at-once.png) | An edit the check has nothing to say about, the Helvetica paragraph a sentence longer with nothing under it: Apply makes it at once | + +What the run found, and what changed for it: + +- **Apply out of view:** in the first run the notes pushed Apply and Cancel + out of the card at this window size. Since `c50104da`, a card with notes + takes the panel's height, its fields scroll, and Apply and Cancel stay at + its foot. The notes are shorter too. +- **The edit box shows only one line:** the box over the page is sized for + the paragraph's own text when it is clicked, and doesn't grow while you + type, so a longer draft shows only its last line ("Beta" in e02). This was + there before this change and is left as a follow-up. diff --git a/apps/pdftools/tests/evidence/edit-20261011/README.zh-CN.md b/apps/pdftools/tests/evidence/edit-20261011/README.zh-CN.md new file mode 100644 index 000000000..b8d1d3d83 --- /dev/null +++ b/apps/pdftools/tests/evidence/edit-20261011/README.zh-CN.md @@ -0,0 +1,43 @@ +# PDF Tools 在“网页浏览器打印出来的页面”上的 Edit — 2026-10-11 + +[English](README.md) | 简体中文 + +在 Apply 之前,Edit 面板会说明一次编辑除了改字之外还会带来什么变化(`pdf.edit_check`), +并用直白的话拒绝引擎做不到的编辑。用本分支 `c50104da` 构建的桌面 Shell 和真实的 `pdf` +引擎,在 `pdftools_fixture --edit-sample` 写出的页面上展示了这一点。这一页上有一个被网页 +浏览器以无名 Type 3 字体打印成五块的标题,以及使用嵌入式子集字体的中文文字。 + +这次 Shell 运行在 Linux 构建主机上,而不是在 Mac 上,因为当时另一个会话正在 Mac 上运行 +OctoSense。 + +- **构建:** `cargo build --locked -p octosense --no-default-features --features app-hub,craft-engines`。 +- **显示:** 一个私有的无头 weston 14.0.2(pixman 渲染器,永不空闲),使用 Makepad 的 + Wayland 后端。 +- **主目录:** 一个全新的 `OCTOSENSE_HOME`,PDF Tools 的资料库里只有这份示例,并设置 + `MAKEPAD_REMOTE` 和 `MAKEPAD_WM_TEST_APP=pdftools`。 +- **运行:** 只有一个实例,通过远程桥驱动,最后以 `/quit` 结束。驱动程序使用了 #495 中 + [ui.py](../../ui.py) 的 Linux 启动方式(它的 `Shell` 和 `click_page`),目前还不在仓库中; + 步骤见下文。 + +画面是每点一像素的 `/g` 截图,裁剪为应用区域(1292 x 662,即 Shell 为 PDF Tools 打开的 +默认窗口)。每一步都在运行时 64 ms 的脚本时间预算内通过,日志中没有脚本错误。 +[receipt.json](receipt.json) 记录了提交、二进制文件的摘要以及每一步的结果。 + +| 画面 | 内容 | +| --- | --- | +| [e01-piece.png](e01-piece.png) | Edit,点击标题的第一块(“Studio2026”):Paragraph 2,Font 显示“Unnamed font · 27.8 pt”,以及“The PDF stores this line in 5 pieces: only this one changes.”(PDF 把这一行存成 5 块:只有这一块会改变。)原文保持原字体,所以按钮显示 Apply | +| [e02-warned.png](e02-warned.png) | 输入“ Beta”:输入停顿后显示“Set in Helvetica: this paragraph's font can't show all of this text.”(改用 Helvetica 排版:这个段落的字体无法显示全部文字。)和“Longer than its box: it runs into 2 lines of text nearby.”(超出原来的框:会压到附近 2 行文字。)按钮显示“Apply anyway”,并且在字段滚动时固定在卡片底部,始终可见 | +| [e03-refused.png](e03-refused.png) | 在“季”这一块中输入“夏”(字体中没有这个字),以红色拒绝:“PDF Tools can't edit Chinese, Japanese or Korean text yet.”(PDF Tools 目前还不能编辑中文、日文或韩文文字。) | +| [e04-apply-refused.png](e04-apply-refused.png) | 此时按 Apply 只会再次显示这个原因:“Couldn't change the text: PDF Tools can't edit Chinese, Japanese or Korean text yet.” 文档没有任何改变(状态栏仍显示 Saved) | +| [de02-warned.png](de02-warned.png)、[de03-refused.png](de03-refused.png) | Shell 切换到 OctoSense 深色模式后的同样两步 | +| [e05-applied-anyway.png](e05-applied-anyway.png) | 对“Studio2026 Beta”按 Apply anyway:这是引擎自己的结果,用 Helvetica 排版并压在“春”和“季”上,与面板的说明一致 | +| [e06-applied-at-once.png](e06-applied-at-once.png) | 一次检查没有任何需要说明的编辑:给下方的 Helvetica 段落多加一句话,它下面没有其他内容,于是按 Apply 后立即生效 | + +这次运行发现的问题,以及为此做的改动: + +- **Apply 不在视野内:** 第一次运行时,在这个窗口尺寸下,说明文字把 Apply 和 Cancel 挤出了 + 卡片。从 `c50104da` 起,带说明的卡片会占满面板的高度,其中的字段可以滚动,Apply 和 + Cancel 固定在卡片底部。说明文字也更短了。 +- **编辑框只显示一行:** 页面上的编辑框在点击段落时按段落原文的大小确定,输入时不会变大, + 所以较长的草稿只显示最后一行(e02 中的“Beta”)。这个问题在本次改动之前就存在,留作后续 + 处理。 diff --git a/apps/pdftools/tests/evidence/edit-20261011/de02-warned.png b/apps/pdftools/tests/evidence/edit-20261011/de02-warned.png new file mode 100644 index 000000000..be7b3f8c6 Binary files /dev/null and b/apps/pdftools/tests/evidence/edit-20261011/de02-warned.png differ diff --git a/apps/pdftools/tests/evidence/edit-20261011/de03-refused.png b/apps/pdftools/tests/evidence/edit-20261011/de03-refused.png new file mode 100644 index 000000000..b4463b2eb Binary files /dev/null and b/apps/pdftools/tests/evidence/edit-20261011/de03-refused.png differ diff --git a/apps/pdftools/tests/evidence/edit-20261011/e01-piece.png b/apps/pdftools/tests/evidence/edit-20261011/e01-piece.png new file mode 100644 index 000000000..50a33184e Binary files /dev/null and b/apps/pdftools/tests/evidence/edit-20261011/e01-piece.png differ diff --git a/apps/pdftools/tests/evidence/edit-20261011/e02-warned.png b/apps/pdftools/tests/evidence/edit-20261011/e02-warned.png new file mode 100644 index 000000000..67b7bbe8f Binary files /dev/null and b/apps/pdftools/tests/evidence/edit-20261011/e02-warned.png differ diff --git a/apps/pdftools/tests/evidence/edit-20261011/e03-refused.png b/apps/pdftools/tests/evidence/edit-20261011/e03-refused.png new file mode 100644 index 000000000..449dbf60c Binary files /dev/null and b/apps/pdftools/tests/evidence/edit-20261011/e03-refused.png differ diff --git a/apps/pdftools/tests/evidence/edit-20261011/e04-apply-refused.png b/apps/pdftools/tests/evidence/edit-20261011/e04-apply-refused.png new file mode 100644 index 000000000..a4b97191b Binary files /dev/null and b/apps/pdftools/tests/evidence/edit-20261011/e04-apply-refused.png differ diff --git a/apps/pdftools/tests/evidence/edit-20261011/e05-applied-anyway.png b/apps/pdftools/tests/evidence/edit-20261011/e05-applied-anyway.png new file mode 100644 index 000000000..62fce1ee8 Binary files /dev/null and b/apps/pdftools/tests/evidence/edit-20261011/e05-applied-anyway.png differ diff --git a/apps/pdftools/tests/evidence/edit-20261011/e06-applied-at-once.png b/apps/pdftools/tests/evidence/edit-20261011/e06-applied-at-once.png new file mode 100644 index 000000000..c8c01d133 Binary files /dev/null and b/apps/pdftools/tests/evidence/edit-20261011/e06-applied-at-once.png differ diff --git a/apps/pdftools/tests/evidence/edit-20261011/receipt.json b/apps/pdftools/tests/evidence/edit-20261011/receipt.json new file mode 100644 index 000000000..9a039187f --- /dev/null +++ b/apps/pdftools/tests/evidence/edit-20261011/receipt.json @@ -0,0 +1,111 @@ +{ + "result": "passed", + "date": "2026-10-11", + "platform": "Linux x86_64 (the build host), weston 14.0.2 headless (pixman, --idle-time=0), Makepad's Wayland backend", + "commit": "c50104da", + "binary": { + "build": "cargo build --locked -p octosense --no-default-features --features app-hub,craft-engines", + "sha256": "412d3570bebd2ca29041ca801b23ebab1bd30d9e25d61d3ea7b6eb7e0845abd8" + }, + "source_sha256": { + "apps/pdftools/bundle/main.splash": "d91831ea9125355df5bdd74f6c8f5b05e58923e745404b2ff29bd64e2a0bc6a0" + }, + "sample": "cargo run --locked -p octosense-pdf-service --example pdftools_fixture -- --edit-sample /apps/os.pdftools/accounts/device/library/Launch flyer.pdf", + "launch": "a fresh OCTOSENSE_HOME (OCTOS_APP_CORE_DIR and MAKEPAD_HOME inside it), MAKEPAD_REMOTE=127.0.0.1:8937, MAKEPAD_WM_TEST_APP=pdftools, on its own weston; one instance; ended with /quit", + "driver": "a scratch driver over the Linux launch of apps/pdftools/tests/ui.py from #495 (Shell, click_page); not in the repository", + "script_budget_ms": 64, + "script_budget_overruns": 0, + "steps": [ + "Home: open Launch flyer.pdf; Edit", + "click the title's first piece (page point 128, 111): Paragraph 2, the five-pieces note, Apply", + "type ' Beta' at its end: the font and room warnings, Apply anyway (e02)", + "Cancel; click the piece 季 (253, 111), type 夏: the red refusal (e03); Apply: the banner repeats it (e04)", + "Cancel; switch to OctoSense dark; the same steps (de01-de04)", + "the title piece again, ' Beta', Apply anyway: the edit made, over 春 and 季 (e05)", + "the Helvetica paragraph (100, 200), ' Lunch is at one.' appended, Apply: made at once (e06)" + ], + "panel_labels_seen": { + "title": [ + "Launch flyer.pdf", + "Pages", + "Outline", + "Comments", + "1 / 1", + "100%", + "Edit text", + "Paragraph 2", + "Font", + "Unnamed font · 27.8 pt", + "Set in Helvetica: this paragraph's font can't show all of this text.", + "Longer than its box: it runs into 2 lines of text nearby.", + "The PDF stores this line in 5 pieces: only this one changes.", + "Alignment", + "Saved", + "80 KB of 64 MB used" + ], + "cjk": [ + "Launch flyer.pdf", + "Pages", + "Outline", + "Comments", + "1 / 1", + "100%", + "Edit text", + "Paragraph 4", + "Font", + "Unnamed font · 27.8 pt", + "PDF Tools can't edit Chinese, Japanese or Korean text yet.", + "The PDF stores this line in 5 pieces: only this one changes.", + "Alignment", + "Colour", + "Its own colour", + "Saved", + "80 KB of 64 MB used" + ], + "dtitle": [ + "Launch flyer.pdf", + "Pages", + "Outline", + "Comments", + "1 / 1", + "100%", + "Edit text", + "Paragraph 2", + "Font", + "Unnamed font · 27.8 pt", + "Set in Helvetica: this paragraph's font can't show all of this text.", + "Longer than its box: it runs into 2 lines of text nearby.", + "The PDF stores this line in 5 pieces: only this one changes.", + "Alignment", + "Saved", + "80 KB of 64 MB used" + ], + "dcjk": [ + "Launch flyer.pdf", + "Pages", + "Outline", + "Comments", + "1 / 1", + "100%", + "Edit text", + "Paragraph 4", + "Font", + "Unnamed font · 27.8 pt", + "PDF Tools can't edit Chinese, Japanese or Korean text yet.", + "The PDF stores this line in 5 pieces: only this one changes.", + "Alignment", + "Colour", + "Its own colour", + "Saved", + "80 KB of 64 MB used" + ] + }, + "findings": [ + "fixed in c50104da: the notes pushed Apply and Cancel out of the card in the 1292 x 662 app area; a card with notes now takes the panel's height, its fields scroll, Apply and Cancel stay at its foot", + "follow-up, older than this change: the edit box over the page is sized at the click and doesn't grow while typing, so a longer draft shows only its last line" + ], + "not_verified": [ + "macOS (another session was running OctoSense on the Mac)", + "a person's eyes on a phone-sized window (desktop only)" + ] +} \ No newline at end of file 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/crates/shell/src/pdftools_model_tests.rs b/crates/shell/src/pdftools_model_tests.rs index 2a1460379..3cb05f342 100644 --- a/crates/shell/src/pdftools_model_tests.rs +++ b/crates/shell/src/pdftools_model_tests.rs @@ -2,8 +2,9 @@ //! `apps/pdftools/bundle/main.splash` (everything between the shared //! interface and `start_timeout(`), run in a script VM. They decide what an //! imported PDF is called (`files.import` reports the chosen file's name; the -//! file keeps the name the app gave it) and what the library index keeps -//! across a restart. +//! file keeps the name the app gave it), what the library index keeps +//! across a restart, and what the Edit panel says an edit would do before +//! Apply (from `pdf.edit_check`). use makepad_widgets::*; use serde_json::json; @@ -319,3 +320,110 @@ fn read_mode_keeps_the_zoom_the_person_chose() { near(kept(1.0, false, "width", 5.5, 0.1, 1.0), 4.0, "at most 400%"); near(kept(1.0, false, "page", 5.5, 0.1, 1.0), 0.25, "at least 25%"); } + +#[test] +fn a_fonts_name_reads_as_a_person_would_say_it() { + let label = |font: &str| pdftools_model(&format!("font_label(\"{font}\").to_json()")); + // A subset's tag goes, and so do the engine's hyphens; a browser's Type 3 + // fonts have no name at all. + assert_eq!(label("ABCDEF+Menlo-Regular"), json!("Menlo Regular")); + assert_eq!(label("Helvetica-Bold"), json!("Helvetica Bold")); + assert_eq!(label("Times-Roman"), json!("Times Roman")); + assert_eq!(label("Shippori Mincho Type3"), json!("Shippori Mincho")); + assert_eq!(label(""), json!("Unnamed font")); + assert_eq!(label("ABCDE+Odd"), json!("ABCDE+Odd"), "five letters are no subset tag"); + // The Font box: the name and the size (whole sizes here: the script's + // numbers are single precision). + let font = |name: &str, size: u32| pdftools_model(&format!("font_text({{font: \"{name}\" size: {size}}}).to_json()")); + assert_eq!(font("", 28), json!("Unnamed font · 28 pt")); + assert_eq!(font("ABCDEF+FixtureSans-SC", 0), json!("FixtureSans SC")); +} + +#[test] +fn the_edit_panel_says_what_an_edit_would_change_before_apply() { + // What pdf.edit_check answers, as the panel puts it (the service's own + // tests hold the answers to what the engine then does). + let risks = |check: &str| pdftools_model(&format!("edit_risks({check}).to_json()")); + assert_eq!(risks("nil"), json!([]), "before the check answers, or an engine without it"); + assert_eq!(risks("{keeps_font: true font: \"\" box: [44.62, 97.54, 166.8, 27.8] covers: 0 off_page: false}"), json!([])); + assert_eq!( + risks("{keeps_font: false font: \"Helvetica-Bold\" covers: 3 off_page: false}"), + json!([ + "Set in Helvetica Bold: this paragraph's font can't show all of this text.", + "Longer than its box: it runs into 3 lines of text nearby." + ]) + ); + assert_eq!(risks("{keeps_font: true font: \"X\" covers: 1 off_page: true}"), json!(["Longer than its box: it runs into 1 line of text nearby.", "Part of it falls off the page."])); + // Apply says so while there is something to say, but not for a refusal + // (Apply would only be refused again). + let label = |check: &str, error: &str| pdftools_model(&format!("apply_label({{check: {check} check_error: \"{error}\"}}).to_json()")); + assert_eq!(label("nil", ""), json!("Apply")); + assert_eq!(label("{keeps_font: true covers: 0 off_page: false}", ""), json!("Apply")); + assert_eq!(label("{keeps_font: false font: \"Helvetica\" covers: 0 off_page: false}", ""), json!("Apply anyway")); + assert_eq!(label("nil", "PDF Tools can't edit Chinese, Japanese or Korean text yet."), json!("Apply")); + // A check is for the edit as it stands: its text, alignment and colour. + let key = |draft: &str, align: &str, color: &str| pdftools_model(&format!("edit_key({{draft: \"{draft}\" align: \"{align}\" color: {color}}}).to_json()")); + assert_eq!(key("Studio", "left", "nil"), json!("Studio\nleft\n")); + assert_ne!(key("Studio", "left", "nil"), key("Studio", "center", "nil")); + assert_ne!(key("Studio", "left", "nil"), key("Studio", "left", "\"#c01c28\"")); +} + +#[test] +fn a_title_printed_in_pieces_is_counted_as_pieces() { + // pdf.lines' paragraphs on the page `--edit-sample` writes (a page as a + // web browser prints it): the title in five pieces, one per font, then a + // Chinese paragraph and an English one below. + let page = r#"[ + {n: 1 box: [44.62, 75.77, 219.58, 10.51] size: 9.14}, + {n: 2 box: [44.62, 97.54, 166.8, 27.8] size: 27.8}, + {n: 3 box: [211.42, 97.54, 27.8, 27.8] size: 27.8}, + {n: 4 box: [239.22, 97.54, 27.8, 27.8] size: 27.8}, + {n: 5 box: [267.02, 97.54, 55.6, 27.8] size: 27.8}, + {n: 6 box: [322.62, 97.54, 27.8, 27.8] size: 27.8}, + {n: 7 box: [44.62, 150.87, 177.1, 35.15] size: 12.65}, + {n: 8 box: [44.62, 193.2, 235.55, 30.3] size: 12.0} + ]"#; + let pieces = |n: u32| pdftools_model(&format!("line_pieces({page}, {n}).to_json()")); + for n in 2..=6 { + assert_eq!(pieces(n), json!(5), "piece {n} of the title"); + } + for n in [1, 7, 8] { + assert_eq!(pieces(n), json!(1), "paragraph {n} is whole"); + } + assert_eq!(pieces(99), json!(1), "a paragraph that isn't there"); + // Two columns side by side are not one line in pieces. + let columns = "[{n: 1 box: [50, 100, 200, 12] size: 10}, {n: 2 box: [300, 100, 200, 12] size: 10}]"; + assert_eq!(pdftools_model(&format!("line_pieces({columns}, 1).to_json()")), json!(1)); + assert_eq!(pdftools_model("pieces_note(5).to_json()"), json!("The PDF stores this line in 5 pieces: only this one changes.")); + assert_eq!(pdftools_model("pieces_note(1).to_json()"), json!("")); +} + +/// The errors the script's parser and tokenizer report for `code`, run in a +/// bare VM: what runs after them fails there (no widgets, no host), and is +/// not counted. +fn parse_errors(code: &str) -> Vec { + let mut host = ScriptVmHost::new((), ()); + let mut vm = ScriptVm { + host: &mut host, + bx: Box::new(ScriptVmBase::new()), + }; + vm.bx.captured_errors = Some(Vec::new()); + vm.with_instruction_limit(200_000, |vm| { + vm.eval(ScriptMod { + file: "pdftools_parse_test.splash".into(), + code: format!("use mod.math.*\nuse mod.std.regex\n{code}\n;"), + ..Default::default() + }) + }); + vm.take_errors().into_iter().filter(|e| e.contains("parser.rs") || e.contains("tokenizer.rs")).collect() +} + +#[test] +fn the_whole_bundle_parses() { + // The model tests above run only the functions before `start_timeout(`; + // the views and handlers after it are parsed when the app loads, and one + // parse error there fails the whole app. The parser reads all of it. + assert_eq!(parse_errors(PDF_TOOLS), Vec::::new()); + // The check itself finds a parse error (a reserved name, as `me` is). + assert!(!parse_errors(&format!("{PDF_TOOLS}\nfn broken(){{ let me = 1 }}")).is_empty()); +} 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("/.*$", "");