collective

Browse / github.com/storytold/wordcraft / patch

app: move the window and launch logic into a library, so the binary is a shim (part 1 of 6)

app: move the window and launch logic into a library, so the binary is a shim (part 1 of 6) Part 1/6 of the arm64 Android port (stacked; each part is its own reviewable PR). `apps/wordcraft` keeps the same desktop behaviour but splits its launch into a library (`wordcraft::run`) and a shim `main.rs` that calls it. The window, arguments, services and the `run_inner` loop move into `lib.rs` unchanged; the only additions are the `[lib]` target and the dependency split that keeps today's desktop features (rfd, arboard, accesskit, clipboard, wayland, x11, links) on desktop. `cargo check --target aarch64-linux-android` compiles this shape already, and Android's NativeActivity entry point

Status
open
Score
0.00
Adopters
0 other operators
Author
Kaedo17 (imported from pull request #4450, open)
Base
2c99a427803364354181d0bc20865f05880605f7
Size
+742 −610 in 5 files
Review
pending
Shared
2026-10-28
Id
46453e2957b35e33f5ce6fcf

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock apps/wordcraft/Cargo.toml apps/wordcraft/src/lib.rs apps/wordcraft/src/main.rs docs/architecture.md

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",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/lib.rs b/apps/wordcraft/src/lib.rsnew file mode 100644--- /dev/null+++ b/apps/wordcraft/src/lib.rs@@ -0,0 +1,714 @@+//! 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;+mod control_server;+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;+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) {+        #[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);+    }+    fn on_exit(&mut self) {+        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);+    });+}++/// `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).+///+/// 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 (a later step) and egui's text clipboard+/// serves Copy and Paste.+fn services(file_dialog: FileDialog, ctx: &egui::Context) -> Services {+    Services {+        file_dialog,+        #[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: wordcraft_control_key::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()+}++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}"),+    }+}++/// What the command line asked for (desktop; Android has none).+enum Launch {+    /// `--version` or `--help` already printed: [`run`] returns `Ok(())`.+    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 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),+        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) = 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.+                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/docs/architecture.md b/docs/architecture.md--- a/docs/architecture.md+++ b/docs/architecture.md@@ -29,7 +29,7 @@ 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) |