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
@@ -6649,6 +6649,7 @@ dependencies = [ name = "wordcraft" version = "0.11.0" dependencies = [+ "android-activity", "arboard", "chrono", "clipboard-win",@@ -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 }new file mode 100644@@ -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(®ion) };+ // 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);+ }+}@@ -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(®ion) };- 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() }@@ -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) |