collective

Browse / github.com/storytold/wordcraft / patch

android: the in-app file browser serves every file dialog over the app's storage (part 3 of 6)

android: the in-app file browser serves every file dialog over the app's storage (part 3 of 6) Part **3 of 6** of the arm64 Android port — **stacked on #4451 (part 2, the NativeActivity entry)**, so that diff shows here too until it lands. Draft until the Android direction is decided. Android has no system file picker the app can drive (rfd has no backend for the target), and until now a file command there answered none — silently cancelled. With no system-dialog hook and the host's app-private storage recorded (`Services::app_files_dir`), `ask_file` serves every request — Open, Save As, Export, Insert Picture, recipients, Save as Template — in the built-in #576 browser instead, which

Status
open
Score
0.00
Adopters
0 other operators
Author
Kaedo17 (imported from pull request #4452, open)
Base
2c99a427803364354181d0bc20865f05880605f7
Size
+1268 −627 in 13 files
Review
pending
Shared
2026-10-28
Id
1eb7b9d83a4a85a0725bb0e4

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock apps/wordcraft-android/Cargo.toml apps/wordcraft-android/src/lib.rs apps/wordcraft/Cargo.toml apps/wordcraft/src/android_storage.rs apps/wordcraft/src/lib.rs apps/wordcraft/src/main.rs crates/ui-egui/src/file_browser.rs crates/ui-egui/src/file_dialogs.rs crates/ui-egui/src/lib.rs docs/architecture.md docs/control-protocol.md xtask/src/layers.rs

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -6649,6 +6649,7 @@ dependencies = [ name = "wordcraft" version = "0.11.0" dependencies = [+ "android-activity",  "arboard",  "chrono",  "clipboard-win",@@ -6680,6 +6681,15 @@ dependencies = [  "zbus", ] +[[package]]+name = "wordcraft-android"+version = "0.11.0"+dependencies = [+ "android-activity",+ "log",+ "wordcraft",+]+ [[package]] name = "wordcraft-cli" version = "0.11.0"diff --git a/apps/wordcraft-android/Cargo.toml b/apps/wordcraft-android/Cargo.tomlnew file mode 100644--- /dev/null+++ b/apps/wordcraft-android/Cargo.toml@@ -0,0 +1,60 @@+[package]+name = "wordcraft-android"+description = "WordCraft's Android entry point: the NativeActivity android_main trampoline that opens the shared app"+version.workspace = true+edition.workspace = true+license.workspace = true+rust-version.workspace = true++[lib]+# NativeActivity loads this library and calls its android_main.+crate-type = ["cdylib"]++# This crate deliberately does not inherit the workspace lints. Android's NativeActivity ABI+# requires an unmangled android_main export, and on edition 2024 that spelling+# (`#[unsafe(no_mangle)]`) trips rust's `unsafe_code` lint, which the workspace forbids — there is+# no safe spelling of a required export symbol. It therefore sets the workspace's *clippy* denials+# itself (every one of them) and leaves `unsafe_code` alone. The shared app it opens keeps the+# workspace lints unchanged.+[lints.clippy]+unwrap_used = "deny"+expect_used = "deny"+panic = "deny"+unimplemented = "deny"+todo = "deny"+unreachable = "deny"++[dependencies]+wordcraft = { path = "../wordcraft" }+log = { workspace = true }++[target.'cfg(target_os = "android")'.dependencies]+# The NativeActivity handle the app's event loop needs; the same 0.6 instance eframe and winit+# use, so the handle passes across crates as one type.+android-activity = { version = "0.6", default-features = false, features = ["native-activity"] }++[package.metadata.android]+package = "ai.storyteller.wordcraft"+apk_name = "wordcraft"+build_targets = ["aarch64-linux-android"]++[package.metadata.android.sdk]+min_sdk_version = 28+target_sdk_version = 35++[package.metadata.android.application]+label = "WordCraft"+theme = "@android:style/Theme.DeviceDefault.NoActionBar.Fullscreen"++[package.metadata.android.application.activity]+# Every configuration change the activity handles itself instead of a restart (the same list the+# other Craft apps' Android builds use): the window, documents and the app survive rotating the+# device, folding and unfolding it, a theme switch, a font-scale change, a different keyboard or+# locale.+config_changes = "orientation|keyboardHidden|screenSize|screenLayout|smallestScreenSize|density|layoutDirection|uiMode|fontScale|keyboard|navigation|touchscreen|locale|mcc|mnc|colorMode"+label = "WordCraft"+launch_mode = "singleTask"+# A document is read and written both ways; the app rotates with the device (the other Craft+# apps pin landscape, where the work is a timeline or canvas).+orientation = "unspecified"+exported = truediff --git a/apps/wordcraft-android/src/lib.rs b/apps/wordcraft-android/src/lib.rsnew file mode 100644--- /dev/null+++ b/apps/wordcraft-android/src/lib.rs@@ -0,0 +1,29 @@+//! Android entry point for WordCraft.+//!+//! Android's `NativeActivity` loads this library and calls [`android_main`] on a dedicated+//! thread. It opens the shared app ([`wordcraft::run_android`], the same code the desktop binary+//! runs) and returns when the window closes, ending the activity's main-loop thread.+//!+//! This crate exists because that export must be unmangled, and on edition 2024 the only spelling+//! (`#[unsafe(no_mangle)]`) trips the workspace-forbidden `unsafe_code` lint — see `Cargo.toml`+//! for why the crate carries its own lints instead.+//!+//! Android limitations (v1): no system file picker and no system message dialogs (rfd has no+//! Android backend), so Save, Open, exports and picture import are served by WordCraft's own+//! built-in file browser (a later step), and there is no per-user settings folder (`HOME` and+//! `XDG_CONFIG_HOME` are unset): preferences, logs, recovery, user fonts and templates live in+//! the app-private storage directory the activity resolves.+//!+//! On every other target this crate is empty: only Android's loader ever looks for `android_main`.++/// NativeActivity's main-loop thread entry point (required; see the `android-activity` docs).+/// winit pumps the activity's lifecycle and input events inside [`wordcraft::run_android`], which+/// needs the activity's handle to create its event loop; when the activity is destroyed that+/// returns and this returns with it.+#[cfg(target_os = "android")]+#[unsafe(no_mangle)]+pub fn android_main(app: android_activity::AndroidApp) {+    if let Err(e) = wordcraft::run_android(app) {+        log::error!("wordcraft exited with an error: {e}");+    }+}diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml--- a/apps/wordcraft/Cargo.toml+++ b/apps/wordcraft/Cargo.toml@@ -7,6 +7,10 @@ publish = false default-run = "wordcraft" description = "WordCraft desktop app" +[lib]+# The desktop binary is a shim over this library (and other hosts can open the same app).+crate-type = ["rlib"]+ [lints] workspace = true @@ -19,7 +23,7 @@ wordcraft-fonts = { workspace = true } wordcraft-ui-egui = { workspace = true } # The control key file's location (shared with the MCP bridge). wordcraft-control-key = { workspace = true }-eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11", "links"] }+eframe = { workspace = true, features = ["default_fonts", "wgpu"] } egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true }@@ -74,6 +78,12 @@ clipboard-win = { version = "5.4", features = ["std"] }  [target.'cfg(not(target_arch = "wasm32"))'.dependencies] craft-print = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" }+# The control server's random key.+getrandom = "0.3"++[target.'cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))'.dependencies]+# Native file dialogs (Open, Save As, Export, pictures): rfd has no backend for Android (it does+# not compile there), so the app's own file browser serves those commands instead (a later step). rfd = { workspace = true } # Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94); # rfd itself already depends on it.@@ -81,8 +91,19 @@ pollster = "1" # Pictures and copied files on the system clipboard (#45); egui's paste only carries text. Already # in the build through egui-winit, with the same features. arboard = { workspace = true, features = ["image-data"] }-# The control server's random key.-getrandom = "0.3"++[target.'cfg(not(target_os = "android"))'.dependencies]+# Not on Android: eframe only builds `accesskit` there with a desktop backend set, the clipboard+# and link opening have no backends either, and Wayland and X11 don't exist on a phone.+eframe = { workspace = true, features = ["accesskit", "clipboard", "wayland", "x11", "links"] }++[target.'cfg(target_os = "android")'.dependencies]+# The NativeActivity handle eframe's event loop needs (`NativeOptions.android_app`); the same 0.6+# instance winit and eframe use, so the handle passes across crates as one type.+android-activity = { version = "0.6", default-features = false, features = ["native-activity"] }+# eframe's Android backend (NativeActivity via winit). Without it android-activity builds with no+# backend and fails to compile.+eframe = { workspace = true, features = ["android-native-activity"] }  [target.'cfg(windows)'.dependencies] craft-update = { workspace = true }diff --git a/apps/wordcraft/src/android_storage.rs b/apps/wordcraft/src/android_storage.rsnew file mode 100644--- /dev/null+++ b/apps/wordcraft/src/android_storage.rs@@ -0,0 +1,88 @@+//! Android's app-private storage, below the `AndroidApp` data directory.+//!+//! Android has no system file picker for this app (rfd has no backend there), so opens, saves,+//! exports and settings default to private storage: no permissions and no Storage Access+//! Framework round-trip, though invisible to the Files app (the in-app browser can additionally+//! reach shared storage once "All files access" is granted; see `apps/wordcraft-android`). Pure+//! path logic, unit-tested on the host; the base directory comes from [`crate::run_inner`],+//! which records it before anything resolves the settings directory.+//!+//! Two folders below the base: `Documents/` (a place for the in-app browser to start and for+//! saves to land) and `config/` (preferences, logs, the user dictionary, user fonts, templates+//! and the control key file — what [`crate::settings_dir`] resolves to on Android).++// Only Android's launch resolves and creates these; the host build compiles the module for the+// tests below.+#![cfg_attr(not(target_os = "android"), allow(dead_code))]++use std::path::{Path, PathBuf};++/// The storage folders below the app-private base directory.+#[derive(Clone, Debug, PartialEq, Eq)]+pub struct Layout {+    /// The app-private root: what the in-app browser may reach (`Services::app_files_dir`).+    pub base: PathBuf,+    /// Where the in-app browser starts and where saves and exports default to.+    pub documents: PathBuf,+    /// Preferences, logs, the user dictionary, user fonts, templates and the control key file+    /// (the settings directory).+    pub config: PathBuf,+}++/// Split the app-private `base` into its folders.+pub fn layout(base: &Path) -> Layout {+    Layout { base: base.to_path_buf(), documents: base.join("Documents"), config: base.join("config") }+}++/// Create the folders (`Err` names the first failure; the app then keeps that feature off rather+/// than crashing).+pub fn ensure(layout: &Layout) -> Result<(), String> {+    for dir in [&layout.documents, &layout.config] {+        std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.to_string_lossy()))?;+    }+    Ok(())+}++#[cfg(test)]+mod tests {+    use super::*;++    fn temp_base(tag: &str) -> PathBuf {+        std::env::temp_dir().join(format!("wordcraft-android-storage-{tag}-{}", std::process::id()))+    }++    #[test]+    fn layout_splits_the_base_into_documents_and_config() {+        let base = Path::new("/data/data/ai.storyteller.wordcraft/files");+        let l = layout(base);+        assert_eq!(l.base, base);+        assert_eq!(l.documents, base.join("Documents"));+        assert_eq!(l.config, base.join("config"));+        // the settings folder is `base`'s `config` folder, so the split can be read back if needed+        assert_eq!(l.config.parent(), Some(base));+    }++    #[test]+    fn ensure_creates_every_folder() {+        let base = temp_base("ok");+        let _ = std::fs::remove_dir_all(&base);+        let l = layout(&base);+        ensure(&l).unwrap();+        assert!(l.documents.is_dir() && l.config.is_dir());+        // Re-running over existing folders is fine.+        ensure(&l).unwrap();+        let _ = std::fs::remove_dir_all(&base);+    }++    #[test]+    fn ensure_reports_a_folder_it_could_not_create() {+        let base = temp_base("blocked");+        let _ = std::fs::remove_dir_all(&base);+        // a file where a folder has to go: create_dir_all must fail, not panic+        std::fs::create_dir_all(&base).unwrap();+        std::fs::write(base.join("Documents"), b"x").unwrap();+        let err = ensure(&layout(&base)).unwrap_err();+        assert!(err.contains("Documents"), "{err}");+        let _ = std::fs::remove_dir_all(&base);+    }+}diff --git a/apps/wordcraft/src/lib.rs b/apps/wordcraft/src/lib.rsnew file mode 100644--- /dev/null+++ b/apps/wordcraft/src/lib.rs@@ -0,0 +1,789 @@+//! WordCraft app library: the window, hooks and launch logic, so the desktop binary (`main.rs`)+//! is a shim whose `main` calls [`run`], and another host can open the same app.+//!+//! Usage: `wordcraft [--control <port>] [--sample] [files…]`; `--help` prints `USAGE`.+//!+//! `--control <port>` (or `WORDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:+//! `{"id":1,"key":"…","method":"engine.execute","params":{"command":"text.insert","params":{"text":"Hi"}}}`.+//! Every request needs the key the app writes to `<settings>/control-key.<instance>` (mode 0600,+//! removed on exit). See `docs/control-protocol.md`.+//!+//! Android has no command line, so the phone's launch is the default one: no control server, no+//! sample, no files to open — the app starts on File › Home. The NativeActivity entry point (a+//! later step, `apps/wordcraft-android`) calls [`run_android`], which hands eframe the activity+//! handle its event loop needs. rfd and arboard have no backend for the target (they do not+//! compile there), so the system file dialogs and the system clipboard beyond egui's text stay+//! desktop only, and with no settings folder resolved yet (`HOME`/`XDG_CONFIG_HOME` are unset)+//! preferences, logs, recovery, user fonts, templates and the control server stay off until a+//! later step resolves the app-private storage the way the desktop resolves its settings folder.+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]++mod animations;+#[cfg(target_os = "macos")]+mod apple_events;+// Android: arboard (the system clipboard's pictures, formatted copies and format names) has no+// backend for the target (it does not compile there); egui's text clipboard serves Copy/Paste.+#[cfg(not(target_os = "android"))]+mod clipboard;+// Android's app-private storage (pure path logic, unit-tested on the host).+mod android_storage;+mod control_server;+// Android: no desktop file manager to register with (its hook is desktop-only).+#[cfg(not(target_os = "android"))]+mod default_apps;+// Android: rfd (the native file dialogs) has no backend for the target either; the app's own file+// browser serves those commands instead (a later step).+#[cfg(not(target_os = "android"))]+mod file_dialogs;+#[cfg(any(target_os = "windows", target_os = "macos", test))]+mod graphics;+mod logging;+// Android: no system spooler to list printers from or submit jobs to.+#[cfg(not(target_os = "android"))]+mod print;+mod region;+#[cfg(target_os = "macos")]+mod traffic_lights;++use wordcraft_engine::Session;+use wordcraft_ui_egui::{+    Services, UiState, WordApp,+    window_geometry::{RawWindowState, WindowGeometry, take_rescue},+};++/// The app, the restored window geometry (or, with none, the default: no position) until the+/// first frame that knows the monitor has checked it, the control server's key file (removed on+/// exit), the file dialogs the app asked for (desktop; Android's dialogs are its own file+/// browser, a later step), this frame's raw host window state and, on macOS, the documents+/// opened from Finder.+struct App {+    app: WordApp,+    window: Option<WindowGeometry>,+    key_file: Option<control_server::KeyFile>,+    #[cfg(not(target_os = "android"))]+    dialogs: file_dialogs::Launcher,+    // The host's answer for this frame. `InputState` still describes the last painted frame on+    // a hidden logic tick.+    raw: RawWindowState,+    #[cfg(target_os = "macos")]+    apple_events: fmv_macos_events::Inbox,+}++impl eframe::App for App {+    /// Advances one desktop frame: polls Finder events on macOS, runs the app and its file dialogs,+    /// fits or rescues the window and maximizes it after opening, then gates a close or quit request+    /// on unsaved changes and active printing.+    fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {+        // Android has no dialogs to show from the frame (`file_dialogs` is desktop).+        #[cfg(target_os = "android")]+        let _ = frame;+        #[cfg(target_os = "macos")]+        apple_events::poll(&self.apple_events, &mut self.app, ctx);+        self.app.logic(ctx);+        #[cfg(not(target_os = "android"))]+        self.dialogs.show(frame);+        let window = self.raw;+        // A window that doesn't fit on its screen (the default size on a laptop) shrinks to fit+        // there, so all of the scroll bar can be dragged (#1215). Checked on the frame that first+        // knows the monitor: the off-screen check below takes the pending geometry then.+        if let Some((pos, size)) = self.window.and_then(|g| g.fit_on_monitor(&window)) {+            ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));+            ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));+        }+        // The geometry saved for the next start is the active window's.+        let prev = self.app.ui.window;+        // Another document window's state is the one it last painted with: the host reports only+        // the root window's for this frame.+        let active = self.app.active_viewport();+        let tracked = if active == egui::ViewportId::ROOT {+            window+        } else {+            ctx.input_for(active, |i| RawWindowState::from_viewport(Some(i.viewport()), Some(i.viewport_rect())))+        };+        self.app.ui.window = WindowGeometry::track(prev, &tracked);+        // The first frame, once the monitor is known (as `take_rescue` waits for): maximize a+        // window that opened at its normal size so it never flashed on screen half-opened.+        let maximize = self.window.and_then(|g| g.maximize_after_open()).filter(|_| window.monitor_size.is_some());+        if let Some(pos) = take_rescue(&mut self.window, &window) {+            ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));+        }+        if let Some(command) = maximize {+            ctx.send_viewport_cmd(command);+        }+        // The window's close button: with other windows open only this window closes (the app+        // handles that while drawing it); the last one quits, asking first about unsaved changes+        // (`WordApp::close_requested`).+        let closing = ctx.input(|i| i.viewport().close_requested());+        if closing && (self.app.closes_a_window() || !self.app.close_requested()) {+            ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);+        } else if (closing || self.app.quit_requested) && !self.app.printing.busy() {+            // On macOS, letting eframe drop the window inside `-[NSApplication run]` aborts in+            // `-[_NSTouchBarFinderObservation invalidate]`. Command-Q calls `terminate:`. Once+            // the close is allowed, take that path instead of dropping the window on this turn.+            #[cfg(target_os = "macos")]+            {+                ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);+                self.app.quit_requested = false;+                schedule_macos_terminate(&mut self.app, self.key_file.as_ref());+            }+            #[cfg(not(target_os = "macos"))]+            if self.app.quit_requested {+                ctx.send_viewport_cmd(egui::ViewportCommand::Close);+            }+        }+    }+    /// Records this frame's native window state before forwarding the hook, so logic never reads a+    /// stale inner rectangle from a hidden tick.+    fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {+        // Keep this frame's native window state. `logic` must not read the last painted+        // `InputState` when a hidden Wayland tick reports no fresh inner rect.+        self.raw = RawWindowState::from_raw(raw);+        self.app.raw_input_hook(ctx, raw);+    }+    fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {+        self.app.ui(ui);+        #[cfg(not(target_os = "android"))]+        self.dialogs.show(frame);+        // Android has no dialogs to show from the frame (`file_dialogs` is desktop).+        #[cfg(target_os = "android")]+        let _ = frame;+    }+    fn on_exit(&mut self) {+        // Android has no system spooler (`print` is desktop): nothing pending to finish.+        #[cfg(not(target_os = "android"))]+        print::finish_pending_jobs();+        self.app.session.finish_recovery();+        save_prefs(&self.app);+        if let Some(key_file) = &self.key_file {+            key_file.remove();+        }+    }+}++/// Quit the way Command-Q does, on the next turn of the main queue.+///+/// Called when the window is closing. Doing it on the next turn lets the close+/// button's display cycle finish while the window still exists; `terminate:`+/// then ends the process. Preferences are written and the control key file is+/// removed first, as `on_exit` does, because a fallback [`std::process::exit`]+/// skips `on_exit`. A second close while a quit is already scheduled is ignored.+#[cfg(target_os = "macos")]+fn schedule_macos_terminate(app: &mut WordApp, key_file: Option<&control_server::KeyFile>) {+    use std::sync::atomic::{AtomicBool, Ordering};+    if app.printing.busy() {+        return;+    }+    static QUIT: AtomicBool = AtomicBool::new(false);+    if QUIT.swap(true, Ordering::Relaxed) {+        return;+    }+    log::info!("window close: quitting");+    // This path can bypass on_exit. Join the owned workers before termination and cleanup.+    print::finish_pending_jobs();+    app.session.finish_recovery();+    save_prefs(app);+    if let Some(key_file) = key_file {+        key_file.remove();+    }+    dispatch2::DispatchQueue::main().exec_async(|| {+        if let Some(mtm) = objc2::MainThreadMarker::new() {+            objc2_app_kit::NSApplication::sharedApplication(mtm).terminate(None);+        }+        // `terminate:` does not return when it succeeds. If it did, leave anyway:+        // staying up after the close was cancelled would trap the user in the window.+        std::process::exit(0);+    });+}++/// Android's resolved app-private storage ([`android_storage::Layout`]); `None` until+/// [`run_inner`] resolved it, and when the activity offers no path.+#[cfg(target_os = "android")]+static ANDROID_STORAGE: std::sync::OnceLock<Option<android_storage::Layout>> = std::sync::OnceLock::new();++/// The settings folder resolved on Android: `config` under the app-private storage, `Some` once+/// [`run_inner`] resolved it, `None` when there is none. Desktop never sets the cell and reads+/// the environment.+#[cfg(target_os = "android")]+fn android_settings_dir() -> Option<std::path::PathBuf> {+    ANDROID_STORAGE.get().and_then(|l| l.as_ref()).map(|l| l.config.clone())+}++/// The app-private root the in-app browser may reach, resolved on Android (the+/// `Services::app_files_dir` the app creator wires). Desktop has none.+#[cfg(target_os = "android")]+fn android_storage_base() -> Option<std::path::PathBuf> {+    ANDROID_STORAGE.get().and_then(|l| l.as_ref()).map(|l| l.base.clone())+}++/// The settings folder (preferences, logs, the control key, the user dictionary, user fonts,+/// templates, recovery): on Android the app-private storage resolved at start-up, otherwise+/// macOS `~/Library/Application Support/WordCraft`, Windows `%APPDATA%\WordCraft`, or+/// `$XDG_CONFIG_HOME/wordcraft` (`wordcraft_control_key::settings_dir` — Android has no+/// equivalent: `HOME` and `XDG_CONFIG_HOME` are unset there).+fn settings_dir() -> Option<std::path::PathBuf> {+    #[cfg(target_os = "android")]+    {+        android_settings_dir()+    }+    #[cfg(not(target_os = "android"))]+    {+        wordcraft_control_key::settings_dir()+    }+}++/// `ui.json` in the settings folder, which the MCP bridge also uses to find the control key.+fn prefs_path() -> Option<std::path::PathBuf> {+    settings_dir().map(|b| b.join("ui.json"))+}++/// Where the log files live: `logs` in the preferences folder (see `logging`).+fn log_dir() -> Option<std::path::PathBuf> {+    Some(prefs_path()?.parent()?.join("logs"))+}++/// `custom-dictionary.txt` in the settings folder: the words Add to Dictionary kept (#40).+fn user_dict_path() -> Option<std::path::PathBuf> {+    settings_dir().map(|b| b.join(wordcraft_engine::Session::USER_DICT_FILE))+}++fn load_user_dictionary(app: &mut WordApp) {+    if !prefs_enabled() {+        return;+    }+    if let Some(p) = user_dict_path() {+        wordcraft_proof::load_user_dictionary(&p);+        app.session.user_dict_path = Some(p);+    }+}++/// Runs without preferences (`WORDCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.+fn prefs_enabled() -> bool {+    std::env::var_os("WORDCRAFT_NO_PREFS").is_none()+}++fn load_prefs(app: &mut WordApp) {+    if !prefs_enabled() {+        return;+    }+    if let Some(p) = prefs_path()+        && let Ok(bytes) = std::fs::read(&p)+        && let Some(ui) = UiState::from_json(&bytes)+    {+        app.apply_prefs(ui);+    }+}++/// Fonts the user added (#582) live in `fonts` in the settings folder: load them, and store fonts+/// added later there. Runs without preferences neither read nor keep them.+fn load_user_fonts() {+    if !prefs_enabled() {+        return;+    }+    let Some(dir) = settings_dir().map(|d| d.join("fonts")) else { return };+    let (loaded, skipped) = wordcraft_fonts::FontDb::global().load_user_fonts(&dir);+    if !loaded.is_empty() || !skipped.is_empty() {+        log::info!("user fonts: {} loaded, {} skipped from {}", loaded.len(), skipped.len(), dir.display());+    }+}++/// The saved window geometry, read before the window opens (`load_prefs` runs after).+fn saved_window() -> Option<WindowGeometry> {+    if !prefs_enabled() {+        return None;+    }+    let bytes = std::fs::read(prefs_path()?).ok()?;+    UiState::from_json(&bytes)?.window?.sanitized()+}++fn save_prefs(app: &WordApp) {+    if !prefs_enabled() {+        return;+    }+    if let Some(p) = prefs_path() {+        if let Some(dir) = p.parent()+            && let Err(e) = std::fs::create_dir_all(dir)+        {+            log::warn!("preferences: cannot create {}: {e}", dir.display());+            return;+        }+        match serde_json::to_vec_pretty(&app.prefs()) {+            Ok(bytes) => {+                if let Err(e) = write_prefs(&p, &bytes) {+                    log::warn!("preferences: cannot save {}: {e}", p.display());+                }+            }+            Err(e) => log::warn!("preferences: cannot serialize: {e}"),+        }+    }+}++/// Writes `bytes` to a sibling temporary file and renames it over `path`, so a failed or+/// interrupted write leaves the previous preferences in place.+fn write_prefs(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {+    let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "ui.json".into());+    let tmp = path.with_file_name(format!(".{name}.{}.tmp", std::process::id()));+    let written = std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path));+    if written.is_err() {+        let _ = std::fs::remove_file(&tmp);+    }+    written+}++/// Native file dialogs, shown without blocking the window (`file_dialogs`, #94), File › Print+/// through the system spooler (`print`), the system clipboard's text, formatted copies,+/// pictures and format names (`clipboard`, #45, #584, #687, #833), the system's animation setting+/// (`animations`), and making WordCraft the default app for Word documents (`default_apps`, #295).+///+/// Android passes `None` for the dialogs: rfd, arboard and the spooler's printer list have no+/// backend for the target (they do not compile there, or find nothing to list), so the file+/// commands are served by the app's own file browser over the app-private storage+/// ([`android_storage`]) and egui's text clipboard serves Copy and Paste.+fn services(file_dialog: FileDialog, ctx: &egui::Context) -> Services {+    Services {+        file_dialog,+        // Android: the in-app browser walks the app-private storage; shared storage waits for+        // the "All files access" grant hooks (a later step).+        #[cfg(target_os = "android")]+        app_files_dir: android_storage_base(),+        #[cfg(not(target_os = "android"))]+        printers: Some(print::discover()),+        #[cfg(not(target_os = "android"))]+        print_native: Some(print::submit()),+        #[cfg(not(target_os = "android"))]+        clipboard_picture: Some(Box::new(clipboard::picture)),+        #[cfg(not(target_os = "android"))]+        make_default_app: default_apps::hook(),+        // Personal templates live in `Templates` in the settings folder (#2852), made on first save.+        templates_dir: settings_dir().map(|d| d.join("Templates")),+        animations_enabled: Some(animations::hook(ctx)),+        #[cfg(not(target_os = "android"))]+        clipboard_text: Some(Box::new(clipboard::text)),+        #[cfg(not(target_os = "android"))]+        clipboard_html: Some(Box::new(clipboard::html)),+        #[cfg(not(target_os = "android"))]+        set_clipboard_html: Some(Box::new(clipboard::set_html)),+        #[cfg(not(target_os = "android"))]+        clipboard_rtf: Some(Box::new(clipboard::rtf)),+        #[cfg(not(target_os = "android"))]+        clipboard_formats: Some(Box::new(clipboard::formats)),+        ..Default::default()+    }+}++/// `Services::file_dialog`'s hook type (`wordcraft_ui_egui` owns it); the desktop's `file_dialogs`+/// module implements it with rfd, and Android (which has no system picker, a later step serving+/// the commands with its own file browser) passes `None`.+type FileDialog = Option<Box<dyn Fn(wordcraft_ui_egui::FileDialogRequest) -> std::sync::mpsc::Receiver<wordcraft_ui_egui::FileDialogAnswer>>>;++/// Window, Dock and taskbar icon.+fn app_icon() -> Option<egui::IconData> {+    #[cfg(target_os = "macos")]+    let png: &[u8] = include_bytes!("../../../assets/app-icon/wordcraft-macos-512.png");+    #[cfg(not(target_os = "macos"))]+    let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.wordcraft.png");+    eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()+}++// Desktop only: Android has no command line, so nothing prints the usage text.+#[cfg(not(target_os = "android"))]+const USAGE: &str = "\+wordcraft — the WordCraft desktop app++USAGE:+  wordcraft [--sample] [--control PORT] [FILE…]++OPTIONS:+  --sample            open the sample document+  --control PORT      start the localhost control server on PORT, for agents and scripts+                      (or set WORDCRAFT_CONTROL_PORT); see docs/control-protocol.md+  --version           print the version+  -h, --help          print this help+";++/// The commit the build came from (short), or `dev`.+fn build_sha() -> &'static str {+    option_env!("WORDCRAFT_BUILD_SHA").map(|s| s.get(..8).unwrap_or(s)).unwrap_or("dev")+}++/// The arguments as text, and apart the files whose names aren't valid Unicode, such as a Latin-1+/// one (#1440). A `--control` port that isn't is an error.+#[cfg(not(target_os = "android"))]+fn text_args() -> (Vec<String>, Vec<std::path::PathBuf>) {+    let (mut args, mut files) = (Vec::new(), Vec::new());+    for (i, a) in std::env::args_os().skip(1).enumerate() {+        match a.into_string() {+            Ok(a) => args.push(a),+            Err(a) if args.last().is_some_and(|p| p == "--control") => {+                // Unlike `eprintln!`, never panics.+                let (n, lossy) = (i + 1, a.to_string_lossy());+                let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("wordcraft: argument {n} is not valid Unicode: {lossy}\n"));+                std::process::exit(2);+            }+            Err(a) => files.push(a.into()),+        }+    }+    (args, files)+}++/// Open a file whose name isn't valid Unicode (#1440). `file.open` takes its path as text, so it+/// gets the file's bytes under the lossy name, and the document then keeps the real path to save to.+/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB.+fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {+    let name = path.to_string_lossy();+    let opened = wordcraft_engine::io::read_document(path)+        .and_then(|bytes| app.run("file.open", serde_json::json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})));+    match opened {+        // Not when it waits for a password: that open names the document itself.+        Ok(r) if r.get("pending").is_none() => app.session.path = Some(path.into()),+        Ok(_) => {}+        Err(e) => log::warn!("{name}: {e}"),+    }+}++/// What the command line asked for. Android has no command line, so its launch is always the+/// default [`Launch::Run`].+enum Launch {+    /// `--version` or `--help` already printed: [`run`] returns `Ok(())`.+    #[cfg(not(target_os = "android"))]+    Done,+    /// Run the app: the control port, `--sample`, the files whose names are valid Unicode, and+    /// those whose names aren't (#1440).+    Run { control_port: Option<u16>, sample: bool, files: Vec<String>, other_files: Vec<std::path::PathBuf> },+}++/// Parses the command line into a [`Launch`]. Desktop only: Android has no command line, so the+/// phone's launch is the default one.+#[cfg(not(target_os = "android"))]+fn launch_args() -> Launch {+    let mut control_port: Option<u16> = std::env::var("WORDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());+    let mut files = Vec::new();+    let mut sample = false;+    let (args, other_files) = text_args();+    let mut bad_control: Option<Option<String>> = None;+    let mut args = args.into_iter().peekable();+    while let Some(a) = args.next() {+        match a.as_str() {+            // A flag after `--control` stays a flag; a bad or missing port is an error (#1426).+            "--control" => {+                let port = args.next_if(|p| p.strip_prefix('-').is_none_or(|n| n.starts_with(|c: char| c.is_ascii_digit())));+                match port.as_deref().and_then(|p| p.parse::<std::num::NonZeroU16>().ok()) {+                    Some(p) => control_port = Some(p.get()),+                    None => bad_control = Some(port),+                }+            }+            "--sample" => sample = true,+            "--version" => {+                println!("wordcraft {} ({})", env!("CARGO_PKG_VERSION"), build_sha());+                return Launch::Done;+            }+            // Before the window opens (#968). Unlike `print!`, a closed stdout can't panic.+            "--help" | "-h" => {+                let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes());+                return Launch::Done;+            }+            _ => files.push(a),+        }+    }+    // After the loop, so `--version` later on the line still wins. Unlike `eprintln!`, never panics.+    if let Some(port) = bad_control {+        let got = port.map(|p| format!(", got {p:?}")).unwrap_or_default();+        let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("wordcraft: --control needs a port number from 1 to 65535{got}\n"));+        std::process::exit(2);+    }+    Launch::Run { control_port, sample, files, other_files }+}++/// Open the window and run the app until its window closes. The desktop binary calls this from+/// `main`; Android's NativeActivity entry point (a later step, `apps/wordcraft-android`) calls+/// [`run_android`] from `android_main`.+pub fn run() -> eframe::Result {+    #[cfg(target_os = "android")]+    {+        run_inner(None)+    }+    #[cfg(not(target_os = "android"))]+    {+        run_inner()+    }+}++/// Android's entry: like [`run`], but with the activity's handle, which eframe's event loop+/// cannot be created without — leaving it out ends the activity as soon as it starts.+#[cfg(target_os = "android")]+pub fn run_android(android_app: android_activity::AndroidApp) -> eframe::Result {+    run_inner(Some(android_app))+}++/// Shared startup; `android_app` is the NativeActivity handle eframe needs on Android. Parses the+/// command line (desktop), installs logging and the panic hook, restores preferences and window+/// geometry, opens requested documents or the Start screen, starts the control server when+/// asked, and runs the eframe event loop.+fn run_inner(#[cfg(target_os = "android")] android_app: Option<android_activity::AndroidApp>) -> eframe::Result {+    // Windows: the update helper is a copy of this program started with `--finish-update`.+    #[cfg(windows)]+    if let Some(result) = craft_update::self_update::finish(&craft_update::suite::WORDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {+        std::process::exit(i32::from(result.is_err()));+    }+    // First, so the panic hook and every start-up warning are recorded (`logging`).+    let logger = logging::install();+    logging::install_panic_hook();+    // Android: split the app-private storage once, before anything reads the settings folder —+    // preferences, logs, recovery, user fonts, templates and the control server all hang off+    // `config`, and the in-app browser walks the root (and shared storage, a later step).+    // Desktop keeps its environment resolution (`wordcraft_control_key::settings_dir`).+    #[cfg(target_os = "android")]+    {+        let layout = android_app.as_ref().and_then(|app| app.internal_data_path().as_deref().map(android_storage::layout));+        let _ = ANDROID_STORAGE.set(layout.clone());+        if let Some(l) = layout.as_ref() {+            match android_storage::ensure(l) {+                Ok(()) => log::info!("android storage: {}", l.base.display()),+                Err(e) => log::warn!("android storage: {e}"),+            }+        }+    }+    // Android has no command line: the phone's launch is the default one — no control server, no+    // sample, no files to open (the app starts on File › Home).+    #[cfg(target_os = "android")]+    let launch = Launch::Run { control_port: None, sample: false, files: Vec::new(), other_files: Vec::new() };+    #[cfg(not(target_os = "android"))]+    let launch = launch_args();+    let (control_port, sample, files, other_files) = match launch {+        Launch::Run { control_port, sample, files, other_files } => (control_port, sample, files, other_files),+        #[cfg(not(target_os = "android"))]+        Launch::Done => return Ok(()),+    };+    // The log file lives in the settings directory, next to the preferences; opened after the+    // arguments, so `--version` leaves no file behind. Records logged until now are written to it+    // first. Runs without preferences (agents' test runs) log to standard error only, so they+    // don't rotate away the user's own logs.+    if let Some(logger) = logger {+        match log_dir().filter(|_| prefs_enabled()) {+            Some(dir) => match logger.attach_dir(&dir) {+                Ok(path) => log::info!("WordCraft {} ({}), log file {}", env!("CARGO_PKG_VERSION"), build_sha(), path.display()),+                // Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.+                Err(e) => log::warn!("no log file: {e}"),+            },+            None => logger.no_file(),+        }+    }+    let mut options = eframe::NativeOptions {+        viewport: egui::ViewportBuilder::default()+            .with_title("WordCraft")+            .with_app_id("ai.storyteller.wordcraft")+            .with_inner_size([1440.0, 920.0])+            .with_min_inner_size([760.0, 480.0])+            .with_drag_and_drop(true)+            .with_fullsize_content_view(true)+            .with_titlebar_shown(false)+            .with_title_shown(false),+        ..Default::default()+    };+    // The activity's handle (Android only): eframe's event loop is built from it. Without it,+    // `run_native` fails before the window exists and the activity ends on launch.+    #[cfg(target_os = "android")]+    {+        options.android_app = android_app.clone();+    }+    if let Some(icon) = app_icon() {+        options.viewport = options.viewport.with_icon(icon);+    }+    // Before eframe creates the wgpu instance: default Windows to DirectX 12 only, or OpenGL when+    // DirectX 12 has no GPU driver or this is a 32-bit build, and compile DirectX 12 shaders with+    // FXC, which needs no DLL of ours (#603; see graphics.rs).+    #[cfg(target_os = "windows")]+    graphics::configure(&mut options, eframe::wgpu::Backends::from_env());+    // Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (#2652).+    #[cfg(target_os = "macos")]+    graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64")));+    load_user_fonts();+    // Windows opened for further documents start like the first one, before its saved place.+    let window_builder = options.viewport.clone();+    let restored = saved_window();+    if let Some(window) = restored {+        options.viewport = window.apply(options.viewport);+    }+    #[cfg(all(unix, not(target_os = "macos"), not(target_os = "android")))]+    // winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file+    // manager showed a "no" cursor. Run through XWayland when it's there; WORDCRAFT_WAYLAND=1+    // keeps the native Wayland backend. (Android is unix too, and has no X11.)+    if std::env::var_os("DISPLAY").is_some() && std::env::var_os("WORDCRAFT_WAYLAND").is_none() {+        options.event_loop_builder = Some(Box::new(|b| {+            use winit::platform::x11::EventLoopBuilderExtX11;+            b.with_x11();+        }));+    }+    // Registered before the event loop starts, so it catches the Finder event that launched us as+    // well as later ones. Lives until the event loop returns; the app creator only borrows it.+    #[cfg(target_os = "macos")]+    let apple_events = apple_events::AppleEvents::install();+    #[cfg(target_os = "macos")]+    let apple_events = &apple_events;+    eframe::run_native(+        "WordCraft",+        options,+        Box::new(move |cc| {+            // The system region picks the new document's paper, the units and the decimal+            // separator (#838).+            let region = region::system();+            let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(&region) };+            // The system file dialogs (desktop). Android has none (rfd has no backend for the+            // target): no hook yet — a later step serves those commands with the app's own file+            // browser.+            #[cfg(not(target_os = "android"))]+            let (file_dialog, dialogs) = {+                let (file_dialog, dialogs) = file_dialogs::hook(&cc.egui_ctx);+                (Some(file_dialog), dialogs)+            };+            #[cfg(target_os = "android")]+            let file_dialog: FileDialog = None;+            let mut session = Session::new(doc);+            session.system_region = region;+            let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx));+            app.set_startup_update_check(true);+            load_prefs(&mut app);+            load_user_dictionary(&mut app);+            // A region chosen in File › Options replaces the system's for the first blank+            // document too (#838).+            if !sample && app.session.prefs.region.is_some() {+                let doc = app.session.blank_document();+                app.session.set_document(doc);+            }+            app.watch_desktop_theme(&cc.egui_ctx);+            if prefs_enabled()+                && let Some(dir) = settings_dir()+            {+                let ctx = cc.egui_ctx.clone();+                if let Err(e) = app.session.enable_recovery(dir.join("recovery"), std::sync::Arc::new(move || ctx.request_repaint())) {+                    log::warn!("local recovery unavailable: {e}");+                }+            }+            app.ui.window = restored;+            app.set_window_builder(window_builder);+            app.integrated_titlebar = cfg!(target_os = "macos");+            #[cfg(target_os = "macos")]+            if let Some(bar) = traffic_lights::title_bar() {+                log::info!("title bar: {bar:?}");+                app.mac_titlebar = bar;+            }+            let mut key_file = None;+            if let Some(port) = control_port+                && let Some((rx, file)) = control_server::start(port, cc.egui_ctx.clone(), settings_dir().as_deref())+            {+                app = app.with_control(rx);+                key_file = file;+            }+            // Nothing to open (no files, no sample, no control channel driving the editor): start+            // on File › Home, as Word does (#766), unless the Start screen option is off. Files+            // macOS hands over later open on top of it and close it (`apple_events`).+            if files.is_empty() && other_files.is_empty() && !sample && control_port.is_none() {+                app.start_screen();+            }+            for f in files {+                if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {+                    log::warn!("{f}: {e}");+                }+            }+            for f in other_files {+                open_non_unicode(&mut app, &f);+            }+            Ok(Box::new(App {+                app,+                // With nothing restored, the window the system placed is checked all the same.+                window: Some(restored.unwrap_or_default()),+                key_file,+                #[cfg(not(target_os = "android"))]+                dialogs,+                raw: RawWindowState::default(),+                #[cfg(target_os = "macos")]+                apple_events: apple_events.connect(&cc.egui_ctx),+            }))+        }),+    )+}++// Not on macOS: APFS refuses file names that aren't valid UTF-8, so no such file can exist there.+#[cfg(all(test, unix, not(target_os = "macos")))]+mod tests {+    use super::*;+    use std::os::unix::ffi::OsStringExt;++    #[test]+    fn a_file_whose_name_isnt_unicode_opens_and_saves_back_to_itself() {+        let dir = std::env::temp_dir().join(format!("wc-app-non-utf8-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&dir);+        std::fs::create_dir_all(&dir).unwrap();+        let path = dir.join(std::ffi::OsString::from_vec(b"caf\xe9.txt".to_vec()));+        std::fs::write(&path, "Bonjour").unwrap();++        let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());+        open_non_unicode(&mut app, &path);+        assert_eq!(app.session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "Bonjour");+        assert_eq!(app.session.path.as_deref(), Some(path.as_path()));++        app.run("caret.docEnd", serde_json::json!({})).unwrap();+        app.run("text.insert", serde_json::json!({"text": " encore"})).unwrap();+        app.run("file.save", serde_json::json!({})).unwrap();+        assert!(std::fs::read_to_string(&path).unwrap().contains("Bonjour encore"));+        assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "a second file was written");++        // A file that can't be read is a warning, as one that fails to open by name is.+        open_non_unicode(&mut app, &dir.join(std::ffi::OsString::from_vec(b"gon\xe9.txt".to_vec())));+        assert_eq!(app.session.path.as_deref(), Some(path.as_path()));+        let _ = std::fs::remove_dir_all(dir);+    }++    /// A named pipe or a device under such a name is a warning, not a read: the pipe blocked+    /// start-up until something wrote to it, and `/dev/zero` read until memory ran out.+    #[test]+    fn a_pipe_or_device_whose_name_isnt_unicode_is_not_read() {+        let dir = std::env::temp_dir().join(format!("wc-app-non-utf8-dev-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&dir);+        std::fs::create_dir_all(&dir).unwrap();+        let fifo = dir.join(std::ffi::OsString::from_vec(b"fif\xe9.txt".to_vec()));+        if !std::process::Command::new("mkfifo").arg(&fifo).status().is_ok_and(|s| s.success()) {+            let _ = std::fs::remove_dir_all(&dir);+            eprintln!("mkfifo unavailable; skipping");+            return;+        }+        let zero = dir.join(std::ffi::OsString::from_vec(b"z\xe9ro.txt".to_vec()));+        std::os::unix::fs::symlink("/dev/zero", &zero).unwrap();+        // If the open blocks on the pipe, a writer comes after 10 s, so the test fails rather than hangs.+        let unblock = fifo.clone();+        std::thread::spawn(move || {+            std::thread::sleep(std::time::Duration::from_secs(10));+            let _ = std::fs::OpenOptions::new().write(true).open(unblock);+        });++        let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());+        app.run("text.insert", serde_json::json!({"text": "kept"})).unwrap();+        for p in [&fifo, &zero] {+            let started = std::time::Instant::now();+            open_non_unicode(&mut app, p);+            assert!(started.elapsed() < std::time::Duration::from_secs(5), "{p:?} took {:?}", started.elapsed());+            assert_eq!(app.session.path, None, "{p:?}");+            assert!(app.session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("kept"), "{p:?}");+        }+        let _ = std::fs::remove_dir_all(dir);+    }+}++#[cfg(test)]+mod prefs_tests {+    use super::write_prefs;++    #[test]+    fn failed_preferences_write_keeps_the_old_file_and_reports_the_error() {+        let dir = std::env::temp_dir().join(format!("wordcraft-prefs-test-{}", std::process::id()));+        std::fs::create_dir_all(&dir).unwrap();+        let path = dir.join("ui.json");+        std::fs::write(&path, b"old").unwrap();+        write_prefs(&path, b"new").unwrap();+        assert_eq!(std::fs::read(&path).unwrap(), b"new");+        // A directory squatting on the temporary name makes the preparation step fail.+        std::fs::create_dir(dir.join(format!(".ui.json.{}.tmp", std::process::id()))).unwrap();+        assert!(write_prefs(&path, b"newer").is_err());+        assert_eq!(std::fs::read(&path).unwrap(), b"new");+        let _ = std::fs::remove_dir_all(&dir);+    }+}diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs--- a/apps/wordcraft/src/main.rs+++ b/apps/wordcraft/src/main.rs@@ -1,611 +1,7 @@-//! WordCraft desktop app.-//!-//! Usage: `wordcraft [--control <port>] [--sample] [files…]`; `--help` prints `USAGE`.-//!-//! `--control <port>` (or `WORDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:-//! `{"id":1,"key":"…","method":"engine.execute","params":{"command":"text.insert","params":{"text":"Hi"}}}`.-//! Every request needs the key the app writes to `<settings>/control-key.<instance>` (mode 0600,-//! removed on exit). See `docs/control-protocol.md`.+//! WordCraft desktop app binary: a shim over the app library (`lib.rs`, [`wordcraft::run`]). #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -mod animations;-#[cfg(target_os = "macos")]-mod apple_events;-mod clipboard;-mod control_server;-mod default_apps;-mod file_dialogs;-#[cfg(any(target_os = "windows", target_os = "macos", test))]-mod graphics;-mod logging;-mod print;-mod region;-#[cfg(target_os = "macos")]-mod traffic_lights;--use wordcraft_engine::Session;-use wordcraft_ui_egui::{-    Services, UiState, WordApp,-    window_geometry::{RawWindowState, WindowGeometry, take_rescue},-};--/// The app, the restored window geometry (or, with none, the default: no position) until the-/// first frame that knows the monitor has checked it, the control server's key file (removed on-/// exit), the file dialogs the app asked for, this frame's raw host window state and, on macOS,-/// the documents opened from Finder.-struct App(-    WordApp,-    Option<WindowGeometry>,-    Option<control_server::KeyFile>,-    file_dialogs::Launcher,-    // The host's answer for this frame. `InputState` still describes the last painted frame on-    // a hidden logic tick.-    RawWindowState,-    #[cfg(target_os = "macos")] fmv_macos_events::Inbox,-);--impl eframe::App for App {-    /// Advances one desktop frame: polls Finder events on macOS, runs the app and its file dialogs,-    /// fits or rescues the window and maximizes it after opening, then gates a close or quit request-    /// on unsaved changes and active printing.-    fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {-        #[cfg(target_os = "macos")]-        apple_events::poll(&self.5, &mut self.0, ctx);-        self.0.logic(ctx);-        self.3.show(frame);-        let window = self.4;-        // A window that doesn't fit on its screen (the default size on a laptop) shrinks to fit-        // there, so all of the scroll bar can be dragged (#1215). Checked on the frame that first-        // knows the monitor: the off-screen check below takes the pending geometry then.-        if let Some((pos, size)) = self.1.and_then(|g| g.fit_on_monitor(&window)) {-            ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));-            ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));-        }-        // The geometry saved for the next start is the active window's.-        let prev = self.0.ui.window;-        // Another document window's state is the one it last painted with: the host reports only-        // the root window's for this frame.-        let active = self.0.active_viewport();-        let tracked = if active == egui::ViewportId::ROOT {-            window-        } else {-            ctx.input_for(active, |i| RawWindowState::from_viewport(Some(i.viewport()), Some(i.viewport_rect())))-        };-        self.0.ui.window = WindowGeometry::track(prev, &tracked);-        // The first frame, once the monitor is known (as `take_rescue` waits for): maximize a-        // window that opened at its normal size so it never flashed on screen half-opened.-        let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| window.monitor_size.is_some());-        if let Some(pos) = take_rescue(&mut self.1, &window) {-            ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));-        }-        if let Some(command) = maximize {-            ctx.send_viewport_cmd(command);-        }-        // The window's close button: with other windows open only this window closes (the app-        // handles that while drawing it); the last one quits, asking first about unsaved changes-        // (`WordApp::close_requested`).-        let closing = ctx.input(|i| i.viewport().close_requested());-        if closing && (self.0.closes_a_window() || !self.0.close_requested()) {-            ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);-        } else if (closing || self.0.quit_requested) && !self.0.printing.busy() {-            // On macOS, letting eframe drop the window inside `-[NSApplication run]` aborts in-            // `-[_NSTouchBarFinderObservation invalidate]`. Command-Q calls `terminate:`. Once-            // the close is allowed, take that path instead of dropping the window on this turn.-            #[cfg(target_os = "macos")]-            {-                ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);-                self.0.quit_requested = false;-                schedule_macos_terminate(&mut self.0, self.2.as_ref());-            }-            #[cfg(not(target_os = "macos"))]-            if self.0.quit_requested {-                ctx.send_viewport_cmd(egui::ViewportCommand::Close);-            }-        }-    }-    /// Records this frame's native window state before forwarding the hook, so logic never reads a-    /// stale inner rectangle from a hidden tick.-    fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {-        // Keep this frame's native window state. `logic` must not read the last painted-        // `InputState` when a hidden Wayland tick reports no fresh inner rect.-        self.4 = RawWindowState::from_raw(raw);-        self.0.raw_input_hook(ctx, raw);-    }-    fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {-        self.0.ui(ui);-        self.3.show(frame);-    }-    fn on_exit(&mut self) {-        print::finish_pending_jobs();-        self.0.session.finish_recovery();-        save_prefs(&self.0);-        if let Some(key_file) = &self.2 {-            key_file.remove();-        }-    }-}--/// Quit the way Command-Q does, on the next turn of the main queue.-///-/// Called when the window is closing. Doing it on the next turn lets the close-/// button's display cycle finish while the window still exists; `terminate:`-/// then ends the process. Preferences are written and the control key file is-/// removed first, as `on_exit` does, because a fallback [`std::process::exit`]-/// skips `on_exit`. A second close while a quit is already scheduled is ignored.-#[cfg(target_os = "macos")]-fn schedule_macos_terminate(app: &mut WordApp, key_file: Option<&control_server::KeyFile>) {-    use std::sync::atomic::{AtomicBool, Ordering};-    if app.printing.busy() {-        return;-    }-    static QUIT: AtomicBool = AtomicBool::new(false);-    if QUIT.swap(true, Ordering::Relaxed) {-        return;-    }-    log::info!("window close: quitting");-    // This path can bypass on_exit. Join the owned workers before termination and cleanup.-    print::finish_pending_jobs();-    app.session.finish_recovery();-    save_prefs(app);-    if let Some(key_file) = key_file {-        key_file.remove();-    }-    dispatch2::DispatchQueue::main().exec_async(|| {-        if let Some(mtm) = objc2::MainThreadMarker::new() {-            objc2_app_kit::NSApplication::sharedApplication(mtm).terminate(None);-        }-        // `terminate:` does not return when it succeeds. If it did, leave anyway:-        // staying up after the close was cancelled would trap the user in the window.-        std::process::exit(0);-    });-}--/// `ui.json` in the settings folder, which the MCP bridge also uses to find the control key.-fn prefs_path() -> Option<std::path::PathBuf> {-    wordcraft_control_key::settings_dir().map(|b| b.join("ui.json"))-}--/// Where the log files live: `logs` in the preferences folder (see `logging`).-fn log_dir() -> Option<std::path::PathBuf> {-    Some(prefs_path()?.parent()?.join("logs"))-}--/// `custom-dictionary.txt` in the settings folder: the words Add to Dictionary kept (#40).-fn user_dict_path() -> Option<std::path::PathBuf> {-    wordcraft_control_key::settings_dir().map(|b| b.join(wordcraft_engine::Session::USER_DICT_FILE))-}--fn load_user_dictionary(app: &mut WordApp) {-    if !prefs_enabled() {-        return;-    }-    if let Some(p) = user_dict_path() {-        wordcraft_proof::load_user_dictionary(&p);-        app.session.user_dict_path = Some(p);-    }-}--/// Runs without preferences (`WORDCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.-fn prefs_enabled() -> bool {-    std::env::var_os("WORDCRAFT_NO_PREFS").is_none()-}--fn load_prefs(app: &mut WordApp) {-    if !prefs_enabled() {-        return;-    }-    if let Some(p) = prefs_path()-        && let Ok(bytes) = std::fs::read(&p)-        && let Some(ui) = UiState::from_json(&bytes)-    {-        app.apply_prefs(ui);-    }-}--/// Fonts the user added (#582) live in `fonts` in the settings folder: load them, and store fonts-/// added later there. Runs without preferences neither read nor keep them.-fn load_user_fonts() {-    if !prefs_enabled() {-        return;-    }-    let Some(dir) = wordcraft_control_key::settings_dir().map(|d| d.join("fonts")) else { return };-    let (loaded, skipped) = wordcraft_fonts::FontDb::global().load_user_fonts(&dir);-    if !loaded.is_empty() || !skipped.is_empty() {-        log::info!("user fonts: {} loaded, {} skipped from {}", loaded.len(), skipped.len(), dir.display());-    }-}--/// The saved window geometry, read before the window opens (`load_prefs` runs after).-fn saved_window() -> Option<WindowGeometry> {-    if !prefs_enabled() {-        return None;-    }-    let bytes = std::fs::read(prefs_path()?).ok()?;-    UiState::from_json(&bytes)?.window?.sanitized()-}--fn save_prefs(app: &WordApp) {-    if !prefs_enabled() {-        return;-    }-    if let Some(p) = prefs_path() {-        if let Some(dir) = p.parent()-            && let Err(e) = std::fs::create_dir_all(dir)-        {-            log::warn!("preferences: cannot create {}: {e}", dir.display());-            return;-        }-        match serde_json::to_vec_pretty(&app.prefs()) {-            Ok(bytes) => {-                if let Err(e) = write_prefs(&p, &bytes) {-                    log::warn!("preferences: cannot save {}: {e}", p.display());-                }-            }-            Err(e) => log::warn!("preferences: cannot serialize: {e}"),-        }-    }-}--/// Writes `bytes` to a sibling temporary file and renames it over `path`, so a failed or-/// interrupted write leaves the previous preferences in place.-fn write_prefs(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {-    let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "ui.json".into());-    let tmp = path.with_file_name(format!(".{name}.{}.tmp", std::process::id()));-    let written = std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path));-    if written.is_err() {-        let _ = std::fs::remove_file(&tmp);-    }-    written-}--/// Native file dialogs, shown without blocking the window (`file_dialogs`, #94), File › Print-/// through the system spooler (`print`), the system clipboard's text, formatted copies,-/// pictures and format names (`clipboard`, #45, #584, #687, #833), the system's animation setting-/// (`animations`), and making WordCraft the default app for Word documents (`default_apps`, #295).-fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {-    Services {-        file_dialog: Some(file_dialog),-        printers: Some(print::discover()),-        print_native: Some(print::submit()),-        clipboard_picture: Some(Box::new(clipboard::picture)),-        make_default_app: default_apps::hook(),-        // Personal templates live in `Templates` in the settings folder (#2852), made on first save.-        templates_dir: wordcraft_control_key::settings_dir().map(|d| d.join("Templates")),-        animations_enabled: Some(animations::hook(ctx)),-        clipboard_text: Some(Box::new(clipboard::text)),-        clipboard_html: Some(Box::new(clipboard::html)),-        set_clipboard_html: Some(Box::new(clipboard::set_html)),-        clipboard_rtf: Some(Box::new(clipboard::rtf)),-        clipboard_formats: Some(Box::new(clipboard::formats)),-        ..Default::default()-    }-}--/// Window, Dock and taskbar icon.-fn app_icon() -> Option<egui::IconData> {-    #[cfg(target_os = "macos")]-    let png: &[u8] = include_bytes!("../../../assets/app-icon/wordcraft-macos-512.png");-    #[cfg(not(target_os = "macos"))]-    let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.wordcraft.png");-    eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()-}--const USAGE: &str = "\-wordcraft — the WordCraft desktop app--USAGE:-  wordcraft [--sample] [--control PORT] [FILE…]--OPTIONS:-  --sample            open the sample document-  --control PORT      start the localhost control server on PORT, for agents and scripts-                      (or set WORDCRAFT_CONTROL_PORT); see docs/control-protocol.md-  --version           print the version-  -h, --help          print this help-";--/// The commit the build came from (short), or `dev`.-fn build_sha() -> &'static str {-    option_env!("WORDCRAFT_BUILD_SHA").map(|s| s.get(..8).unwrap_or(s)).unwrap_or("dev")-}--/// The arguments as text, and apart the files whose names aren't valid Unicode, such as a Latin-1-/// one (#1440). A `--control` port that isn't is an error.-fn text_args() -> (Vec<String>, Vec<std::path::PathBuf>) {-    let (mut args, mut files) = (Vec::new(), Vec::new());-    for (i, a) in std::env::args_os().skip(1).enumerate() {-        match a.into_string() {-            Ok(a) => args.push(a),-            Err(a) if args.last().is_some_and(|p| p == "--control") => {-                // Unlike `eprintln!`, never panics.-                let (n, lossy) = (i + 1, a.to_string_lossy());-                let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("wordcraft: argument {n} is not valid Unicode: {lossy}\n"));-                std::process::exit(2);-            }-            Err(a) => files.push(a.into()),-        }-    }-    (args, files)-}--/// Open a file whose name isn't valid Unicode (#1440). `file.open` takes its path as text, so it-/// gets the file's bytes under the lossy name, and the document then keeps the real path to save to.-/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB.-fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {-    let name = path.to_string_lossy();-    let opened = wordcraft_engine::io::read_document(path)-        .and_then(|bytes| app.run("file.open", serde_json::json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})));-    match opened {-        // Not when it waits for a password: that open names the document itself.-        Ok(r) if r.get("pending").is_none() => app.session.path = Some(path.into()),-        Ok(_) => {}-        Err(e) => log::warn!("{name}: {e}"),-    }-}--/// Parses the command line, installs logging and the panic hook, restores preferences and window-/// geometry, opens requested documents or the Start screen, starts the control server when-/// asked, and runs the eframe event loop. fn main() -> eframe::Result {-    // Windows: the update helper is a copy of this program started with `--finish-update`.-    #[cfg(windows)]-    if let Some(result) = craft_update::self_update::finish(&craft_update::suite::WORDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {-        std::process::exit(i32::from(result.is_err()));-    }-    // First, so the panic hook and every start-up warning are recorded (`logging`).-    let logger = logging::install();-    logging::install_panic_hook();-    let mut control_port: Option<u16> = std::env::var("WORDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());-    let mut files = Vec::new();-    let mut sample = false;-    let (args, other_files) = text_args();-    let mut bad_control: Option<Option<String>> = None;-    let mut args = args.into_iter().peekable();-    while let Some(a) = args.next() {-        match a.as_str() {-            // A flag after `--control` stays a flag; a bad or missing port is an error (#1426).-            "--control" => {-                let port = args.next_if(|p| p.strip_prefix('-').is_none_or(|n| n.starts_with(|c: char| c.is_ascii_digit())));-                match port.as_deref().and_then(|p| p.parse::<std::num::NonZeroU16>().ok()) {-                    Some(p) => control_port = Some(p.get()),-                    None => bad_control = Some(port),-                }-            }-            "--sample" => sample = true,-            "--version" => {-                println!("wordcraft {} ({})", env!("CARGO_PKG_VERSION"), build_sha());-                return Ok(());-            }-            // Before the window opens (#968). Unlike `print!`, a closed stdout can't panic.-            "--help" | "-h" => {-                let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes());-                return Ok(());-            }-            _ => files.push(a),-        }-    }-    // After the loop, so `--version` later on the line still wins. Unlike `eprintln!`, never panics.-    if let Some(port) = bad_control {-        let got = port.map(|p| format!(", got {p:?}")).unwrap_or_default();-        let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("wordcraft: --control needs a port number from 1 to 65535{got}\n"));-        std::process::exit(2);-    }-    // The log file lives in the settings directory, next to the preferences; opened after the-    // arguments, so `--version` leaves no file behind. Records logged until now are written to it-    // first. Runs without preferences (agents' test runs) log to standard error only, so they-    // don't rotate away the user's own logs.-    if let Some(logger) = logger {-        match log_dir().filter(|_| prefs_enabled()) {-            Some(dir) => match logger.attach_dir(&dir) {-                Ok(path) => log::info!("WordCraft {} ({}), log file {}", env!("CARGO_PKG_VERSION"), build_sha(), path.display()),-                // Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.-                Err(e) => log::warn!("no log file: {e}"),-            },-            None => logger.no_file(),-        }-    }-    let mut options = eframe::NativeOptions {-        viewport: egui::ViewportBuilder::default()-            .with_title("WordCraft")-            .with_app_id("ai.storyteller.wordcraft")-            .with_inner_size([1440.0, 920.0])-            .with_min_inner_size([760.0, 480.0])-            .with_drag_and_drop(true)-            .with_fullsize_content_view(true)-            .with_titlebar_shown(false)-            .with_title_shown(false),-        ..Default::default()-    };-    if let Some(icon) = app_icon() {-        options.viewport = options.viewport.with_icon(icon);-    }-    // Before eframe creates the wgpu instance: default Windows to DirectX 12 only, or OpenGL when-    // DirectX 12 has no GPU driver or this is a 32-bit build, and compile DirectX 12 shaders with-    // FXC, which needs no DLL of ours (#603; see graphics.rs).-    #[cfg(target_os = "windows")]-    graphics::configure(&mut options, eframe::wgpu::Backends::from_env());-    // Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (#2652).-    #[cfg(target_os = "macos")]-    graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64")));-    load_user_fonts();-    // Windows opened for further documents start like the first one, before its saved place.-    let window_builder = options.viewport.clone();-    let restored = saved_window();-    if let Some(window) = restored {-        options.viewport = window.apply(options.viewport);-    }-    #[cfg(all(unix, not(target_os = "macos")))]-    // winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file-    // manager showed a "no" cursor. Run through XWayland when it's there; WORDCRAFT_WAYLAND=1-    // keeps the native Wayland backend.-    if std::env::var_os("DISPLAY").is_some() && std::env::var_os("WORDCRAFT_WAYLAND").is_none() {-        options.event_loop_builder = Some(Box::new(|b| {-            use winit::platform::x11::EventLoopBuilderExtX11;-            b.with_x11();-        }));-    }-    // Registered before the event loop starts, so it catches the Finder event that launched us as-    // well as later ones. Lives until the event loop returns; the app creator only borrows it.-    #[cfg(target_os = "macos")]-    let apple_events = apple_events::AppleEvents::install();-    #[cfg(target_os = "macos")]-    let apple_events = &apple_events;-    eframe::run_native(-        "WordCraft",-        options,-        Box::new(move |cc| {-            // The system region picks the new document's paper, the units and the decimal-            // separator (#838).-            let region = region::system();-            let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(&region) };-            let (file_dialog, dialogs) = file_dialogs::hook(&cc.egui_ctx);-            let mut session = Session::new(doc);-            session.system_region = region;-            let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx));-            app.set_startup_update_check(true);-            load_prefs(&mut app);-            load_user_dictionary(&mut app);-            // A region chosen in File › Options replaces the system's for the first blank-            // document too (#838).-            if !sample && app.session.prefs.region.is_some() {-                let doc = app.session.blank_document();-                app.session.set_document(doc);-            }-            app.watch_desktop_theme(&cc.egui_ctx);-            if prefs_enabled()-                && let Some(dir) = wordcraft_control_key::settings_dir()-            {-                let ctx = cc.egui_ctx.clone();-                if let Err(e) = app.session.enable_recovery(dir.join("recovery"), std::sync::Arc::new(move || ctx.request_repaint())) {-                    log::warn!("local recovery unavailable: {e}");-                }-            }-            app.ui.window = restored;-            app.set_window_builder(window_builder);-            app.integrated_titlebar = cfg!(target_os = "macos");-            #[cfg(target_os = "macos")]-            if let Some(bar) = traffic_lights::title_bar() {-                log::info!("title bar: {bar:?}");-                app.mac_titlebar = bar;-            }-            let mut key_file = None;-            if let Some(port) = control_port-                && let Some((rx, file)) = control_server::start(port, cc.egui_ctx.clone(), wordcraft_control_key::settings_dir().as_deref())-            {-                app = app.with_control(rx);-                key_file = file;-            }-            // Nothing to open (no files, no sample, no control channel driving the editor): start-            // on File › Home, as Word does (#766), unless the Start screen option is off. Files-            // macOS hands over later open on top of it and close it (`apple_events`).-            if files.is_empty() && other_files.is_empty() && !sample && control_port.is_none() {-                app.start_screen();-            }-            for f in files {-                if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {-                    log::warn!("{f}: {e}");-                }-            }-            for f in other_files {-                open_non_unicode(&mut app, &f);-            }-            Ok(Box::new(App(-                app,-                // With nothing restored, the window the system placed is checked all the same.-                Some(restored.unwrap_or_default()),-                key_file,-                dialogs,-                RawWindowState::default(),-                #[cfg(target_os = "macos")]-                apple_events.connect(&cc.egui_ctx),-            )))-        }),-    )-}--// Not on macOS: APFS refuses file names that aren't valid UTF-8, so no such file can exist there.-#[cfg(all(test, unix, not(target_os = "macos")))]-mod tests {-    use super::*;-    use std::os::unix::ffi::OsStringExt;--    #[test]-    fn a_file_whose_name_isnt_unicode_opens_and_saves_back_to_itself() {-        let dir = std::env::temp_dir().join(format!("wc-app-non-utf8-{}", std::process::id()));-        let _ = std::fs::remove_dir_all(&dir);-        std::fs::create_dir_all(&dir).unwrap();-        let path = dir.join(std::ffi::OsString::from_vec(b"caf\xe9.txt".to_vec()));-        std::fs::write(&path, "Bonjour").unwrap();--        let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());-        open_non_unicode(&mut app, &path);-        assert_eq!(app.session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "Bonjour");-        assert_eq!(app.session.path.as_deref(), Some(path.as_path()));--        app.run("caret.docEnd", serde_json::json!({})).unwrap();-        app.run("text.insert", serde_json::json!({"text": " encore"})).unwrap();-        app.run("file.save", serde_json::json!({})).unwrap();-        assert!(std::fs::read_to_string(&path).unwrap().contains("Bonjour encore"));-        assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "a second file was written");--        // A file that can't be read is a warning, as one that fails to open by name is.-        open_non_unicode(&mut app, &dir.join(std::ffi::OsString::from_vec(b"gon\xe9.txt".to_vec())));-        assert_eq!(app.session.path.as_deref(), Some(path.as_path()));-        let _ = std::fs::remove_dir_all(dir);-    }--    /// A named pipe or a device under such a name is a warning, not a read: the pipe blocked-    /// start-up until something wrote to it, and `/dev/zero` read until memory ran out.-    #[test]-    fn a_pipe_or_device_whose_name_isnt_unicode_is_not_read() {-        let dir = std::env::temp_dir().join(format!("wc-app-non-utf8-dev-{}", std::process::id()));-        let _ = std::fs::remove_dir_all(&dir);-        std::fs::create_dir_all(&dir).unwrap();-        let fifo = dir.join(std::ffi::OsString::from_vec(b"fif\xe9.txt".to_vec()));-        if !std::process::Command::new("mkfifo").arg(&fifo).status().is_ok_and(|s| s.success()) {-            let _ = std::fs::remove_dir_all(&dir);-            eprintln!("mkfifo unavailable; skipping");-            return;-        }-        let zero = dir.join(std::ffi::OsString::from_vec(b"z\xe9ro.txt".to_vec()));-        std::os::unix::fs::symlink("/dev/zero", &zero).unwrap();-        // If the open blocks on the pipe, a writer comes after 10 s, so the test fails rather than hangs.-        let unblock = fifo.clone();-        std::thread::spawn(move || {-            std::thread::sleep(std::time::Duration::from_secs(10));-            let _ = std::fs::OpenOptions::new().write(true).open(unblock);-        });--        let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());-        app.run("text.insert", serde_json::json!({"text": "kept"})).unwrap();-        for p in [&fifo, &zero] {-            let started = std::time::Instant::now();-            open_non_unicode(&mut app, p);-            assert!(started.elapsed() < std::time::Duration::from_secs(5), "{p:?} took {:?}", started.elapsed());-            assert_eq!(app.session.path, None, "{p:?}");-            assert!(app.session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("kept"), "{p:?}");-        }-        let _ = std::fs::remove_dir_all(dir);-    }-}--#[cfg(test)]-mod prefs_tests {-    use super::write_prefs;--    #[test]-    fn failed_preferences_write_keeps_the_old_file_and_reports_the_error() {-        let dir = std::env::temp_dir().join(format!("wordcraft-prefs-test-{}", std::process::id()));-        std::fs::create_dir_all(&dir).unwrap();-        let path = dir.join("ui.json");-        std::fs::write(&path, b"old").unwrap();-        write_prefs(&path, b"new").unwrap();-        assert_eq!(std::fs::read(&path).unwrap(), b"new");-        // A directory squatting on the temporary name makes the preparation step fail.-        std::fs::create_dir(dir.join(format!(".ui.json.{}.tmp", std::process::id()))).unwrap();-        assert!(write_prefs(&path, b"newer").is_err());-        assert_eq!(std::fs::read(&path).unwrap(), b"new");-        let _ = std::fs::remove_dir_all(&dir);-    }+    wordcraft::run() }diff --git a/crates/ui-egui/src/file_browser.rs b/crates/ui-egui/src/file_browser.rs--- a/crates/ui-egui/src/file_browser.rs+++ b/crates/ui-egui/src/file_browser.rs@@ -13,7 +13,7 @@ use egui::{Align2, Rect, Sense, Ui, pos2, vec2};  use crate::file_dialogs::{AfterPick, FileDialogRequest, open_filters}; use crate::theme::{Tokens, regular};-use crate::{WordApp, icons};+use crate::{Services, WordApp, icons};  /// At most this many entries are listed; a bigger folder shows the first ones and says so. const MAX_LISTED: usize = 5000;@@ -91,6 +91,39 @@ pub(crate) fn home() -> Option<PathBuf> {     std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).filter(|h| !h.is_empty()).map(PathBuf::from) } +/// "Up one level" from `dir`: the parent while both `dir` and its parent stay inside `root`, else+/// `None` — at the root's top (the button reads as disabled), and for a `dir` the root doesn't+/// contain. `Path::starts_with` compares path components, so a sibling whose name shares the+/// root's text is not inside it.+pub(crate) fn up_from(root: &Path, dir: &Path) -> Option<PathBuf> {+    match dir.parent() {+        Some(parent) if dir.starts_with(root) && parent.starts_with(root) => Some(parent.to_path_buf()),+        _ => None,+    }+}++/// Whether `p` is a folder the browser may open: under the app-private root, or under the shared+/// root when the host offers one.+pub(crate) fn inside_root(p: &Path, app_root: &Path, shared_root: Option<&Path>) -> bool {+    p.starts_with(app_root) || shared_root.is_some_and(|s| p.starts_with(s))+}++/// The roots an Android browser walks (the host's app-private storage, and shared storage when+/// the host offers it): navigation is clamped inside them. The desktop has none —+/// `FileBrowser::roots` is `None` and every folder the user types is fair game, as before.+pub(crate) struct Roots {+    /// The app-private root (`Services::app_files_dir`).+    pub(crate) app: PathBuf,+    /// Shared internal storage once the host offers it (`Services::shared_storage_root`).+    pub(crate) shared: Option<PathBuf>,+}++/// The app storage the browser starts in: its `Documents` folder when it exists, else the root.+fn app_start(roots: &Roots) -> PathBuf {+    let documents = roots.app.join("Documents");+    if documents.is_dir() { documents } else { roots.app.clone() }+}+ /// What the user typed in the file name field, as a path: relative to `dir`, absolute, or under /// the home folder (`~/…`). `None` when nothing was typed. pub(crate) fn resolve(dir: &Path, typed: &str) -> Option<PathBuf> {@@ -122,6 +155,9 @@ enum Note {     None,     NotFound(String),     Replace(PathBuf),+    /// Shared storage needs Android's "All files access" before it can be browsed; the ask+    /// opened the system page.+    Grant, }  /// The open browser.@@ -130,6 +166,8 @@ pub(crate) struct FileBrowser {     pub(crate) after: AfterPick,     /// Opened because the system dialog couldn't be shown: say why, and how to get it.     unavailable: bool,+    /// The roots navigation is clamped to (Android); `None` on the desktop, as before.+    roots: Option<Roots>,     dir: PathBuf,     /// The folder field (edited by the user, applied on Enter).     location: String,@@ -146,7 +184,7 @@ pub(crate) struct FileBrowser { }  impl FileBrowser {-    pub(crate) fn new(req: FileDialogRequest, after: AfterPick, start: PathBuf, unavailable: bool) -> Self {+    pub(crate) fn new(req: FileDialogRequest, after: AfterPick, start: PathBuf, unavailable: bool, roots: Option<Roots>) -> Self {         let (name, filters) = match &req {             FileDialogRequest::Open { purpose } | FileDialogRequest::OpenMany { purpose } => {                 (String::new(), open_filters(purpose).iter().map(|(l, e)| ((*l).to_string(), e.iter().map(|x| (*x).to_string()).collect())).collect())@@ -162,6 +200,7 @@ impl FileBrowser {             req,             after,             unavailable,+            roots,             dir: start.clone(),             location: String::new(),             name,@@ -180,8 +219,10 @@ impl FileBrowser {         self.filters.get(self.filter).map_or(&[], |(_, e)| e.as_slice())     } -    /// Show the folder `dir` (an unreadable folder shows why, and Up still works).+    /// Show the folder `dir` (an unreadable folder shows why, and Up still works). On Android a+    /// folder outside the browser's roots lands on the shown root's top instead.     pub(crate) fn navigate(&mut self, dir: PathBuf) {+        let dir = self.clamped(dir);         self.listing = list_dir(&dir, self.exts(), MAX_LISTED).map_err(|e| e.to_string());         self.location = dir.to_string_lossy().into_owned();         self.dir = dir;@@ -189,12 +230,51 @@ impl FileBrowser {         self.note = Note::None;     } +    /// The folder actually shown for `dir`: itself, or — when the browser is clamped and `dir`+    /// is outside its roots (a typed path, a jump) — the root the browser was in.+    fn clamped(&self, dir: PathBuf) -> PathBuf {+        let Some(roots) = &self.roots else { return dir };+        if inside_root(&dir, &roots.app, roots.shared.as_deref()) {+            return dir;+        }+        match roots.shared.as_deref() {+            Some(s) if self.dir.starts_with(s) => s.to_path_buf(),+            _ => roots.app.clone(),+        }+    }++    /// Where "Up one level" goes: the parent, clamped to the shown root on Android; `None` when+    /// there is nowhere up to go (the root's top, or no parent on the desktop).+    pub(crate) fn up_target(&self) -> Option<PathBuf> {+        let Some(roots) = &self.roots else { return self.dir.parent().map(Path::to_path_buf) };+        let root: &Path = match roots.shared.as_deref() {+            Some(s) if self.dir.starts_with(s) => s,+            _ => &roots.app,+        };+        up_from(root, &self.dir)+    }+     fn up(&mut self) {-        if let Some(parent) = self.dir.parent().map(Path::to_path_buf) {+        if let Some(parent) = self.up_target() {             self.navigate(parent);         }     } +    /// The app-storage start (its `Documents` when it exists): the switcher's and the home+    /// button's target on Android.+    fn app_start_target(&self) -> Option<PathBuf> {+        self.roots.as_ref().map(app_start)+    }++    /// Where the home-folder button goes: the user's home on the desktop; on Android the app+    /// storage the browser starts in.+    fn home_target(&self) -> Option<PathBuf> {+        if self.roots.is_some() {+            return self.app_start_target();+        }+        home()+    }+     /// OK: a highlighted folder, or a typed folder, is opened; otherwise the typed name is the     /// pick. Save adds the chosen type's extension to a name without one and asks once more     /// before replacing a file; Open wants a file that exists.@@ -204,6 +284,12 @@ impl FileBrowser {             return Step::Stay;         }         let Some(mut path) = resolve(&self.dir, &self.name) else { return Step::Stay };+        // Android: a typed pick outside the browser's roots (…/, an absolute path) is not one+        // this dialog may take.+        if self.roots.as_ref().is_some_and(|r| !inside_root(&path, &r.app, r.shared.as_deref())) {+            self.note = Note::NotFound(self.name.trim().to_string());+            return Step::Stay;+        }         if path.is_dir() {             self.name.clear();             self.navigate(path);@@ -264,13 +350,17 @@ impl WordApp {             FileDialogRequest::Save { dir: Some(d), .. } => Some(PathBuf::from(d)).filter(|d| d.is_dir()),             _ => None,         };+        // Android: navigation is clamped to the host's app-private storage (and shared storage,+        // once the host offers it); the desktop clamps nothing, as before.+        let roots = self.services.app_files_dir.as_ref().map(|app| Roots { app: app.clone(), shared: self.services.shared_storage_root.clone() });         let start = asked             .or_else(|| self.file_browser.last_dir.clone().filter(|d| d.is_dir()))             .or_else(|| self.session.path.as_ref().and_then(|p| p.parent()).map(Path::to_path_buf).filter(|d| d.is_dir()))+            .or_else(|| roots.as_ref().map(app_start).filter(|d| d.is_dir()))             .or_else(home)             .or_else(|| std::env::current_dir().ok())             .unwrap_or_else(|| PathBuf::from("/"));-        self.file_browser.open = Some(Box::new(FileBrowser::new(req, after, start, unavailable)));+        self.file_browser.open = Some(Box::new(FileBrowser::new(req, after, start, unavailable, roots)));     }      /// `ui.fileBrowser`: the built-in browser for `purpose` (document, picture, recipients, save),@@ -316,7 +406,7 @@ pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) {         .anchor(Align2::CENTER_CENTER, vec2(0.0, -20.0));     let shown = crate::window_close::window(ctx, window).show(ctx, |ui| {         ui.set_width(620.0);-        done = body(ui, &mut b, &t);+        done = body(ui, &mut b, &t, &app.services);     });     if shown.is_some_and(|s| crate::window_close::show(ctx, &s.response, &t)) {         done = Some(None);@@ -327,8 +417,9 @@ pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) {     } } -/// The browser's contents; `Some` once it closes (`Some(None)`: cancelled).-fn body(ui: &mut Ui, b: &mut FileBrowser, t: &Tokens) -> Option<Option<PathBuf>> {+/// The browser's contents; `Some` once it closes (`Some(None)`: cancelled). `s` is only read for+/// the shared-storage grant hooks (Android; the desktop draws none of it).+fn body(ui: &mut Ui, b: &mut FileBrowser, t: &Tokens, s: &Services) -> Option<Option<PathBuf>> {     if b.unavailable {         ui.label(             egui::RichText::new(tl!(@@ -340,11 +431,11 @@ fn body(ui: &mut Ui, b: &mut FileBrowser, t: &Tokens) -> Option<Option<PathBuf>>     }     let mut location_done = false;     ui.horizontal(|ui| {-        if icon_button(ui, t, "chevronUp", tl!("Up one level"), b.dir.parent().is_some()) {+        if icon_button(ui, t, "chevronUp", tl!("Up one level"), b.up_target().is_some()) {             b.up();         }         if icon_button(ui, t, "homeFolder", tl!("Home folder"), true)-            && let Some(h) = home()+            && let Some(h) = b.home_target()         {             b.navigate(h);         }@@ -358,6 +449,32 @@ fn body(ui: &mut Ui, b: &mut FileBrowser, t: &Tokens) -> Option<Option<PathBuf>>         }     });     ui.add_space(4.0);+    // Android's second root: the switcher between the app-private tree and shared storage,+    // offered only while the host offers shared storage (the desktop draws nothing here).+    if b.roots.as_ref().is_some_and(|r| r.shared.is_some()) {+        let shared: PathBuf = b.roots.as_ref().and_then(|r| r.shared.clone()).unwrap_or_default();+        let in_shared = b.dir.starts_with(&shared);+        ui.horizontal(|ui| {+            if ui.selectable_label(!in_shared, tl!("App files")).clicked()+                && in_shared+                && let Some(start) = b.app_start_target()+            {+                b.navigate(start);+            }+            if ui.selectable_label(in_shared, tl!("Shared storage")).clicked() && !in_shared {+                let granted = s.shared_storage_granted.as_ref().map(|f| f()).unwrap_or(false);+                if granted {+                    b.navigate(shared.clone());+                } else {+                    if let Some(ask) = &s.shared_storage_grant {+                        ask();+                    }+                    b.note = Note::Grant;+                }+            }+        });+        ui.add_space(4.0);+    }     let mut pick = false;     let mut go: Option<PathBuf> = None;     let mut clicked: Option<Entry> = None;@@ -409,6 +526,10 @@ fn body(ui: &mut Ui, b: &mut FileBrowser, t: &Tokens) -> Option<Option<PathBuf>>         b.navigate(dir);     }     let message = match (&b.listing, &b.note) {+        (_, Note::Grant) => Some((+            tl!("To browse shared storage, allow WordCraft all-files access in Android's settings, then choose Shared storage again.").to_string(),+            t.text_dim,+        )),         (_, Note::NotFound(name)) => Some((crate::i18n::fmt(tl!("{name} wasn't found."), &[("name", name)]), t.red)),         (_, Note::Replace(p)) => {             let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();@@ -535,7 +656,7 @@ mod tests {         std::fs::create_dir(dir.join("drafts")).unwrap();         std::fs::write(dir.join("drafts").join("old.docx"), b"x").unwrap();         let req = FileDialogRequest::Save { name: "Document1.docx".into(), dir: None };-        let mut b = FileBrowser::new(req, AfterPick::OpenDocument { document: 0 }, dir.clone(), false);+        let mut b = FileBrowser::new(req, AfterPick::OpenDocument { document: 0 }, dir.clone(), false, None);         b.name = "drafts".into();         assert_eq!(b.accept(), Step::Stay);         assert_eq!(b.dir, dir.join("drafts"));@@ -577,14 +698,78 @@ mod tests {         let _ = std::fs::remove_dir_all(&dir);     } +    /// "Up one level" stays inside the shown root: the parent while it is still under the root,+    /// and none at the root's top (the button then reads as disabled).+    #[test]+    fn up_from_stops_at_the_root() {+        let root = Path::new("/data/user/0/ai.storyteller.wordcraft/files");+        assert_eq!(up_from(root, &root.join("Documents").join("drafts")), Some(root.join("Documents")));+        assert_eq!(up_from(root, &root.join("Documents")), Some(root.to_path_buf()));+        assert_eq!(up_from(root, root), None, "the root's top has no Up");+        // a root that isn't a parent of dir (defensive): no Up rather than an escape+        assert_eq!(up_from(root, Path::new("/storage/emulated/0")), None);+    }++    /// A folder this dialog may open is under the app-private root or the shared root; a path+    /// that merely starts with the same text but is a sibling directory is not inside.+    #[test]+    fn inside_root_accepts_only_the_roots() {+        let app = Path::new("/data/user/0/ai.storyteller.wordcraft/files");+        let shared = Path::new("/storage/emulated/0");+        assert!(inside_root(&app.join("Documents"), app, None));+        assert!(inside_root(app, app, None), "the root itself is a folder the dialog may show");+        assert!(!inside_root(Path::new("/data/user/0/ai.storyteller.wordcraft/other"), app, None));+        assert!(!inside_root(Path::new("/data/user/0/ai.storyteller.wordcraft/files-x"), app, None), "a sibling that shares a prefix");+        assert!(inside_root(&shared.join("Download"), app, Some(shared)));+        assert!(!inside_root(Path::new("/etc"), app, Some(shared)));+    }++    /// Android (no system dialog hook, the host's app-private storage recorded): Save As serves+    /// the request in the built-in browser over that storage instead of answering none — and the+    /// browser is clamped to the host's roots.+    #[test]+    fn app_files_dir_serves_the_dialog_in_the_built_in_browser() {+        let base = scratch("android-route");+        std::fs::create_dir_all(base.join("Documents")).unwrap();+        let services = Services { app_files_dir: Some(base.clone()), ..Default::default() };+        let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), services);+        a.run("text.insert", json!({"text": "Saved on the phone"})).unwrap();+        a.save_as_dialog();+        let b = a.file_browser.open.as_mut().expect("the built-in browser serves the request");+        assert_eq!(b.roots.as_ref().map(|r| r.app.clone()), Some(base.clone()), "clamped to the host's roots");+        assert!(!b.unavailable, "the browser is not a broken-portal fallback here");+        assert_eq!(b.dir, base.join("Documents"), "starts in the phone's Documents");+        b.name = "novel".into();+        let Step::Pick(path) = b.accept() else { panic!("picked") };+        a.close_file_browser(Some(path));+        assert!(!a.session.dirty);+        let saved = wordcraft_engine::io::open_path(&base.join("Documents").join("novel.docx")).unwrap();+        assert!(saved.plain_text(wordcraft_doc::StoryRef::Body).contains("Saved on the phone"));+        let _ = std::fs::remove_dir_all(&base);+    }++    /// No host hooks at all (a bare desktop test run): Save As keeps today's behaviour — no+    /// browser opens, the pick answers none.+    #[test]+    fn no_hooks_at_all_still_answers_none_without_a_browser() {+        let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());+        a.save_as_dialog();+        assert!(a.file_browser.open.is_none(), "no browser without a host's roots");+    }+     /// #3037: an empty folder (or a type filter that matches nothing) says so instead of a blank     /// box; a matching file replaces the hint.     #[test]     fn an_empty_folder_says_there_are_no_items() {         let dir = scratch("empty_state");         let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());-        let mut b =-            FileBrowser::new(FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument { document: 0 }, dir.clone(), false);+        let mut b = FileBrowser::new(+            FileDialogRequest::Open { purpose: "document".into() },+            AfterPick::OpenDocument { document: 0 },+            dir.clone(),+            false,+            None,+        );         b.navigate(dir.clone());         assert!(matches!(&b.listing, Ok(l) if l.entries.is_empty()), "the folder starts empty");         a.file_browser.open = Some(Box::new(b));diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs--- a/crates/ui-egui/src/file_dialogs.rs+++ b/crates/ui-egui/src/file_dialogs.rs@@ -142,6 +142,14 @@ impl WordApp {             // A host that answered at once (or failed to show anything) is handled right away.             return self.poll_file_dialog().map_or(Asked::Pending, Asked::Done);         }+        // No system dialog hook (Android): the built-in browser serves the request over the+        // host's app-private storage (`Services::app_files_dir`) instead of answering none. The+        // browser is a native feature (web keeps its own flow), hence the gate.+        #[cfg(not(target_arch = "wasm32"))]+        if self.services.app_files_dir.is_some() {+            self.show_file_browser(req, after, false);+            return Asked::Pending;+        }         let picked = match &req {             FileDialogRequest::Open { purpose } | FileDialogRequest::OpenMany { purpose } => {                 self.services.pick_open.as_ref().and_then(|f| f(purpose))diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -117,6 +117,26 @@ pub struct Services {     /// sender counts as cancelled. An `Err` says no system dialog could be shown, and the app shows     /// its built-in file browser instead (#576). The app shows one dialog at a time ([`file_dialogs`]).     pub file_dialog: Option<Box<dyn Fn(FileDialogRequest) -> std::sync::mpsc::Receiver<FileDialogAnswer>>>,+    /// Android (and any host with no system picker): the app-private storage directory the+    /// in-app browser may reach. When set and [`Self::file_dialog`] is `None`,+    /// [`WordApp::ask_file`] serves every request in the built-in+    /// [`file_browser`](crate::file_browser) instead of answering none (cancelled).+    pub app_files_dir: Option<std::path::PathBuf>,+    /// Android's shared internal-storage root (`/storage/emulated/0`): the browser's **second**+    /// root, offered as a switcher beside `app_files_dir` only while this is set — only the+    /// Android build sets it. Navigating it still needs the grant below: without it the switcher+    /// asks for the grant instead of going there. `None` on the desktop and the web, where no+    /// switcher is drawn at all.+    pub shared_storage_root: Option<std::path::PathBuf>,+    /// Whether Android's "All files access" (`MANAGE_EXTERNAL_STORAGE`) holds right now, checked+    /// whenever the browser is about to enter the shared root — the settings page the user is+    /// sent to backgrounds the app, so the next tap re-checks without a callback. `None`+    /// elsewhere (and when no JVM was reachable): shared storage then reads as not granted.+    pub shared_storage_granted: Option<Box<dyn Fn() -> bool>>,+    /// Asks Android for "All files access": its system settings page on Android 11+, the+    /// ordinary permission dialog before that. Best effort — a build or device that cannot ask+    /// says so in the browser and stays where it is. `None` elsewhere.+    pub shared_storage_grant: Option<Box<dyn Fn()>>,     /// Web: open a file picker; the file arrives later through `inbox`.     pub open_async: Option<Box<dyn Fn(&str)>>,     /// Web: files (name, bytes) delivered asynchronously (picker, drag and drop).diff --git a/docs/architecture.md b/docs/architecture.md--- a/docs/architecture.md+++ b/docs/architecture.md@@ -1,6 +1,6 @@ # WordCraft architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (`proof` is per language: English and Polish; previously major: first committed version; `plan/architecture.md` is local-only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Android: host-provided storage serves every file command in the built-in browser; previously minor: the app is a library and `wordcraft-android` is the NativeActivity entry crate) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4  How WordCraft is built today (origin/main `40c5d51`). The longer design notes in `plan/architecture.md` are gitignored and local to the original author's machine; this file is the@@ -8,7 +8,7 @@ committed, current summary. Rules for working in the code are in [`AGENTS.md`](.  ## Layers -~85,600 lines of Rust, 15 crates, 3 apps and `xtask`. Layering is enforced by `cargo xtask layers`:+~85,600 lines of Rust, 15 crates, 4 apps and `xtask`. Layering is enforced by `cargo xtask layers`: a crate may only depend on crates in lower layers, and nothing below L6 depends on egui, eframe, winit or rfd. @@ -29,9 +29,18 @@ winit or rfd. | L4 | `control-key` | 0.1k | Per-window control-port key files shared by the app and the MCP bridge | | L5 | `mcp` | 1.0k | MCP server: every command as a tool; headless or bridged to the running app | | L6 | `ui-egui` | 13.1k | Word-style front end: ribbon, keytips, mini-toolbar, Backstage, canvas, rulers, panes, dialogs, Equation tab, i18n catalogs, themes, control channel, headless `ui_shot` example |-| apps | `wordcraft` | 1.6k | Desktop app (eframe + wgpu; logging, window geometry, file associations) |+| apps | `wordcraft` | 1.6k | Desktop app (library + binary; eframe + wgpu; logging, window geometry, file associations) | | apps | `wordcraft-cli` | 0.4k | `convert`, `text`, `inspect`, `run`, `commands`, `parity`, `mcp`, `zotero` | | apps | `wordcraft-web` | 0.3k | Browser build (eframe web runner, WebGPU with WebGL2 fallback) |+| apps | `wordcraft-android` | 0.03k | Android build (NativeActivity entry, `cargo apk`): opens the shared app library; settings resolve to the app-private storage |++`apps/wordcraft-android` is a `cdylib` trampoline: Android's `NativeActivity` loads it and calls its+unmangled `android_main`, which opens the shared app library (`wordcraft::run_android`, the same code+the desktop binary runs). The unmangled export is why that crate does not inherit the workspace+lints: edition 2024 spells it `#[unsafe(no_mangle)]` and there is no safe spelling of a required+export symbol, so the crate carries the workspace's clippy denials itself and holds no unsafe code.+Its manifest (`package.metadata.android`) carries the APK identity, the SDK levels and the activity's+`configChanges`; the app-private storage it resolves hands the shared library its settings folder.  ## Data flow @@ -48,6 +57,29 @@ winit or rfd.    an egui/wgpu canvas; PDF export walks the same display list through `pdf`. 5. **Save:** `engine::io` picks a writer by extension. +## Host-provided storage (Android)++A host with no system file picker tells the app where its files live through [`Services`](#layers)+instead of a `cfg` in the dialogs: with `app_files_dir` set, every file command — Open, Save As,+Export, Insert Picture, recipients, Save as Template — is served by the built-in+[`file_browser`](../crates/ui-egui/src/file_browser.rs) (the #576 browser the desktop already had),+which opens on the frame the command asks and answers exactly as the system dialog would. The+browser's navigation is clamped inside the host's roots: *Up one level* stops at the root it is in,+the home-folder button goes to the app's `Documents` folder, and a typed pick outside the roots is+refused as not found. The shared-storage switcher (a second root beside the app's own, entered only+while the host's grant hooks say access is held) is drawn only while a host offers one — the+Android build starts offering it with the "All files access" hooks in the next step of the series.++The Android build splits its app-private data directory once (`apps/wordcraft/src/android_storage.rs`):++```text+<files>/Documents/  where the browser starts and saves land+<files>/config/     the settings folder: preferences, logs, the user dictionary, user fonts,+                    templates, recovery and the control key+```++A host with a system picker (desktop, web) sets nothing and behaves exactly as before.+ ## Testing and gates  - 821 `#[test]` functions and 10 property-test blocks: model, layout, DOCX round-trip and hostile@@ -64,5 +96,7 @@ winit or rfd.  | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | Android: `Services::app_files_dir` serves every file command in the built-in file browser, clamped to the host's roots (shared storage waits for the grant hooks); the app-private storage splits into `Documents/` and `config/` |+| 2026-10-26 | minor | Android groundwork: `apps/wordcraft` is a library (`wordcraft::run`/`run_android`) with the desktop binary as a shim, and `apps/wordcraft-android` is the NativeActivity entry crate whose app-private storage resolves the settings folder | | 2026-10-10 | minor | `proof`: proofing languages (English, Polish), affix engine, Polish patterns; layout and engine route each run by its language | | 2026-10-10 | major | First committed architecture doc, from the crates' sources and `AGENTS.md` |diff --git a/docs/control-protocol.md b/docs/control-protocol.md--- a/docs/control-protocol.md+++ b/docs/control-protocol.md@@ -54,7 +54,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key.  | Method | Params | Result | |---|---|---|-| `engine.execute` | `command`, `params`, `doc?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) |+| `engine.execute` | `command`, `params`, `doc?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop and Android: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser — on Android clamped to the app's storage roots — for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | | `document.inspect` | `text?`, `doc?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages | | `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf; `document`: the active document's id; `documents`: the open documents (`id`, `title`, `path`, `dirty`, `active`) |diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs--- a/xtask/src/layers.rs+++ b/xtask/src/layers.rs@@ -54,6 +54,7 @@ pub const TABLE: &[(&str, Class)] = &[     ("wordcraft", Class::Exempt),     ("cli", Class::Exempt),     ("web", Class::Exempt),+    ("android", Class::Exempt),     ("xtask", Class::Exempt), ]; @@ -311,7 +312,7 @@ mod tests {      #[test]     fn apps_and_xtask_exempt() {-        for app in ["wordcraft", "wordcraft-cli", "wordcraft-web", "xtask"] {+        for app in ["wordcraft", "wordcraft-cli", "wordcraft-web", "wordcraft-android", "xtask"] {             assert!(check(&[c(app, &[("egui", Normal, false), ("wordcraft-ui-egui", Normal, true)])]).is_empty());         }     }