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..1a5da424e 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 @@ -90,6 +135,13 @@ title is taken); the file keeps the name the app gave it and the title lives in `library.json`. Then it opens in a tab. A refusal shows on Home in plain words with the host's own message below it. +On Linux the host's dialog is the desktop's own helper, which Makepad runs: +the first of zenity, qarma, matedialog and kdialog on `PATH`. A machine with +none of them gets no `files.import` (`files.status` says +`import_supported: false`), so Home says "Not available here" beside the +button, and the button says that this OctoSense can't open files from the +device for PDF Tools. + ## How it works Every path is relative to the app's storage, the folder `fs.*` sees; the @@ -150,7 +202,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 @@ -166,10 +224,20 @@ grab and ends with `/quit`; a lock folder keeps it to one hidden shell on the machine, and it waits while another OctoSense runs). With `--card-host` it uses App Hub's `card-host`, which serves no host services. +On Linux, where `MAKEPAD_HIDE_WINDOWS` does nothing, `--shell` gives each run +a headless weston of its own instead of a Terminal.app tab: the pixman +renderer, never idle, in a private runtime folder. The shell runs on it with +Makepad's Wayland backend and the run home's XDG folders, and weston stops +after `/quit`. Another OctoSense on the machine doesn't hold the run up. Give +`--grab-scale 1` for one pixel per point (the window's dpi is 1 there). Give +`--work` on the disk you mean to test, because Ubuntu's `/tmp` is tmpfs. + ```sh cargo build --locked -p octosense --no-default-features --features app-hub,craft-engines python3 apps/pdftools/tests/ui.py --shell target/debug/octosense --lock

