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.rs
new 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) |
 