--output target/pdftools-ui python3 apps/pdftools/tests/ui.py --card-host /target/release/card-host --output target/pdftools-ui +# Linux +python3 apps/pdftools/tests/ui.py --shell target/debug/octosense --lock --work --grab-scale 1 --output target/pdftools-ui ``` The runtime gives one handler 64 ms of wall-clock time, so on a busy machine @@ -179,7 +247,7 @@ exceeded` in its log); `--budget-ms 1000` raises the hosts' budget | Run | What it covers | | --- | --- | -| `shell` | Every mode on the samples in the shell's own window, light, then dark through the shell's own style menu (the dark pass works on what the light pass left): Home and its covers, the status line against the storage's own bytes, "Open a PDF from this device" refused, reading and thumbnails, Read mode (entered from the pill, a page turned and the zoom changed in it, left by Exit read mode or the corner control on that page at that zoom), find ("9 matches on 4 pages") from the top bar alone, with the left panel given back when it ends, a highlight and its reply dated on this machine's clock ("Today 19:42"), a rotation and its undo, combine with a page range ("One PDF of 5 pages"), initials placed by Fill & Sign, a text edit with an alignment and a colour, save, and the damaged file; then one cover per PDF in the cover cache, each in `library.json` | +| `shell` | Every mode on the samples in the shell's own window, light, then dark through the shell's own style menu (the dark pass works on what the light pass left): Home and its covers, the status line against the storage's own bytes, "Open a PDF from this device" refused (on Linux with no dialog helper, "Not available here"), reading and thumbnails, Read mode (entered from the pill, a page turned and the zoom changed in it, left by Exit read mode or the corner control on that page at that zoom), find ("9 matches on 4 pages") from the top bar alone, with the left panel given back when it ends, a highlight and its reply dated on this machine's clock ("Today 19:42"), a rotation and its undo, combine with a page range ("One PDF of 5 pages"), initials placed by Fill & Sign, a text edit with an alignment and a colour, save, and the damaged file; then one cover per PDF in the cover cache, each in `library.json` | | `restart` | The same home again: the library, its titles, the page a PDF was left at, `Imported PDF.pdf` placed as an import leaves one, every current cover kept, not drawn again, and a PDF removed from Home taking its kept cover with it | | `full` | Storage filled to 8 KB short of its 64 MiB: the refusal on Home, then a removal, which asks once more | | `empty` | No PDFs: the empty library and its Open button, light and dark | @@ -201,7 +269,10 @@ python3 apps/pdftools/dev-fixture/make_fixture.py /os.pdftoo The host's dialog cannot be driven in a hidden shell, and a hidden window never has focus, so the files service refuses `files.import` there before the dialog opens (`foreground_required: …`); the runs check that the app shows -that refusal. +that refusal. On Linux the run's window is mapped on its weston and counts as +focused. With no dialog helper on `PATH` the runs check "Not available here" +and the button's note instead. With a helper the dialog would really open, so +they leave the step out and say so in `run.json`. ## Status @@ -233,11 +304,33 @@ 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. -- **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 - cap and the unsaved-changes question (the dev fixture covers their screens); - other screen sizes; Linux, Windows, phones. +- **Verified on 11 Oct 2026 on Linux** (the Linux build host: Ubuntu 26.04, + x86_64, software GL under a headless weston, homes on ext4; + [tests/evidence/linux-20261011](tests/evidence/linux-20261011/README.md)): + - the model tests and the pdf service's tests; + - the `shell`, `restart`, `full` and `empty` runs with the real engine at + the runtime's 64 ms budget, five times: four in UTC (the last two after + rebases onto main with #485, #494 and #487), and once in UTC+14, where + comment dates crossed midnight correctly; + - PDF Tools opening at 1296 x 698 (an app area of 1292 x 662), as on the + Mac; + - "Open a PDF from this device" importing a PDF for real through Makepad's + Linux dialog path, with a stand-in answering for the dialog helper. +- **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 own file dialog (a hidden window on the Mac + cannot open it; on Linux a stand-in helper answered); with the real engine, + form fields, outlines, Redo, the other comment and page operations, + password-protected PDFs, the eight-PDF cap and the unsaved-changes question + (the dev fixture covers their screens); other screen sizes; on Linux, X11, + a GPU and a desktop compositor; Windows; phones. - **Known**: Read mode fills the app's window, not the screen: no app can make the OctoSense window itself full screen (there is no host API for it), so maximize the window for the full effect. On a heavily loaded machine a diff --git a/apps/pdftools/README.zh-CN.md b/apps/pdftools/README.zh-CN.md index 1026fce9f..d0d10a5d1 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 的文件服务把用户在宿主自己的对话框中选择的一个文件 @@ -73,6 +106,11 @@ PDF to make room”)、已打开八个 PDF、关闭时有未保存的更改, “Lease 2026 (2)”);文件本身保留应用为它取的名字,标题记录在 `library.json` 中。随后它在 一个标签中打开。若被拒绝,主页会用通俗的文字说明原因,下面附上宿主自己的消息。 +在 Linux 上,宿主的对话框是桌面自带的助手程序,由 Makepad 运行:即 `PATH` 上 zenity、 +qarma、matedialog 和 kdialog 中的第一个。一个都没有的机器得不到 `files.import` +(`files.status` 回答 `import_supported: false`),所以主页在按钮旁显示“Not available +here”,点按按钮会说明这个 OctoSense 无法为 PDF Tools 从设备打开文件。 + ## 工作原理 所有路径都相对于应用的存储,即 `fs.*` 看到的文件夹;引擎也在同一个文件夹中工作。 @@ -118,7 +156,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 @@ -132,10 +175,19 @@ cargo test --locked -p octosense-shell --lib pdftools_model OctoSense 在运行时它会等待)。使用 `--card-host` 时,它使用 App Hub 的 `card-host`,后者 不提供任何宿主服务。 +在 Linux 上,`MAKEPAD_HIDE_WINDOWS` 不起作用,所以 `--shell` 不使用 Terminal.app 标签页,而是 +为每次运行启动一个独占的无头 weston:使用 pixman 渲染器,从不进入空闲状态,运行时文件夹是私有 +的。Shell 在它上面运行,使用 Makepad 的 Wayland 后端,XDG 文件夹都放在本次运行的主目录中; +`/quit` 之后 weston 随之停止。机器上的其他 OctoSense 不会让运行等待。用 `--grab-scale 1` +得到每点一个像素(那里窗口的 dpi 为 1)。用 `--work` 指定你要测试的磁盘,因为 Ubuntu 的 +`/tmp` 是 tmpfs。 + ```sh cargo build --locked -p octosense --no-default-features --features app-hub,craft-engines python3 apps/pdftools/tests/ui.py --shell target/debug/octosense --lock --output target/pdftools-ui python3 apps/pdftools/tests/ui.py --card-host /target/release/card-host --output target/pdftools-ui +# Linux +python3 apps/pdftools/tests/ui.py --shell target/debug/octosense --lock --work --grab-scale 1 --output target/pdftools-ui ``` 运行时给每个处理函数 64 ms 的挂钟时间,因此在繁忙的机器上,一次运行可能因为某个处理函数 @@ -144,7 +196,7 @@ python3 apps/pdftools/tests/ui.py --card-host /target/release/card-host | 运行 | 覆盖内容 | | --- | --- | -| `shell` | 在 Shell 自己的窗口中、在示例上覆盖每种模式,先浅色,再通过 Shell 自己的样式菜单切到深色(深色这一轮在浅色一轮留下的状态上进行):主页及其封面、状态栏与存储实际字节数相符、“Open a PDF from this device”被拒绝、阅读和缩略图、阅读模式(从浮动控制条进入,在其中翻页并改变缩放,再用 Exit read mode 或右上角按钮退出,回到同一页、同一缩放)、只用顶栏的查找(“9 matches on 4 pages”,结束时交还左侧面板)、一处高亮及其回复(日期按本机时钟显示,如“Today 19:42”)、一次旋转及其撤销、带页面范围的合并(“One PDF of 5 pages”)、Fill & Sign 放置的姓名缩写、一次带对齐方式和颜色的文字编辑、保存,以及损坏的文件;之后检查封面缓存中每个 PDF 只有一个封面,且都记在 `library.json` 中 | +| `shell` | 在 Shell 自己的窗口中、在示例上覆盖每种模式,先浅色,再通过 Shell 自己的样式菜单切到深色(深色这一轮在浅色一轮留下的状态上进行):主页及其封面、状态栏与存储实际字节数相符、“Open a PDF from this device”被拒绝(在没有对话框助手程序的 Linux 上为“Not available here”)、阅读和缩略图、阅读模式(从浮动控制条进入,在其中翻页并改变缩放,再用 Exit read mode 或右上角按钮退出,回到同一页、同一缩放)、只用顶栏的查找(“9 matches on 4 pages”,结束时交还左侧面板)、一处高亮及其回复(日期按本机时钟显示,如“Today 19:42”)、一次旋转及其撤销、带页面范围的合并(“One PDF of 5 pages”)、Fill & Sign 放置的姓名缩写、一次带对齐方式和颜色的文字编辑、保存,以及损坏的文件;之后检查封面缓存中每个 PDF 只有一个封面,且都记在 `library.json` 中 | | `restart` | 再次使用同一个主目录:资料库、其中的标题、PDF 离开时所在的页面、按导入方式放入的 `Imported PDF.pdf`、所有当前封面都被保留且没有重新绘制,以及从主页移除的 PDF 连同其保留的封面一起删除 | | `full` | 存储被填到距 64 MiB 只差 8 KB:主页上的拒绝提示,然后移除一个 PDF(会再确认一次) | | `empty` | 没有 PDF:空资料库及其 Open 按钮,浅色和深色 | @@ -162,7 +214,9 @@ python3 apps/pdftools/dev-fixture/make_fixture.py /os.pdftoo 隐藏的 Shell 中无法操作宿主的对话框,而且隐藏窗口永远不会获得焦点,所以文件服务会在对话框 打开前就拒绝 `files.import`(`foreground_required: …`);各次运行会检查应用是否显示了这个 -拒绝。 +拒绝。在 Linux 上,运行中的窗口映射在它的 weston 上,并被视为有焦点。`PATH` 上没有对话框 +助手程序时,各次运行改为检查“Not available here”和按钮给出的提示。有助手程序时,对话框会真的 +打开,所以各次运行会跳过这一步,并在 `run.json` 中说明。 ## 状态 @@ -187,9 +241,26 @@ python3 apps/pdftools/dev-fixture/make_fixture.py /os.pdftoo 运行时 64 ms 的预算通过。10 月 11 日它们使用 `--budget-ms 1000`:在 64 ms 下、54 到 74 的 平均负载中,`shell` 运行通过了每个步骤,但在 pdf 服务的文件重命名造成 UI 线程停顿之后超出了 预算。 -- **未验证**:宿主的文件对话框和真实的导入(隐藏窗口无法打开它);使用真实引擎时的表单 - 字段、大纲、Redo、其他评论和页面操作、受密码保护的 PDF、八个 PDF 的上限以及未保存更改 - 的询问(开发夹具覆盖了它们的界面);其他屏幕尺寸;Linux、Windows、手机。 +- **已于 2026 年 10 月 11 日在 Linux 上验证**(Linux 构建主机:Ubuntu 26.04,x86_64,在无头 + weston 下使用软件 GL,主目录位于 ext4; + [tests/evidence/linux-20261011](tests/evidence/linux-20261011/README.zh-CN.md)): + - 模型测试和 pdf 服务的测试; + - 使用真实引擎、按运行时 64 ms 的预算进行的 `shell`、`restart`、`full` 和 `empty` 运行, + 共五次:四次在 UTC 时区(最后两次是在变基到包含 #485、#494 和 #487 的 main 之后),一次在 + UTC+14 时区,后者中评论日期正确地跨过了午夜; + - PDF Tools 以 1296 x 698 打开(应用区域 1292 x 662),与 Mac 上相同; + - “Open a PDF from this device”经由 Makepad 在 Linux 上的对话框路径真实地导入了一个 PDF, + 由一个替身代替对话框助手程序作答。 +- **已于 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 **未验证**。 +- **未验证**:宿主自己的文件对话框(Mac 上的隐藏窗口无法打开它;在 Linux 上由替身助手程序 + 作答);使用真实引擎时的表单字段、大纲、Redo、其他评论和页面操作、受密码保护的 PDF、八个 + PDF 的上限以及未保存更改的询问(开发夹具覆盖了它们的界面);其他屏幕尺寸;Linux 上的 + X11、GPU 和桌面合成器;Windows;手机。 - **已知问题**:阅读模式占满的是应用窗口,而不是整个屏幕:应用无法让 OctoSense 窗口本身全屏 (没有相应的宿主 API),想要完整效果请将窗口最大化。在负载很高的机器上,一次点按可能超出 64 ms 预算而丢失(在 `library.json` 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/apps/pdftools/tests/evidence/linux-20261011/README.md b/apps/pdftools/tests/evidence/linux-20261011/README.md new file mode 100644 index 000000000..a390ba020 --- /dev/null +++ b/apps/pdftools/tests/evidence/linux-20261011/README.md @@ -0,0 +1,187 @@ +# PDF Tools on Linux — 2026-10-11 + +English | [简体中文](README.zh-CN.md) + +Until now PDF Tools had been verified only on macOS. Here [ui.py](../../ui.py) +ran its four shell journeys on Linux, on main 5cab54ad, which has #479 (the +follow-ups) and #478 (the window hint). This branch changes only ui.py, which +now also launches the shell on Linux. During the runs main moved twice: +first to 3632d376, adding #485 (the dock hides while OctoSense is full +screen) and #494 (the pdfcraft repin), then to 3a563057, adding #487 +(maximize and snaps end above the dock). After each move the branch was +rebased, and the journeys, the import and the window passed again; the +frames are from the first run, on 5cab54ad. [receipt.json](receipt.json) +holds the binaries' and the sources' digests, the commands, what each run +saw, the Linux findings and what was not verified. + +**The setup:** +- The Linux build host: Ubuntu 26.04 LTS, x86_64, 72 CPUs, no GPU and no + desktop session. +- A scratch checkout of main, prepared by `tools/setup.py` against the host's + source hubs. +- The desktop shell, with the real `pdf` engine (`cargo build --locked -p + octosense --no-default-features --features app-hub,craft-engines`, debug). +- On Linux, ui.py gives each session a headless weston of its own in a + private runtime folder (`weston --backend=headless --renderer=pixman + --idle-time=0 --socket=… --no-config`). + - The shell runs on it in a session of its own, with Makepad's Wayland + backend and the run home's XDG folders. + - Mesa's llvmpipe draws (software GL). + - The session ends with `/quit`, and weston stops after the shell. + - `MAKEPAD_HIDE_WINDOWS` does nothing on Linux, so the window is mapped on + that weston. +- Every home was on ext4 (`--work`): the host's `/tmp` is tmpfs. +- The runtime's 64 ms script budget throughout. +- Each frame is the app's area cut from a `/g` grab taken at one pixel per + point (`--grab-scale 1`; the window's dpi is 1). + +```sh +python3 apps/pdftools/tests/ui.py --shell target/debug/octosense --lock /octosense-shell.lock \ + --work /homes --grab-scale 1 --output /ui-64ms +TZ=Pacific/Kiritimati python3 apps/pdftools/tests/ui.py --shell target/debug/octosense \ + --lock /octosense-shell.lock --work /homes --grab-scale 1 --output /ui-tz +python3 apps/pdftools/tests/evidence/linux-20261011/import/check.py \ + --shell target/debug/octosense --output /import-check +``` + +**The runs:** +- **In the host's own zone (UTC),** from 14:25 to 14:29 UTC at load averages + of 18 to 35: `shell` (light, then dark), `restart`, `full` and `empty` all + passed on the first attempt. +- **With `TZ=Pacific/Kiritimati` (UTC+14),** from 14:32 to 14:36 UTC, when + it was already 12 Oct there: the same four passed again. +- **With the final ui.py, in UTC,** from 14:54 to 14:58 UTC, while another + session's local CI ran on the host: the same four passed a third time. + The first two runs and the import check used ui.py before its last + change, which only stops the shell and its weston when the bridge never + answers. +- **After the rebases onto main 3632d376, then 3a563057,** from 15:02 to + 15:06 and from 15:10 to 15:14 UTC: the same four passed a fourth and a + fifth time, PDF Tools opened at the same sizes, and the import check + passed again each time. +- No log had a script error or a budget overrun, and every shell exited + after `/quit`. +- **[import/check.py](import/check.py)** ran "Open a PDF from this device" + with a stand-in dialog helper: see below. +- The pdf service's tests (`cargo test --locked -p octosense-pdf-service`) + and the PDF Tools model tests (`cargo test --locked -p octosense-shell --lib + pdftools_model`, 18) pass on the host too. + +**The frames:** +- [light/](light), [dark/](dark), [restart/](restart), [full/](full) and + [empty/](empty) are from the first run. They show what the macOS frames + in [v2-followups-20261010](../v2-followups-20261010/README.md) show, + except for the file dialog. + - **01-home, 01b-open-from-device:** "Not available here" beside the + button, and the button's banner (below). The status line reads "187 KB + of 64 MB used", as on the Mac. + - **02c-read-mode, 02d-read-mode-zoomed, 02e-read-mode-left:** Read mode, + entered on page 2 of 4. Then page 3 at 125%, and left on page 3 at 125%. + - **03-find, 03b-find-done:** "9 matches on 4 pages" from the top bar. + Done gives back the Comments panel. + - **04-comment, 04b-replied:** the highlight and its reply, "Today 14:25" + at 14:25 UTC. + - **05-pages-rotated, 06-combine, 06b-combined, 07-fill-sign, 08a-colours, + 08b-edited, 09-saved, 10-damaged:** the rotation, Combine with its button + in view, the initials, the paragraph centred and red, the save and the + damaged file. + - **dark/:** the same in the dark style. Read mode is left by its corner + control, and the paragraph is set flush right. + - **restart/:** the covers kept, the page a PDF was left at, the import, + and a removal taking its cover. + - **full/:** "63.9 of 64 MB used", and a removal. + - **empty/:** light and dark. +- [compare/](compare): Linux on the left, macOS on the right, halved: Home, a + page, find and the edited paragraph. +- [tz/](tz): the second run's highlight and reply. **04b-replied** reads + "Today 04:32" at 14:32 UTC on 11 Oct, which was 04:32 on 12 Oct in UTC+14. +- [import/](import): the import through the stand-in helper. +- [window/opened-from-the-menu.png](window/opened-from-the-menu.png): the + whole window, with PDF Tools opened from the shell's menu. + +## What is different on Linux + +- **The file dialog.** makepad's Linux file dialog is neither + xdg-desktop-portal nor GTK. It runs the desktop's own helper, the first of + zenity, qarma, matedialog and kdialog on `PATH` (kdialog first under KDE), + and reads the chosen path from its output + (`platform/src/os/linux/file_dialog.rs`). + - The build host has none of these, so the shell does not offer + `files.import`, and `files.status` answers `import_supported: false`. + - PDF Tools shows "Not available here" beside "Open a PDF from this + device". Its button shows the banner "This OctoSense can't open files + from the device for PDF Tools." Nothing hangs, and nothing is logged as + an error. On Linux, ui.py now checks this instead of the macOS refusal. + - On the Mac a hidden window never has focus, so the dialog is refused. + On Linux the window is mapped on the run's weston, and the files service + counts a window outside macOS as focused until it reports losing focus. + So with a helper on `PATH` the dialog would really open. ui.py leaves + the step out on such a machine and says so in its run.json. +- **A real import, with a stand-in helper.** [import/check.py](import/check.py) + puts a stand-in for zenity first on the shell's `PATH`. The stand-in + records how it was asked and answers with a copy of the samples' field + guide named `Lease 2026.pdf`. Everything after it is shipped code. + - The helper was asked `zenity --file-selection --title=Import a file into + this app`. + - The 16,477 bytes were copied byte for byte into + `accounts/device/library/Imported PDF.pdf`. + - `library.json` titles it "Lease 2026", with 7 pages and a cover. + - It opened in a tab, "Lease 2026.pdf", on "1 / 7" + ([import.json](import/import.json)). + - No test had run a real import before: on the Mac the hidden window has + the dialog refused. The real helpers' own windows were not run. + - The import passes no file filter (the files service adds one only for + photos), so a helper would list every file. That is the same on every + platform. +- **Fonts and text.** The host has only DejaVu (8 font files), and nothing + differs from macOS. The shell's interface draws with Makepad's bundled + fonts. The engine's renders of the samples' sans-serif and serif text + match the Mac's: the same text, wrapping and layout in compare/. CJK text + was not exercised. +- **Software GL.** A shell's start stalls its UI thread while llvmpipe + compiles shaders: a first event of 3.1 to 3.6 s, and draw-list stalls of + 0.5 to 1.2 s (one more comes with the first page). Apart from those, no + stall logged in the five runs was longer than 409 ms, and no script + handler overran 64 ms. +- **The window.** On weston the shell's window is 1400 x 900 points at dpi 1. + - At startup the log says "wm: pdftools asks for a 1536x1024 window; it + opens at 1296x698 (the dock covers 78 points of the desk)". That is an + app area of 1292 x 662, the wide layout, as in the macOS hidden shell. + - Opened from the menu once the desk had drawn, the window is 1296 x 709 + (an app area of 1292 x 673) and ends above the dock. The macOS hidden + shell, a 1400 x 897 window, gave 1296 x 703. + - With #485 the dock hides only while OctoSense is full screen (on Wayland, + also maximized). The shell's window on weston is neither, so the dock + stays, and PDF Tools opened at the same sizes on main 3632d376 and on + 3a563057, which also has #487. +- **Comment dates.** In UTC the local time is UTC itself, so the first run + alone cannot tell the two apart. Under UTC+14 the dates crossed midnight + correctly: the highlight read "Today 04:32", not 14:32 and not + "Yesterday". + - The pdf service takes the offset from `localtime_r`. + - The shell sets the script's `std.local_time` offset from the same call, + so the two agree. +- **ext4.** `pdf.save`'s staging and rename, the render cache's writes and + the covers all ran at 64 ms with no overrun, at load averages of 10 to 35. + - On the Mac the 64 ms runs overran after 262 and 555 ms renames, at load + averages of 54 to 74, so this is not a like-for-like comparison. + - After the restart the five current covers kept their nanosecond + modification times, and only the new import's cover was drawn. + - Remove deleted the PDF's cover. + - The cache evicts by modification time, not by directory order (which + differs between ext4 and APFS). + +No Linux-only bug turned up, so this branch carries no fix. + +## Not verified + +- Windows: there is no machine here. +- A real helper's own dialog (zenity, qarma, matedialog, kdialog). None is + installed on the host, and nothing was installed for this; a stand-in + answered. makepad has no xdg-desktop-portal path. +- X11: weston's Xwayland cannot start on the host (it has no + `/tmp/.X11-unix`). Also a desktop compositor such as GNOME or KDE, a GPU + driver, and a scale above 1 on Linux. +- ui.py's card-host runs (`missing`, `fixture`) on Linux, and a release + build. +- CJK and other non-Latin text on a host with no such fonts. diff --git a/apps/pdftools/tests/evidence/linux-20261011/README.zh-CN.md b/apps/pdftools/tests/evidence/linux-20261011/README.zh-CN.md new file mode 100644 index 000000000..f446da8d4 --- /dev/null +++ b/apps/pdftools/tests/evidence/linux-20261011/README.zh-CN.md @@ -0,0 +1,145 @@ +# PDF Tools 在 Linux 上的运行记录 — 2026-10-11 + +[English](README.md) | 简体中文 + +此前 PDF Tools 只在 macOS 上验证过。这一次,[ui.py](../../ui.py) 在 Linux 上运行了它的四个 +Shell 流程,所用的是 main 5cab54ad,其中包含 #479(后续改进)和 #478(窗口提示)。这个分支 +只修改了 ui.py,使它在 Linux 上也能启动 Shell。运行期间 main 前进了两次:先到 3632d376,新增 +#485(OctoSense 全屏时程序坞自动隐藏)和 #494(更新 pdfcraft 的固定版本),再到 3a563057, +新增 #487(最大化和吸附窗口的底边位于程序坞之上)。每次前进后分支都变基到新的 main 上,各流程、 +导入和窗口检查都再次通过;各帧来自第一次运行,即 5cab54ad 上的运行。 +[receipt.json](receipt.json) 记录了二进制和源文件的摘要、所用命令、每次运行观察到的内容、 +Linux 上的发现,以及未验证的部分。 + +**环境:** +- Linux 构建主机:Ubuntu 26.04 LTS,x86_64,72 个 CPU,没有 GPU,也没有桌面会话。 +- main 的一个临时检出,用 `tools/setup.py` 针对主机上的源码中心仓库准备。 +- 桌面 Shell,使用真实的 `pdf` 引擎(`cargo build --locked -p octosense + --no-default-features --features app-hub,craft-engines`,debug 构建)。 +- 在 Linux 上,ui.py 为每个会话在一个私有的运行时文件夹中启动一个独占的无头 weston + (`weston --backend=headless --renderer=pixman --idle-time=0 --socket=… --no-config`)。 + - Shell 在它上面以独立会话运行,使用 Makepad 的 Wayland 后端,XDG 文件夹都放在本次运行的 + 主目录中。 + - 由 Mesa 的 llvmpipe 绘制(软件 GL)。 + - 会话以 `/quit` 结束,Shell 退出后 weston 随之停止。 + - `MAKEPAD_HIDE_WINDOWS` 在 Linux 上不起作用,所以窗口会映射到这个 weston 上。 +- 每个主目录都在 ext4 上(`--work`):这台主机的 `/tmp` 是 tmpfs。 +- 全程使用运行时 64 ms 的脚本预算。 +- 每一帧都是从 `/g` 截图中按应用区域裁出的,截图为每点一个像素(`--grab-scale 1`;窗口的 + dpi 为 1)。 + +```sh +python3 apps/pdftools/tests/ui.py --shell target/debug/octosense --lock /octosense-shell.lock \ + --work /homes --grab-scale 1 --output /ui-64ms +TZ=Pacific/Kiritimati python3 apps/pdftools/tests/ui.py --shell target/debug/octosense \ + --lock /octosense-shell.lock --work /homes --grab-scale 1 --output /ui-tz +python3 apps/pdftools/tests/evidence/linux-20261011/import/check.py \ + --shell target/debug/octosense --output /import-check +``` + +**各次运行:** +- **使用主机自己的时区(UTC),** UTC 14:25 到 14:29,平均负载 18 到 35:`shell`(先浅色, + 再深色)、`restart`、`full` 和 `empty` 第一次运行就全部通过。 +- **使用 `TZ=Pacific/Kiritimati`(UTC+14),** UTC 14:32 到 14:36,那里已经是 10 月 12 日: + 同样四个流程再次全部通过。 +- **使用最终版本的 ui.py,在 UTC 时区,** UTC 14:54 到 14:58,当时主机上另一个会话的本地 CI + 正在运行:同样四个流程第三次全部通过。前两次运行和导入检查使用的是最后一次修改之前的 + ui.py,那次修改只是在远程桥始终不应答时停止 Shell 及其 weston。 +- **先后变基到 main 3632d376 和 3a563057 之后,** UTC 15:02 到 15:06 以及 15:10 到 15:14: + 同样四个流程第四次和第五次全部通过,PDF Tools 以相同的尺寸打开,导入检查每次都再次通过。 +- 所有日志中都没有脚本错误或超出预算的记录,每个 Shell 在 `/quit` 后都退出了。 +- **[import/check.py](import/check.py)** 用一个替身对话框助手运行了“Open a PDF from this + device”,见下文。 +- pdf 服务的测试(`cargo test --locked -p octosense-pdf-service`)和 PDF Tools 的模型测试 + (`cargo test --locked -p octosense-shell --lib pdftools_model`,18 个)在这台主机上也都通过。 + +**各帧:** +- [light/](light)、[dark/](dark)、[restart/](restart)、[full/](full) 和 [empty/](empty) + 来自第一次运行。除了文件对话框之外,它们显示的内容与 + [v2-followups-20261010](../v2-followups-20261010/README.zh-CN.md) 中 macOS 的各帧相同。 + - **01-home、01b-open-from-device**:按钮旁显示“Not available here”,以及点按按钮后的横幅 + (见下文)。状态栏显示“187 KB of 64 MB used”,与 Mac 上相同。 + - **02c-read-mode、02d-read-mode-zoomed、02e-read-mode-left**:在第 2 页(共 4 页)进入 + 阅读模式,然后翻到第 3 页、缩放到 125%,退出时仍在第 3 页、125%。 + - **03-find、03b-find-done**:从顶栏查找,“9 matches on 4 pages”。按 Done 后评论面板恢复。 + - **04-comment、04b-replied**:一处高亮及其回复,日期为“Today 14:25”,当时是 UTC 14:25。 + - **05-pages-rotated、06-combine、06b-combined、07-fill-sign、08a-colours、08b-edited、 + 09-saved、10-damaged**:旋转、合并(按钮保持可见)、姓名缩写、设为居中和红色的段落、 + 保存以及损坏的文件。 + - **dark/**:深色样式下的同样步骤。阅读模式用右上角的控件退出,段落设为右对齐。 + - **restart/**:保留的封面、PDF 离开时所在的页面、导入的文件,以及连同封面一起的移除。 + - **full/**:“63.9 of 64 MB used”,以及一次移除。 + - **empty/**:浅色和深色。 +- [compare/](compare):左边是 Linux,右边是 macOS,缩小一半:主页、一个页面、查找和编辑后的 + 段落。 +- [tz/](tz):第二次运行中的高亮及其回复。**04b-replied** 显示“Today 04:32”,当时是 10 月 + 11 日 UTC 14:32,也就是 UTC+14 的 10 月 12 日 04:32。 +- [import/](import):通过替身助手完成的导入。 +- [window/opened-from-the-menu.png](window/opened-from-the-menu.png):整个窗口,PDF Tools + 从 Shell 的菜单打开。 + +## Linux 上的不同之处 + +- **文件对话框。** makepad 在 Linux 上的文件对话框既不是 xdg-desktop-portal,也不是 GTK。 + 它运行桌面自带的助手程序,即 `PATH` 上 zenity、qarma、matedialog 和 kdialog 中的第一个 + (KDE 下 kdialog 优先),并从它的输出中读取所选路径 + (`platform/src/os/linux/file_dialog.rs`)。 + - 这台构建主机上一个都没有,所以 Shell 不提供 `files.import`,`files.status` 回答 + `import_supported: false`。 + - PDF Tools 在“Open a PDF from this device”旁显示“Not available here”,点按按钮会显示横幅 + “This OctoSense can't open files from the device for PDF Tools.”。没有卡住,也没有记录 + 任何错误。在 Linux 上,ui.py 现在检查的是这一点,而不是 macOS 上的拒绝提示。 + - 在 Mac 上,隐藏窗口永远不会获得焦点,所以对话框会被拒绝。在 Linux 上,窗口映射在本次 + 运行的 weston 上,而文件服务把 macOS 以外的窗口视为有焦点,直到它报告失去焦点。所以只要 + `PATH` 上有助手程序,对话框就会真的打开。在这样的机器上,ui.py 会跳过这一步,并在它的 + run.json 中说明。 +- **借助替身助手完成的真实导入。** [import/check.py](import/check.py) 把一个 zenity 的替身放在 + Shell 的 `PATH` 最前面。替身记录下调用它的参数,并用示例中野外指南的一个副本作答,副本名为 + `Lease 2026.pdf`。之后的一切都是发布的代码。 + - 助手程序被调用为 `zenity --file-selection --title=Import a file into this app`。 + - 16,477 个字节被逐字节复制到 `accounts/device/library/Imported PDF.pdf`。 + - `library.json` 为它记下标题“Lease 2026”、7 页和封面。 + - 它在一个标签中打开,标签为“Lease 2026.pdf”,位于“1 / 7” + ([import.json](import/import.json))。 + - 此前没有任何测试运行过真实的导入:在 Mac 上,隐藏窗口会让对话框被拒绝。真实助手程序 + 自己的窗口没有运行。 + - 导入时不传任何文件过滤条件(文件服务只为照片添加过滤条件),所以助手程序会列出所有文件。 + 这在每个平台上都一样。 +- **字体与文字。** 这台主机只有 DejaVu(8 个字体文件),与 macOS 相比没有任何不同。Shell 的 + 界面使用 Makepad 自带的字体绘制。引擎对示例中无衬线和衬线文字的渲染与 Mac 上一致: + compare/ 中的文字、换行和布局都相同。没有测试中日韩文字。 +- **软件 GL。** Shell 启动时,llvmpipe 编译着色器,使 UI 线程停顿:第一个事件耗时 3.1 到 + 3.6 秒,draw-list 停顿 0.5 到 1.2 秒(显示第一页时还有一次)。除此之外,五次运行中记录到的 + 停顿都不超过 409 ms,也没有任何脚本处理函数超出 64 ms。 +- **窗口。** 在 weston 上,Shell 的窗口为 1400 x 900 点,dpi 为 1。 + - 启动时日志显示“wm: pdftools asks for a 1536x1024 window; it opens at 1296x698 (the dock + covers 78 points of the desk)”。应用区域为 1292 x 662,即宽布局,与 macOS 上的隐藏 Shell + 相同。 + - 桌面绘制完成后从菜单打开时,窗口为 1296 x 709(应用区域 1292 x 673),底边位于程序坞之上。 + macOS 上的隐藏 Shell(1400 x 897 的窗口)得到的是 1296 x 703。 + - 有了 #485 之后,程序坞只在 OctoSense 全屏时隐藏(在 Wayland 上,最大化也算)。weston 上的 + Shell 窗口两者都不是,所以程序坞保持显示,PDF Tools 在 main 3632d376 和 3a563057(后者还包含 + #487)上都以相同的尺寸打开。 +- **评论日期。** 在 UTC 时区,本地时间就是 UTC,所以单靠第一次运行无法区分两者。在 UTC+14 + 下,日期正确地跨过了午夜:高亮显示“Today 04:32”,而不是 14:32,也不是“Yesterday”。 + - pdf 服务从 `localtime_r` 取得时差。 + - Shell 用同一个调用设置脚本中 `std.local_time` 的时差,所以两者一致。 +- **ext4。** `pdf.save` 的暂存和重命名、渲染缓存的写入以及封面,都在 64 ms 预算下运行,没有超出 + 预算,平均负载为 10 到 35。 + - 在 Mac 上,64 ms 的运行曾在 262 ms 和 555 ms 的重命名之后超出预算,当时平均负载为 54 到 + 74,所以这不是同等条件下的比较。 + - 重启后,五个当前封面保持了纳秒级的修改时间不变,只为新导入的文件绘制了封面。 + - 移除操作删除了该 PDF 的封面。 + - 缓存按修改时间淘汰,而不是按目录顺序(ext4 和 APFS 的目录顺序不同)。 + +没有发现只在 Linux 上出现的问题,所以这个分支不包含修复。 + +## 未验证 + +- Windows:这里没有相应的机器。 +- 真实助手程序自己的对话框(zenity、qarma、matedialog、kdialog)。主机上一个都没有安装,这次 + 也没有安装任何软件;作答的是替身。makepad 没有 xdg-desktop-portal 路径。 +- X11:weston 的 Xwayland 在这台主机上无法启动(没有 `/tmp/.X11-unix`)。另外还有 GNOME 或 + KDE 这样的桌面合成器、GPU 驱动,以及 Linux 上大于 1 的缩放比例。 +- ui.py 在 card-host 中的运行(`missing`、`fixture`)在 Linux 上的情况,以及 release 构建。 +- 在没有相应字体的主机上显示中日韩文字及其他非拉丁文字。 diff --git a/apps/pdftools/tests/evidence/linux-20261011/compare/01-home.png b/apps/pdftools/tests/evidence/linux-20261011/compare/01-home.png new file mode 100644 index 000000000..0fa7d4ce2 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/compare/01-home.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/compare/02-reading.png b/apps/pdftools/tests/evidence/linux-20261011/compare/02-reading.png new file mode 100644 index 000000000..48771a0b3 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/compare/02-reading.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/compare/03-find.png b/apps/pdftools/tests/evidence/linux-20261011/compare/03-find.png new file mode 100644 index 000000000..215fec2ac Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/compare/03-find.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/compare/08b-edited.png b/apps/pdftools/tests/evidence/linux-20261011/compare/08b-edited.png new file mode 100644 index 000000000..e896c5e51 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/compare/08b-edited.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/dark/d01-home.png b/apps/pdftools/tests/evidence/linux-20261011/dark/d01-home.png new file mode 100644 index 000000000..9d5c911ad Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/dark/d01-home.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/dark/d02c-read-mode.png b/apps/pdftools/tests/evidence/linux-20261011/dark/d02c-read-mode.png new file mode 100644 index 000000000..fdce95b45 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/dark/d02c-read-mode.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/dark/d02e-read-mode-left.png b/apps/pdftools/tests/evidence/linux-20261011/dark/d02e-read-mode-left.png new file mode 100644 index 000000000..03a59a1f3 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/dark/d02e-read-mode-left.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/dark/d03-find.png b/apps/pdftools/tests/evidence/linux-20261011/dark/d03-find.png new file mode 100644 index 000000000..1eba90193 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/dark/d03-find.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/dark/d04-comment.png b/apps/pdftools/tests/evidence/linux-20261011/dark/d04-comment.png new file mode 100644 index 000000000..47984b805 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/dark/d04-comment.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/dark/d06-combine.png b/apps/pdftools/tests/evidence/linux-20261011/dark/d06-combine.png new file mode 100644 index 000000000..01b124ffa Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/dark/d06-combine.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/dark/d08-edit.png b/apps/pdftools/tests/evidence/linux-20261011/dark/d08-edit.png new file mode 100644 index 000000000..d6556e825 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/dark/d08-edit.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/dark/d08b-edited.png b/apps/pdftools/tests/evidence/linux-20261011/dark/d08b-edited.png new file mode 100644 index 000000000..5f04bd7bc Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/dark/d08b-edited.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/empty/de01-empty.png b/apps/pdftools/tests/evidence/linux-20261011/empty/de01-empty.png new file mode 100644 index 000000000..b089b00ab Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/empty/de01-empty.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/empty/de02-empty-open.png b/apps/pdftools/tests/evidence/linux-20261011/empty/de02-empty-open.png new file mode 100644 index 000000000..f039814f6 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/empty/de02-empty-open.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/empty/e01-empty.png b/apps/pdftools/tests/evidence/linux-20261011/empty/e01-empty.png new file mode 100644 index 000000000..139127d01 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/empty/e01-empty.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/empty/e02-empty-open.png b/apps/pdftools/tests/evidence/linux-20261011/empty/e02-empty-open.png new file mode 100644 index 000000000..080e41b74 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/empty/e02-empty-open.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/full/f01-home-full.png b/apps/pdftools/tests/evidence/linux-20261011/full/f01-home-full.png new file mode 100644 index 000000000..1024ac675 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/full/f01-home-full.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/full/f02-remove-asks.png b/apps/pdftools/tests/evidence/linux-20261011/full/f02-remove-asks.png new file mode 100644 index 000000000..4e6304cf4 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/full/f02-remove-asks.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/full/f03-removed.png b/apps/pdftools/tests/evidence/linux-20261011/full/f03-removed.png new file mode 100644 index 000000000..75b92efbc Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/full/f03-removed.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/import/check.py b/apps/pdftools/tests/evidence/linux-20261011/import/check.py new file mode 100644 index 000000000..8c6de9f1e --- /dev/null +++ b/apps/pdftools/tests/evidence/linux-20261011/import/check.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""One-off check, kept as run (Linux only): "Open a PDF from this device" +through makepad's Linux file dialog, which runs the desktop's own helper +(zenity, qarma, matedialog or kdialog). The build host has none, so a +stand-in for zenity goes first on the shell's PATH. It records how it was +asked and answers with one real PDF. Everything after it is shipped code: +the helper lookup, its command line, the parsing of its reply, the byte +loader, the files service's import into the app's storage, and PDF Tools' +title for the new file. ui.py's own Linux launcher (a private headless +weston) and driver run the shell. + + python3 apps/pdftools/tests/evidence/linux-20261011/import/check.py \ + --shell target/debug/octosense --output +""" +import argparse +import json +import os +from pathlib import Path +import shutil +import sys + +ROOT = Path(__file__).resolve().parents[6] +sys.path.insert(0, str(ROOT / "apps/pdftools/tests")) +import ui # noqa: E402 + +parser = argparse.ArgumentParser() +parser.add_argument("--shell", type=Path, required=True) +parser.add_argument("--output", type=Path, required=True) +parser.add_argument("--port", type=int, default=8915) +args = parser.parse_args() +assert ui.LINUX, "the stand-in replaces a Linux dialog helper" +out = args.output.resolve() +out.mkdir(parents=True, exist_ok=True) +home = out / "home" +jail = home / "apps" / ui.APP +ui.fixture(home / "apps") +chosen = out / "chosen" / "Lease 2026.pdf" +chosen.parent.mkdir(exist_ok=True) +shutil.copy(jail / ui.LIBRARY / "Field guide.pdf", chosen) +stub = out / "bin" / "zenity" +stub.parent.mkdir(exist_ok=True) +stub.write_text("#!/bin/sh\n" + f"printf '%s\\n' \"$@\" > '{out}/zenity-args.txt'\n" + f"printf '%s\\n' '{chosen}'\n") +stub.chmod(0o755) +os.environ["PATH"] = f"{stub.parent}:{os.environ['PATH']}" + +app = ui.Shell(args.shell.resolve(), home, args.port, out / "shell.log") +seen = {} +try: + app.find("PDF Tools", "Label", timeout=90) + app.see("Quarterly report.pdf") + app.images(3) + app.scroll(-3000) # back to the top: the button row + app.find("Up to 64 MB", "Label") # files.status: import_supported, max_import_bytes + app.grab(out, "i01-home-import-offered") + app.tap("Open a PDF from this device", "Button") + # The new tab: the title is the chosen file's name less ".pdf", and a + # title shows as title + ".pdf" (main.splash shown_name). + app.find("Lease 2026.pdf", "Label", timeout=60) + app.find(" / ", "Label", exact=False, timeout=60) + app.images(1) + app.grab(out, "i02-imported-and-opened") + seen["page_label"] = next(w["t"] for w in app.widgets() if " / " in (w.get("t") or "")) + app.tap("Open", "Button") + app.see("Lease 2026.pdf") + app.scroll(-3000) + app.grab(out, "i03-home-with-the-import") +except BaseException: + ui.keep_failure(app, out, "import") + raise +finally: + app.quit() +ui.check_log(out / "shell.log") +index = json.loads((jail / "accounts/device/library.json").read_text())["docs"] +imported = jail / ui.LIBRARY / "Imported PDF.pdf" +seen.update({ + "zenity_args": (out / "zenity-args.txt").read_text().splitlines(), + "library": sorted(p.name for p in (jail / ui.LIBRARY).glob("*.pdf")), + "imported_file": {"name": imported.name, "bytes": imported.stat().st_size, + "same_bytes_as_chosen": imported.read_bytes() == chosen.read_bytes()}, + "library_json_entry": index.get("Imported PDF.pdf"), +}) +(out / "import.json").write_text(json.dumps(seen, indent=1)) +print(json.dumps(seen, indent=1)) diff --git a/apps/pdftools/tests/evidence/linux-20261011/import/i01-home-import-offered.png b/apps/pdftools/tests/evidence/linux-20261011/import/i01-home-import-offered.png new file mode 100644 index 000000000..2830c2735 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/import/i01-home-import-offered.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/import/i02-imported-and-opened.png b/apps/pdftools/tests/evidence/linux-20261011/import/i02-imported-and-opened.png new file mode 100644 index 000000000..2697e46e7 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/import/i02-imported-and-opened.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/import/i03-home-with-the-import.png b/apps/pdftools/tests/evidence/linux-20261011/import/i03-home-with-the-import.png new file mode 100644 index 000000000..86032bd75 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/import/i03-home-with-the-import.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/import/import.json b/apps/pdftools/tests/evidence/linux-20261011/import/import.json new file mode 100644 index 000000000..f78cf434d --- /dev/null +++ b/apps/pdftools/tests/evidence/linux-20261011/import/import.json @@ -0,0 +1,27 @@ +{ + "page_label": "1 / 7", + "zenity_args": [ + "--file-selection", + "--title=Import a file into this app" + ], + "library": [ + "Apartment lease.pdf", + "Board minutes.pdf", + "Damaged scan.pdf", + "Field guide.pdf", + "Imported PDF.pdf", + "Quarterly report.pdf" + ], + "imported_file": { + "name": "Imported PDF.pdf", + "bytes": 16477, + "same_bytes_as_chosen": true + }, + "library_json_entry": { + "pages": 7, + "bytes": 16477, + "title": "Lease 2026", + "opened": 1791729898.686883, + "cover": ".cache/covers/9bbd32ca74ee02a3-16477-1791729898675883482@48.png" + } +} \ No newline at end of file diff --git a/apps/pdftools/tests/evidence/linux-20261011/light/01-home.png b/apps/pdftools/tests/evidence/linux-20261011/light/01-home.png new file mode 100644 index 000000000..4feb293ef Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/light/01-home.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/light/01b-open-from-device.png b/apps/pdftools/tests/evidence/linux-20261011/light/01b-open-from-device.png new file mode 100644 index 000000000..2bc45455f Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/light/01b-open-from-device.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/light/02-reading.png b/apps/pdftools/tests/evidence/linux-20261011/light/02-reading.png new file mode 100644 index 000000000..3ba9e7ece Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/light/02-reading.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/light/02c-read-mode.png b/apps/pdftools/tests/evidence/linux-20261011/light/02c-read-mode.png new file mode 100644 index 000000000..27fbfa1c3 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/light/02c-read-mode.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/light/02d-read-mode-zoomed.png b/apps/pdftools/tests/evidence/linux-20261011/light/02d-read-mode-zoomed.png new file mode 100644 index 000000000..0e5b74dc8 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/light/02d-read-mode-zoomed.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/light/02e-read-mode-left.png b/apps/pdftools/tests/evidence/linux-20261011/light/02e-read-mode-left.png new file mode 100644 index 000000000..4bc146183 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/light/02e-read-mode-left.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/light/03-find.png b/apps/pdftools/tests/evidence/linux-20261011/light/03-find.png new file mode 100644 index 000000000..61da1b828 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/light/03-find.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/light/03b-find-done.png b/apps/pdftools/tests/evidence/linux-20261011/light/03b-find-done.png new file mode 100644 index 000000000..9f56f995d Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/light/03b-find-done.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/light/04-comment.png b/apps/pdftools/tests/evidence/linux-20261011/light/04-comment.png new file mode 100644 index 000000000..65b48d03b Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/light/04-comment.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/light/04b-replied.png b/apps/pdftools/tests/evidence/linux-20261011/light/04b-replied.png new file mode 100644 index 000000000..88b9369d8 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/light/04b-replied.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/light/05-pages-rotated.png b/apps/pdftools/tests/evidence/linux-20261011/light/05-pages-rotated.png new file mode 100644 index 000000000..813cc3f51 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/light/05-pages-rotated.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/light/06-combine.png b/apps/pdftools/tests/evidence/linux-20261011/light/06-combine.png new file mode 100644 index 000000000..033c628ac Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/light/06-combine.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/light/06b-combined.png b/apps/pdftools/tests/evidence/linux-20261011/light/06b-combined.png new file mode 100644 index 000000000..a0ea067d6 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/light/06b-combined.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/light/07-fill-sign.png b/apps/pdftools/tests/evidence/linux-20261011/light/07-fill-sign.png new file mode 100644 index 000000000..cc7815b6f Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/light/07-fill-sign.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/light/08a-colours.png b/apps/pdftools/tests/evidence/linux-20261011/light/08a-colours.png new file mode 100644 index 000000000..511826d4b Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/light/08a-colours.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/light/08b-edited.png b/apps/pdftools/tests/evidence/linux-20261011/light/08b-edited.png new file mode 100644 index 000000000..c17dd02d4 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/light/08b-edited.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/light/09-saved.png b/apps/pdftools/tests/evidence/linux-20261011/light/09-saved.png new file mode 100644 index 000000000..6e9e0219f Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/light/09-saved.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/light/10-damaged.png b/apps/pdftools/tests/evidence/linux-20261011/light/10-damaged.png new file mode 100644 index 000000000..a15fe9ec1 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/light/10-damaged.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/receipt.json b/apps/pdftools/tests/evidence/linux-20261011/receipt.json new file mode 100644 index 000000000..d2f7d333a --- /dev/null +++ b/apps/pdftools/tests/evidence/linux-20261011/receipt.json @@ -0,0 +1,472 @@ +{ + "result": "pass", + "date": "2026-10-11", + "platform": "Linux: the Linux build host (Ubuntu 26.04 LTS, Linux 7.0, x86_64, 72 CPUs, 138 GiB), under a headless weston 14.0.2 (pixman renderer) of each run's own, Makepad's Wayland backend, Mesa 26.0.8 llvmpipe (software OpenGL ES 3.2, LLVM 21.1.8); no GPU, no desktop session, and no Xwayland (weston's cannot start there: no /tmp/.X11-unix)", + "scope": "PDF Tools v2 with its follow-ups (one search in the top bar, Read mode, pdf.cover, the storage line, Edit's Alignment and Colour, comment dates in local time) and the window hint, end to end on Linux for the first time: ui.py's shell, restart, full and empty runs in an OctoSense desktop shell built from main with the real pdf engine, at the runtime's 64 ms script budget, five times (the host's own zone, UTC; TZ=Pacific/Kiritimati, UTC+14; UTC with the final ui.py; and UTC after rebases onto main 3632d376 and 3a563057); plus 'Open a PDF from this device' through makepad's Linux file dialog with a stand-in helper, and the window opened from the shell's menu.", + "live_engine": true, + "tested": { + "main": "5cab54ad7a6b4b4b175957d0116982bd7b17a342 (#479 merged: PDF Tools v2's follow-ups; the window hint since #478)", + "since": "main moved during the runs, twice. First to 3632d3767c19191423a0f3b54265e0fd2fb3353c, adding #485 (the dock hides while OctoSense is full screen; Wayland counts a maximized window) and #494 (pdfcraft 615ff308173a: safe comment dates and page labels): the branch was rebased, rebuilt and run again. Then to 3a56305771594ae4c1430b5b61be40e50b76e9bf, adding #487 (maximize and snaps end above a floating dock; a native window's own size): the branch was rebased onto it, and at its head 44ef956856b076df9984247fea183cbace8ef1f3 (target/debug/octosense sha256 9a554d89e459a7033b2fbb1756b25586ad816a6f40273bbe495d441ee7c862f7, pdftools_fixture d213bf46e52b62842a7b00c4404dfa43701f91f0c8d22eaf8aa222892ee4f63b) the journeys, the import check and the window probe passed again. The frames are from the first run, on 5cab54ad", + "changed": "apps/pdftools/tests/ui.py only, this branch's Linux launch path (sha256 4a1c85f1dddacff567cd83b2a55ac1b75ff7011f1f5d6411486e5c7c57480bd6); the shell, the bundle and the pdf service are main's. The first two runs, the import check and the window probe used ui.py 7caadf412901b82c351fe55883bff26a174c8a042c320eaffd4fcd5fe7958862, which lacks only the guard that stops the shell and its weston when the bridge never answers; the third run used the final file", + "trees": { + "crates/shell": "bcdea6075b6de48c8ac1253e1348797284165252", + "apps/pdftools/bundle": "3052366c75bd829dfdb87f72c48301aca5ccecd6", + "apps/pdf/host-service": "52ea110128e23578b36511fc0210d2c47a60748a", + "desktop": "c070dac010c68d746990796d9feec0112bcd4ba7", + "Cargo.lock": "32773c2743a6bf09808f68e70ba65a8daad838eb" + } + }, + "checkout": "a scratch git worktree of main on the build host, prepared by tools/setup.py against the host's source hubs (OCTOSENSE_SOURCES_HUB): .sources/makepad d653bc449361 with runtime-patches.lock.json's stacked patches, .sources/octoscript-makepad 6cf2d90aa33f, .sources/octoscript 2e37d9e657a2; App Hub 386359153ed6377b5f047bd9ed2767a6a2ac2bc7 (the root Cargo.toml pin); rustc 1.99.0 (b940084d7 2026-09-28); Python 3.14.4", + "shell": { + "build": "cargo build --locked -p octosense --no-default-features --features app-hub,craft-engines (debug, 4 min 52 s with -j 36)", + "binary": "target/debug/octosense", + "binary_sha256": "750d6c69c24ca5b8edafb732ca7a77b4b3d0d8976c91746a7ffd7a1e37889d84", + "fixture": "cargo build --locked -p octosense-pdf-service --example pdftools_fixture (ui.py runs it with cargo run); target/debug/examples/pdftools_fixture sha256 5805c28bab266d8b723c7e221c8e3c86f1cc601764a31ea139b3247b72a013fb", + "compositor": "per session: weston --backend=headless --renderer=pixman --idle-time=0 --socket=wayland-pdftools --no-config --log=/weston.log, with XDG_RUNTIME_DIR a fresh 0700 folder; stopped (with its clients) after the shell exits", + "launch": "the shell in a session of its own, with ui.py's environment plus OCTOSENSE_HOME= OCTOS_APP_CORE_DIR=/octos-home/.octos MAKEPAD_HOME=/makepad MAKEPAD_HIDE_WINDOWS=1 (no effect on Linux: the window is mapped on the run's weston) MAKEPAD_REMOTE=127.0.0.1:8915 MAKEPAD_WM_TEST_APP=pdftools XDG_RUNTIME_DIR= WAYLAND_DISPLAY=wayland-pdftools XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_CACHE_HOME, XDG_STATE_HOME=/xdg/*; ended with /quit", + "window": "the shell's window is 1400 x 900 points at dpi 1 (weston's headless output does not constrain a floating window); PDF Tools at its own window size: an app area of 1292 x 662 points, the wide layout", + "frames": "the app's area cut from each /g grab taken at --grab-scale 1 (one pixel per point at dpi 1); window/opened-from-the-menu.png is the whole window", + "script_budget": "the runtime's 64 ms: no MAKEPAD_SPLASH_BUDGET_MS, no --budget-ms", + "homes": "on ext4 (--work on the host's disk). The host's /tmp is tmpfs, so ui.py's default --work (the system's temporary folder) would have put every home, and every rename, on tmpfs" + }, + "source_sha256": { + "apps/pdftools/bundle/main.splash": "39801504143dcb670252d9e3167deb0283c2f361962e11627d115c61a083a3e9", + "apps/pdftools/bundle/manifest.json": "3c062555c5521ec7de41a0c2c86b1bac75be0d005cd7727f9d32c55535000236", + "apps/pdftools/tests/ui.py": "4a1c85f1dddacff567cd83b2a55ac1b75ff7011f1f5d6411486e5c7c57480bd6", + "apps/pdftools/tests/evidence/linux-20261011/import/check.py": "00efba6fea2d97cd5631e18a1cf3f890ed18e9220efcac5c34c2c4408b8b214b", + "apps/pdf/host-service/examples/pdftools_fixture.rs": "f44ad5a202da98b4a07468e9dccdff59f7e2b333edec6e879245a3b7491307fc", + "apps/pdf/host-service/src/dates.rs": "0590244f3ca006adb59107ca201a6be424e3a85d122495bad55b2e581626e714", + "apps/pdf/host-service/src/review.rs": "9a75b9548958a95c223b514b91541aff7348e886f7d170a79b1dfbe12a036414", + "apps/pdf/host-service/src/cache.rs": "7f4d6d2a8de28ffb1a92b5c582c1476149cd7d711787e2afcb6067b183dc6dd2", + "apps/pdf/host-service/src/covers.rs": "a391029db168f272dfbd6e670c09a595081cad834febd3164bbe8358fe1be300", + "apps/pdf/host-service/src/change.rs": "226b188c08425e173de2583ad80c88140202dedd757d8072da15564e0d48f563", + "crates/shell/src/files_service/mod.rs": "74df96648bdca7a0dbe64116addcab48643e6295a44aaafdb443c5992889eb9f", + "crates/shell/src/shell/bar.rs": "d8885338365c795ca62c5fe6a7885a336e8112536c689ccf933d0df776451357", + ".sources/makepad/platform/src/os/linux/file_dialog.rs": "de288782465206028fb3e516458d8fe19897509ae0b60561c594e01e2e25bd94" + }, + "commands": [ + "(on the host) git -C /repo.git worktree add --detach 5cab54ad7a6b4b4b175957d0116982bd7b17a342", + "(in ) OCTOSENSE_SOURCES_HUB=/cache/hub python3 tools/setup.py", + "cargo build --locked -j 36 -p octosense --no-default-features --features app-hub,craft-engines", + "cargo build --locked -j 36 -p octosense-pdf-service --example pdftools_fixture", + "after each rebase (onto 3632d376, then 3a563057): the scratch checkout moved to the branch head (829ce84f, then 44ef9568), tools/setup.py again, both builds again (under a minute each time), then the third command below once more (output /ui-main), import/check.py and the window probe", + "python3 apps/pdftools/tests/ui.py --shell target/debug/octosense --lock /octosense-shell.lock --work /homes --grab-scale 1 --output /ui-64ms", + "TZ=Pacific/Kiritimati python3 apps/pdftools/tests/ui.py --shell target/debug/octosense --lock /octosense-shell.lock --work /homes --grab-scale 1 --output /ui-tz", + "(the final ui.py) python3 apps/pdftools/tests/ui.py --shell target/debug/octosense --lock /octosense-shell.lock --work /homes --grab-scale 1 --output /ui-final", + "python3 apps/pdftools/tests/evidence/linux-20261011/import/check.py --shell target/debug/octosense --output /import-check", + "the window from the menu: ui.py's Shell with MAKEPAD_WM_TEST_APP left out of its environment, then, once the desk had drawn, its menu() ('pdf tools', Return), /s and one /g grab (a scratch probe, not kept)", + "cargo test --locked -j 36 -p octosense-pdf-service (56 passed and 1 ignored, then 1 passed and 1 ignored, then 1 passed); cargo test --locked -j 36 -p octosense-shell --lib pdftools_model (18 passed)", + "frames: each grab cut to its sidecar's area (PIL); compare/: the Linux frame beside the macOS frame of the same step, halved and reduced to 256 colours" + ], + "runs": [ + { + "name": "ui.py, the host's own zone (UTC)", + "runs": [ + "shell (light, then dark)", + "restart", + "full", + "empty" + ], + "when": "11 Oct 2026, 14:25:02 to 14:28:57 UTC", + "load": "one-minute load average 34.8 at the start, 18.0 at the end (other sessions' CI on the host)", + "result": "all journeys passed on the first attempt; no script errors and no 'script time budget exceeded' in any log; each shell exited after /quit", + "frames": "light/, dark/, restart/, full/, empty/, compare/" + }, + { + "name": "ui.py with TZ=Pacific/Kiritimati (UTC+14)", + "runs": [ + "shell (light, then dark)", + "restart", + "full", + "empty" + ], + "when": "11 Oct 2026, 14:31:50 to 14:35:49 UTC, which was 04:31 to 04:35 on 12 Oct there", + "load": "one-minute load average 10.7 at the start, 22.3 at the end", + "result": "all journeys passed; no script errors and no budget overruns", + "frames": "tz/" + }, + { + "name": "ui.py again, the final file, in UTC", + "runs": [ + "shell (light, then dark)", + "restart", + "full", + "empty" + ], + "when": "11 Oct 2026, 14:54:09 to 14:58:06 UTC", + "load": "one-minute load average 28.9 at the start, 22.0 at the end, with another session's local CI on the host", + "result": "all journeys passed; no script errors and no budget overruns; comments dated 'Today 14:54' and 'Today 14:56'" + }, + { + "name": "ui.py after the rebase onto main 3632d376 (#485, #494), the final file, in UTC", + "runs": [ + "shell (light, then dark)", + "restart", + "full", + "empty" + ], + "when": "11 Oct 2026, 15:02:28 to 15:06:19 UTC", + "load": "one-minute load average 14.5 at the start, 10.4 at the end", + "result": "all journeys passed; no script errors and no budget overruns; comments dated 'Today 15:03' and 'Today 15:04'; the same storage line (191,793 bytes) and window ('it opens at 1296x698'); then import/check.py and the window probe passed again (1296 x 709 from the menu)" + }, + { + "name": "ui.py after the rebase onto main 3a563057 (#487 too), the final file, in UTC", + "runs": [ + "shell (light, then dark)", + "restart", + "full", + "empty" + ], + "when": "11 Oct 2026, 15:10:20 to 15:14:10 UTC", + "load": "one-minute load average 1.8 at the start, 6.8 at the end", + "result": "all journeys passed; no script errors and no budget overruns; comments dated 'Today 15:11' and 'Today 15:12'; the same storage line (191,793 bytes) and window ('it opens at 1296x698'); then import/check.py passed again ('Lease 2026', '1 / 7', the same bytes) and the window probe gave 1296 x 709 from the menu again" + }, + { + "name": "Open a PDF from this device, with a stand-in helper", + "how": "import/check.py: a stand-in zenity first on the shell's PATH records its arguments and prints the path of a copy of the samples' Field guide.pdf named 'Lease 2026.pdf'; the check taps 'Open a PDF from this device' and checks the tab, the copied bytes and library.json", + "when": "11 Oct 2026, 14:44:47 UTC, load 14.8", + "result": "pass; no script errors", + "observed": { + "page_label": "1 / 7", + "zenity_args": [ + "--file-selection", + "--title=Import a file into this app" + ], + "library": [ + "Apartment lease.pdf", + "Board minutes.pdf", + "Damaged scan.pdf", + "Field guide.pdf", + "Imported PDF.pdf", + "Quarterly report.pdf" + ], + "imported_file": { + "name": "Imported PDF.pdf", + "bytes": 16477, + "same_bytes_as_chosen": true + }, + "library_json_entry": { + "pages": 7, + "bytes": 16477, + "title": "Lease 2026", + "opened": 1791729898.686883, + "cover": ".cache/covers/9bbd32ca74ee02a3-16477-1791729898675883482@48.png" + } + }, + "frames": "import/" + }, + { + "name": "the window opened from the shell's menu", + "how": "no MAKEPAD_WM_TEST_APP; once the desk had drawn: the menu (Logo+Space), 'pdf tools', Return", + "when": "11 Oct 2026, 14:45 UTC", + "observed": { + "log": "wm: pdftools asks for a 1536x1024 window; it opens at 1296x709 (the dock covers 78 points of the desk)", + "shell_window": [ + 1400, + 900 + ], + "dpi": 1, + "app_area": [ + 54, + 105, + 1292, + 673 + ], + "status_line": [ + 74, + 747, + 147, + 18 + ] + }, + "frame": "window/opened-from-the-menu.png" + } + ], + "linux_findings": [ + "File dialog: makepad's Linux file dialog is neither xdg-desktop-portal nor GTK. It runs the desktop's own helper, the first of zenity, qarma, matedialog and kdialog on PATH (kdialog first under KDE), and reads the chosen path from its output (.sources/makepad/platform/src/os/linux/file_dialog.rs). The build host has none of them, so file_dialogs::native_file_bytes_supported() is false: files.status answers import_supported false, and the shell does not offer files.import at all. PDF Tools then shows 'Not available here' beside 'Open a PDF from this device', and the button shows the banner 'This OctoSense can't open files from the device for PDF Tools.' Nothing hangs, and nothing is logged as an error. On Linux, ui.py now checks that (open_on_linux).", + "On Linux the shell's window is mapped on the run's own weston (MAKEPAD_HIDE_WINDOWS does nothing there). The files service counts a window outside macOS as focused until it reports losing focus (crates/shell/src/files_service/mod.rs, Foreground). So with a helper on PATH the dialog really opens. That is why ui.py leaves the step out when a helper is on PATH, and why the stand-in helper's check could run a real import.", + "A real import, which no test had run before (on the Mac the hidden window has the dialog refused): the helper was asked 'zenity --file-selection --title=Import a file into this app'. The chosen 'Lease 2026.pdf' (16,477 bytes) was copied byte for byte into accounts/device/library/Imported PDF.pdf. library.json titled it 'Lease 2026', 7 pages, with its cover. It opened in a tab, 'Lease 2026.pdf', on '1 / 7'. The import passes no file filter (the files service adds one only for photos), so the dialog would list every file, on every platform.", + "Fonts and text: the host has only DejaVu (8 font files in fontconfig), and nothing differs from macOS. The shell's interface draws with Makepad's bundled fonts, and the engine's page renders (the samples' sans-serif and serif text) match the Mac's. compare/ shows Home, a page, find and an edited paragraph beside the macOS frames of the same steps: the same text, wrapping and layout, and the same storage numbers. CJK text was not exercised.", + "Software GL: each shell's start stalls its UI thread: a 3.1 to 3.6 s first event, then draw-list stalls of 0.5 to 1.2 s whose sampled frames are LLVM compiling (Mesa's llvmpipe builds its shaders; one more such compile, of 0.55 to 0.65 s, comes with the first page). Apart from those, no UI-thread stall logged in the five runs was longer than 409 ms, and no script handler overran 64 ms in any of them.", + "Window size: on weston the shell's window is 1400 x 900 points at dpi 1. The log says 'wm: pdftools asks for a 1536x1024 window; it opens at 1296x698 (the dock covers 78 points of the desk)', an app area of 1292 x 662, the wide layout: the same as the macOS hidden shell (1400 x 898). Opened from the menu once the desk had drawn, it is 1296 x 709 (app area 1292 x 673), ending above the dock; the macOS hidden shell's 1400 x 897 window gave 1296 x 703. Both sizes were the same on main 3a563057, which has #485 (the dock hides only while OctoSense is full screen or, on Wayland, maximized; the shell's window on weston is neither) and #487.", + "Read mode: in both runs, light and dark, it was entered from the floating bar on 2 / 4. Tabs, mode bar, rail, panels and status line were gone. A page turned to 3 / 4 and the zoom went from 100% to 125%. It was left by Exit read mode (light) or the corner control (dark), on 3 / 4 at 125%.", + "Comment dates: in the host's own zone, UTC, the highlight read 'Today 14:25' at 14:25 UTC. Local time is UTC there, so that alone cannot tell the two apart. With TZ=Pacific/Kiritimati (UTC+14, already 12 Oct) it read 'Today 04:32' at 14:32 UTC on 11 Oct. The pdf service's conversion and the app's std.local_time both take the offset from localtime_r's tm_gmtoff (crates/shell/src/shell/bar.rs sets the script's offset), and they agree across the date change.", + "ext4: every home was on ext4. pdf.save's staging and rename, the render cache's writes and the covers all passed at 64 ms with no overrun, at load averages of 10 to 35, in five runs. On the Mac the 64 ms runs overran after 262 and 555 ms renames, at load averages of 54 to 74; the loads differ, so this is not a like-for-like timing comparison. After the restart the five current covers were kept with the same nanosecond modification times, and only the new import's cover was drawn. Remove deleted the PDF's cover. The cache evicts by modification time (cache.rs), not by directory order, which differs between ext4 and APFS.", + "The same numbers as on the Mac: the storage line read '187 KB of 64 MB used' for 191,793 bytes, and the samples' covers had the same name hashes and sizes. Only the combined PDF differs, by one byte (10,535 here, 10,534 there)." + ], + "observed": { + "utc_run": { + "storage_line": { + "shown": [ + 191488.0, + 64 + ], + "jail_bytes": 191793, + "library_pdf_bytes": 38356 + }, + "open_from_device": [ + { + "step": "01b-open-from-device", + "shown": [ + "Not available here", + "This OctoSense can't open files from the device for PDF Tools." + ] + }, + { + "step": "e02-empty-open", + "shown": [ + "Not available here", + "This OctoSense can't open files from the device for PDF Tools." + ] + }, + { + "step": "de02-empty-open", + "shown": [ + "Not available here", + "This OctoSense can't open files from the device for PDF Tools." + ] + } + ], + "read_mode": [ + { + "pass": "light", + "entered_on": "2 / 4", + "left_on": "3 / 4", + "zoom": [ + 1.0, + 1.25 + ], + "left_by": "Exit read mode" + }, + { + "pass": "dark", + "entered_on": "2 / 4", + "left_on": "3 / 4", + "zoom": [ + 1.0, + 1.25 + ], + "left_by": "corner control" + } + ], + "comment_dates": [ + { + "pass": "light", + "utc_now": "2026-10-11T14:25Z", + "shown_one_of": [ + "Today 14:25" + ] + }, + { + "pass": "dark", + "utc_now": "2026-10-11T14:27Z", + "shown_one_of": [ + "Today 14:27" + ] + } + ], + "combine_button": [ + { + "pass": "light", + "area": [ + 54, + 76, + 1292, + 662 + ], + "button": [ + 937, + 613, + 140, + 46 + ] + }, + { + "pass": "dark", + "area": [ + 54, + 76, + 1292, + 662 + ], + "button": [ + 937, + 613, + 140, + 46 + ] + } + ], + "covers_after_shell_run": [ + "529d0969fe6aecff-16477-1791728703749376961@48.png", + "613ca0035b985603-10535-1791728845501895083@48.png", + "af4c8071f14d2a8a-5732-1791728703749355064@48.png", + "daec677dceec21c2-9551-1791728703748896645@48.png", + "e135281299f25e61-10535-1791728767562760297@48.png", + "f0b4f225a4635b83-83406-1791728862457895929@48.png" + ], + "removed_with_its_cover": { + "pdf": "Quarterly report combined.pdf", + "cover": ".cache/covers/e135281299f25e61-10535-1791728767562760297@48.png" + }, + "covers_after_restart": { + "kept_untouched": [ + "529d0969fe6aecff-16477-1791728703749376961@48.png", + "613ca0035b985603-10535-1791728845501895083@48.png", + "af4c8071f14d2a8a-5732-1791728703749355064@48.png", + "daec677dceec21c2-9551-1791728703748896645@48.png", + "f0b4f225a4635b83-83406-1791728862457895929@48.png" + ], + "drawn_now": [ + "9bbd32ca74ee02a3-5053-1791728868349894832@48.png" + ], + "gone_now": [ + "e135281299f25e61-10535-1791728767562760297@48.png" + ] + } + }, + "utc_plus_14_run": { + "comment_dates": [ + { + "pass": "light", + "utc_now": "2026-10-11T14:32Z", + "shown_one_of": [ + "Today 04:32" + ] + }, + { + "pass": "dark", + "utc_now": "2026-10-11T14:33Z", + "shown_one_of": [ + "Today 04:33" + ] + } + ], + "read_mode": [ + { + "pass": "light", + "entered_on": "2 / 4", + "left_on": "3 / 4", + "zoom": [ + 1.0, + 1.25 + ], + "left_by": "Exit read mode" + }, + { + "pass": "dark", + "entered_on": "2 / 4", + "left_on": "3 / 4", + "zoom": [ + 1.0, + 1.25 + ], + "left_by": "corner control" + } + ], + "storage_line": { + "shown": [ + 191488.0, + 64 + ], + "jail_bytes": 191793, + "library_pdf_bytes": 38356 + }, + "open_from_device": [ + { + "step": "01b-open-from-device", + "shown": [ + "Not available here", + "This OctoSense can't open files from the device for PDF Tools." + ] + }, + { + "step": "e02-empty-open", + "shown": [ + "Not available here", + "This OctoSense can't open files from the device for PDF Tools." + ] + }, + { + "step": "de02-empty-open", + "shown": [ + "Not available here", + "This OctoSense can't open files from the device for PDF Tools." + ] + } + ], + "covers_after_restart": { + "kept_untouched": [ + "529d0969fe6aecff-16477-1791729111372300750@48.png", + "613ca0035b985603-10535-1791729257928890540@48.png", + "af4c8071f14d2a8a-5732-1791729111372280555@48.png", + "daec677dceec21c2-9551-1791729111371892155@48.png", + "f0b4f225a4635b83-83406-1791729275737890344@48.png" + ], + "drawn_now": [ + "9bbd32ca74ee02a3-5053-1791729282199890273@48.png" + ], + "gone_now": [ + "e135281299f25e61-10535-1791729174500891459@48.png" + ] + } + } + }, + "not_verified": [ + "Windows: no machine here", + "a real helper's dialog (zenity, qarma, matedialog, kdialog): none is installed on the host, and installing software there is not allowed; a stand-in answered; xdg-desktop-portal has no path in makepad", + "X11 (weston's Xwayland cannot start on the host: no /tmp/.X11-unix), a desktop compositor (GNOME, KDE), a GPU driver, a scale above 1 on Linux", + "the card-host runs (missing, fixture) on Linux, and a release build", + "CJK and other non-Latin text on a host with no such fonts", + "phones" + ], + "grabs": [ + "compare/01-home.png", + "compare/02-reading.png", + "compare/03-find.png", + "compare/08b-edited.png", + "dark/d01-home.png", + "dark/d02c-read-mode.png", + "dark/d02e-read-mode-left.png", + "dark/d03-find.png", + "dark/d04-comment.png", + "dark/d06-combine.png", + "dark/d08-edit.png", + "dark/d08b-edited.png", + "empty/de01-empty.png", + "empty/de02-empty-open.png", + "empty/e01-empty.png", + "empty/e02-empty-open.png", + "full/f01-home-full.png", + "full/f02-remove-asks.png", + "full/f03-removed.png", + "import/i01-home-import-offered.png", + "import/i02-imported-and-opened.png", + "import/i03-home-with-the-import.png", + "light/01-home.png", + "light/01b-open-from-device.png", + "light/02-reading.png", + "light/02c-read-mode.png", + "light/02d-read-mode-zoomed.png", + "light/02e-read-mode-left.png", + "light/03-find.png", + "light/03b-find-done.png", + "light/04-comment.png", + "light/04b-replied.png", + "light/05-pages-rotated.png", + "light/06-combine.png", + "light/06b-combined.png", + "light/07-fill-sign.png", + "light/08a-colours.png", + "light/08b-edited.png", + "light/09-saved.png", + "light/10-damaged.png", + "restart/r01-home.png", + "restart/r02-imported.png", + "restart/r03-place-kept.png", + "restart/r04-removed.png", + "tz/04b-replied.png", + "tz/d04-comment.png", + "window/opened-from-the-menu.png" + ] +} diff --git a/apps/pdftools/tests/evidence/linux-20261011/restart/r01-home.png b/apps/pdftools/tests/evidence/linux-20261011/restart/r01-home.png new file mode 100644 index 000000000..ef452ccb8 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/restart/r01-home.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/restart/r02-imported.png b/apps/pdftools/tests/evidence/linux-20261011/restart/r02-imported.png new file mode 100644 index 000000000..efea01b75 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/restart/r02-imported.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/restart/r03-place-kept.png b/apps/pdftools/tests/evidence/linux-20261011/restart/r03-place-kept.png new file mode 100644 index 000000000..9a70b94d5 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/restart/r03-place-kept.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/restart/r04-removed.png b/apps/pdftools/tests/evidence/linux-20261011/restart/r04-removed.png new file mode 100644 index 000000000..b5c6ddbee Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/restart/r04-removed.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/tz/04b-replied.png b/apps/pdftools/tests/evidence/linux-20261011/tz/04b-replied.png new file mode 100644 index 000000000..aa1682030 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/tz/04b-replied.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/tz/d04-comment.png b/apps/pdftools/tests/evidence/linux-20261011/tz/d04-comment.png new file mode 100644 index 000000000..fbe8234b7 Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/tz/d04-comment.png differ diff --git a/apps/pdftools/tests/evidence/linux-20261011/window/opened-from-the-menu.png b/apps/pdftools/tests/evidence/linux-20261011/window/opened-from-the-menu.png new file mode 100644 index 000000000..21eb6596d Binary files /dev/null and b/apps/pdftools/tests/evidence/linux-20261011/window/opened-from-the-menu.png differ diff --git a/apps/pdftools/tests/ui.py b/apps/pdftools/tests/ui.py index b8edce60b..e05957b0e 100644 --- a/apps/pdftools/tests/ui.py +++ b/apps/pdftools/tests/ui.py @@ -5,10 +5,12 @@ (`cargo build --locked -p octosense --no-default-features --features app-hub,craft-engines`). For each run the driver makes a fresh OctoSense home, writes sample PDFs into PDF Tools' storage there (octosense-pdf-service's -`pdftools_fixture` example), starts the shell hidden from a Terminal.app tab -(never as a child of this process), opens the app with MAKEPAD_WM_TEST_APP and -walks it over Makepad's remote bridge, saving original /g grabs. Every engine -call is real. +`pdftools_fixture` example), starts the shell hidden, opens the app with +MAKEPAD_WM_TEST_APP and walks it over Makepad's remote bridge, saving original +/g grabs. Every engine call is real. On macOS the shell starts from a +Terminal.app tab (never as a child of this process). On Linux, where +MAKEPAD_HIDE_WINDOWS does nothing, it starts in a session of its own on a +private headless weston (the pixman renderer, never idle), which stops with it. shell every mode on the samples in the shell's own window, light, then dark through the shell's own style menu (the dark pass works on @@ -29,7 +31,8 @@ full storage with no room left: the engine's refusal, then a removal empty no PDFs: the empty library and "Open a PDF from this device" (a hidden window never has focus, so the files service refuses the - dialog and the app shows that refusal) + dialog and the app shows that refusal; on Linux with no dialog + helper there is no files.import, and the app says so) card-host runs serve no host services: @@ -41,10 +44,12 @@ design in compare/, and the damaged, protected and unsaved states Shell runs hold a lock folder, so one hidden shell runs on this machine at a -time, and refuse to start while another OctoSense runs. +time, and on macOS refuse to start while another OctoSense runs (on Linux each +run has a compositor of its own). python3 apps/pdftools/tests/ui.py --shell target/debug/octosense --lock --output target/pdftools-ui python3 apps/pdftools/tests/ui.py --card-host /target/release/card-host --output target/pdftools-ui + (Linux) python3 apps/pdftools/tests/ui.py --shell target/debug/octosense --lock --work --grab-scale 1 --output target/pdftools-ui """ import argparse import json @@ -53,6 +58,7 @@ import re import shlex import shutil +import signal import socket import subprocess import sys @@ -71,6 +77,11 @@ LIBRARY = "accounts/device/library" # The storage PDF Tools gets: App Hub's system ceiling (no storage.max_bytes). QUOTA = 64 * 1024 * 1024 +LINUX = sys.platform.startswith("linux") +# makepad's Linux file dialog runs the desktop's own helper, the first of +# these on PATH (platform/src/os/linux/file_dialog.rs). With none, the files +# service offers no files.import, and files.status says import_supported: false. +DIALOG_HELPERS = ("zenity", "qarma", "matedialog", "kdialog") def fixture(*args): @@ -297,11 +308,13 @@ def grab(self, out, name): class Shell(Driver): - """The desktop shell, started hidden from a Terminal.app tab.""" + """The desktop shell, started hidden: from a Terminal.app tab on macOS, on + a private headless weston on Linux.""" def __init__(self, binary, home, port, log): super().__init__(port) self.log = log + self.window = self.process = self.weston = None env = { "OCTOSENSE_HOME": home, # Its own kernel folder: a home of OctoSense's own would otherwise @@ -314,13 +327,10 @@ def __init__(self, binary, home, port, log): } if Driver.budget_ms: env["MAKEPAD_SPLASH_BUDGET_MS"] = str(Driver.budget_ms) - command = "cd {} && env {} {} > {} 2>&1; exit".format( - shlex.quote(str(ROOT)), " ".join(f"{k}={shlex.quote(str(v))}" for k, v in env.items()), - shlex.quote(str(binary)), shlex.quote(str(log))) - script = 'tell application "Terminal" to do script "{}"'.format(command.replace("\\", "\\\\").replace('"', '\\"')) - answer = subprocess.run(["osascript", "-e", script], capture_output=True, text=True, check=True).stdout - found = re.search(r"window id (\d+)", answer) - self.window = found[1] if found else None + if LINUX: + self.start_on_weston(binary, home, env, log) + else: + self.start_in_terminal(binary, env, log) self.pid = None deadline = time.monotonic() + 120 while time.monotonic() < deadline: @@ -329,11 +339,78 @@ def __init__(self, binary, home, port, log): if listening: self.pid = int(listening[2]) break + if self.process is not None and self.process.poll() is not None: + break time.sleep(0.5) + if not self.pid: + self.stop_compositor() assert self.pid, f"the shell did not start its remote bridge; see {log}" - self.ready() + try: + self.ready() + except BaseException: + self.stop_compositor() # a session of its own: nothing else would stop it + raise + + def start_in_terminal(self, binary, env, log): + command = "cd {} && env {} {} > {} 2>&1; exit".format( + shlex.quote(str(ROOT)), " ".join(f"{k}={shlex.quote(str(v))}" for k, v in env.items()), + shlex.quote(str(binary)), shlex.quote(str(log))) + script = 'tell application "Terminal" to do script "{}"'.format(command.replace("\\", "\\\\").replace('"', '\\"')) + answer = subprocess.run(["osascript", "-e", script], capture_output=True, text=True, check=True).stdout + found = re.search(r"window id (\d+)", answer) + self.window = found[1] if found else None + + def start_on_weston(self, binary, home, env, log): + """Linux: a headless weston of the run's own (pixman renders without a + GPU; an idle weston stops presenting frames, so it never idles), in a + private runtime folder, and the shell on it with Makepad's Wayland + backend, each in a session of its own. The XDG folders are the run + home's, so nothing lands in the account's own.""" + # A short folder: a socket's path must fit in 108 bytes. + self.runtime = Path(tempfile.mkdtemp(prefix="pdftools-wl-")) + socket_name = "wayland-pdftools" + weston_log = log.with_name("weston.log") + self.weston = subprocess.Popen( + ["weston", "--backend=headless", "--renderer=pixman", "--idle-time=0", f"--socket={socket_name}", + "--no-config", f"--log={weston_log}"], + env=dict(os.environ, XDG_RUNTIME_DIR=str(self.runtime)), stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) + deadline = time.monotonic() + 20 + while not (self.runtime / socket_name).exists(): + if self.weston.poll() is not None or time.monotonic() > deadline: + self.stop_compositor() + raise AssertionError(f"weston did not open its socket; see {weston_log}") + time.sleep(0.1) + env.update(XDG_RUNTIME_DIR=self.runtime, WAYLAND_DISPLAY=socket_name) + for kind in ("CONFIG", "DATA", "CACHE", "STATE"): + env[f"XDG_{kind}_HOME"] = home / "xdg" / kind.lower() + env[f"XDG_{kind}_HOME"].mkdir(parents=True, exist_ok=True) + with log.open("wb") as handle: + self.process = subprocess.Popen([str(binary)], cwd=ROOT, env=dict(os.environ, **{k: str(v) for k, v in env.items()}), + stdin=subprocess.DEVNULL, stdout=handle, stderr=subprocess.STDOUT, + start_new_session=True) + + def stop_compositor(self): + """Linux: the shell if it still runs, then its weston and the runtime folder.""" + for process in (self.process, self.weston): + if process is None or process.poll() is not None: + continue + for signal_number in (signal.SIGTERM, signal.SIGKILL): + try: + os.killpg(process.pid, signal_number) + except ProcessLookupError: + break + try: + process.wait(timeout=10) + break + except subprocess.TimeoutExpired: + pass + if self.weston is not None: + shutil.rmtree(self.runtime, ignore_errors=True) def alive(self): + if self.process is not None: + return self.process.poll() is None try: os.kill(self.pid, 0) return True @@ -373,6 +450,8 @@ def quit(self): if self.window: subprocess.run(["osascript", "-e", f'tell application "Terminal" to close window id {self.window}'], capture_output=True) + if self.weston is not None: + self.stop_compositor() assert exited, f"the shell (pid {self.pid}) did not exit after /quit" @@ -829,6 +908,32 @@ def read_mode(app, out, p, dark): wait_until(lambda: zoom(app) == before, "the zoom did not go back") +NO_IMPORT = "This OctoSense can't open files from the device for PDF Tools." + + +def open_on_linux(app, out, name): + """'Open a PDF from this device' on Linux, where no hidden window keeps + the dialog shut: True when the step was taken here. With no dialog helper + on PATH the shell offers no files.import, so Home says "Not available + here" beside the button and the button says why. With one, the run's + window has focus in its own compositor and the helper's dialog would + really open, which the bridge cannot answer: the step is left out, and + run.json says so.""" + if not LINUX: + return False + if any(shutil.which(helper) for helper in DIALOG_HELPERS): + print(" left out 'Open a PDF from this device': a file dialog helper is on PATH") + RECEIPT.setdefault("open_from_device", []).append({"step": name, "left_out": "a file dialog helper is on PATH"}) + return True + app.find("Not available here", "Label") + app.tap("Open a PDF from this device", "Button") + app.find(NO_IMPORT, "Label") + app.grab(out, name) + app.gone(NO_IMPORT) # the banner goes after 8 s; it would cover the next taps + RECEIPT.setdefault("open_from_device", []).append({"step": name, "shown": ["Not available here", NO_IMPORT]}) + return True + + def covers_kept(jail): """The covers in the pdf service's cache: file name -> modification time.""" folder = jail / ".cache/covers" @@ -848,7 +953,7 @@ def shell_journey(app, out, dark): if not dark and SHELL_JAIL is not None: RECEIPT["storage_line"] = check_storage_line(app, SHELL_JAIL) app.grab(out, f"{p}01-home") - if not dark: + if not dark and not open_on_linux(app, out, "01b-open-from-device"): app.tap("Open a PDF from this device", "Button") app.find("Couldn't open a PDF from this device", "Label") app.find("Bring PDF Tools to the front, then choose the file again.", "Label") @@ -1094,10 +1199,11 @@ def empty(app, out, dark=False): p = "d" if dark else "" app.find("No PDFs here yet", "Label", timeout=60) app.grab(out, f"{p}e01-empty") - app.tap("Open a PDF from this device", "Button") - app.find("Bring PDF Tools to the front, then choose the file again.", "Label") - app.grab(out, f"{p}e02-empty-open") - app.tap("OK", "Button") + if not open_on_linux(app, out, f"{p}e02-empty-open"): + app.tap("Open a PDF from this device", "Button") + app.find("Bring PDF Tools to the front, then choose the file again.", "Label") + app.grab(out, f"{p}e02-empty-open") + app.tap("OK", "Button") def missing(app, out): @@ -1179,9 +1285,10 @@ def shell_runs(args, runs): def session(name, home, steps): # Never beside another OctoSense: a person's, or another run's that - # takes no lock. Wait for it a while, then give up. + # takes no lock. Wait for it a while, then give up. (On Linux the run + # has a compositor of its own.) deadline = time.monotonic() + 1800 - while octosense_running(): + while not LINUX and octosense_running(): assert time.monotonic() < deadline, "another OctoSense is still running: quit it first" print("waiting for another OctoSense to quit", flush=True) time.sleep(20) @@ -1250,6 +1357,7 @@ def both(app, out): app.style("octosense-dark", "octosense dark") empty(app, out, dark=True) session("empty", home, both) + (args.output / "run.json").write_text(json.dumps(RECEIPT, indent=1)) def dev_bundle(out): 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("/.*$", "");