diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1174,6 +1174,7 @@ dependencies = [
 name = "effectcraft"
 version = "0.16.0"
 dependencies = [
+ "android-activity",
  "cpal",
  "effectcraft-engine",
  "effectcraft-host",
diff --git a/apps/effectcraft/Cargo.toml b/apps/effectcraft/Cargo.toml
--- a/apps/effectcraft/Cargo.toml
+++ b/apps/effectcraft/Cargo.toml
@@ -6,6 +6,10 @@ edition.workspace = true
 license.workspace = true
 default-run = "effectcraft"
 
+[lib]
+# The desktop binary and Android's entry shim (apps/effectcraft-android) share this library.
+crate-type = ["rlib"]
+
 [lints]
 workspace = true
 
@@ -15,17 +19,29 @@ effectcraft-host = { workspace = true }
 effectcraft-ui-egui = { workspace = true }
 egui = { workspace = true }
 # `links`: links (Help menu, Discord, Settings) open in the system browser.
-eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] }
+eframe = { workspace = true, features = ["default_fonts", "wgpu", "links"] }
 serde = { workspace = true }
 serde_json = { workspace = true }
 # The window's monitors, size and position (src/window.rs), and macOS event-loop options (no app
 # activation when driven by an agent). The same winit eframe runs on.
 winit = { version = "0.30", default-features = false }
-rfd = { workspace = true }
 pollster = { workspace = true }
 cpal = { workspace = true }
 log = { workspace = true }
 
+[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"] }
+[target.'cfg(not(target_os = "android"))'.dependencies]
+# File and message dialogs. rfd has no Android backend (it doesn't compile there), so
+# Android builds leave the file hooks empty and the commands fail with
+# "no file dialog available".
+rfd = { workspace = true }
+# Screen-reader support and the desktop windowing backends. eframe 0.36 only offers
+# `accesskit` with `android-game-activity`, and Wayland/X11 don't exist on Android.
+eframe = { workspace = true, features = ["accesskit", "wayland", "x11"] }
+
 [target.'cfg(target_os = "linux")'.dependencies]
 # Settings ▸ Appearance ▸ Sync with System: the XDG desktop portal's colour scheme. Already in
 # Cargo.lock (accesskit, through eframe), so it adds no new crates.
diff --git a/apps/effectcraft/src/android_storage.rs b/apps/effectcraft/src/android_storage.rs
new file mode 100644
--- /dev/null
+++ b/apps/effectcraft/src/android_storage.rs
@@ -0,0 +1,62 @@
+//! Android's app-private storage: `Projects/`, `Exports/` and `config/` below the
+//! `AndroidApp::internal_data_path` directory.
+//!
+//! Android has no system file picker in v1 (rfd has no backend there), so saves, render outputs
+//! and settings default to private storage: no permissions, no Storage Access Framework
+//! round-trip, though invisible to the Files app (the in-app browser can additionally reach
+//! shared storage once "All files access" is granted). Pure path logic, unit-tested on the host;
+//! the handle comes from `run_android` (`apps/effectcraft/src/lib.rs`).
+
+use std::path::{Path, PathBuf};
+
+/// The storage folders below the app-private base directory.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Layout {
+    /// Saved `.ecproj`/`.ecprojx` projects (the Open Project prompt lists these).
+    pub projects: PathBuf,
+    /// Render output, frame stills and other saved files.
+    pub exports: PathBuf,
+    /// Settings, auto-save and crash-recovery state (`session.config`).
+    pub config: PathBuf,
+}
+
+/// Split the app-private `base` into its folders.
+pub fn layout(base: &Path) -> Layout {
+    Layout { projects: base.join("Projects"), exports: base.join("Exports"), config: base.join("config") }
+}
+
+/// Create the folders (`Err` names the first failure; the app then keeps that feature off
+/// rather than crashing).
+pub fn ensure(layout: &Layout) -> Result<(), String> {
+    for dir in [&layout.projects, &layout.exports, &layout.config] {
+        std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.to_string_lossy()))?;
+    }
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn temp_base(tag: &str) -> PathBuf {
+        std::env::temp_dir().join(format!("ec-android-storage-{tag}-{}", std::process::id()))
+    }
+
+    #[test]
+    fn layout_splits_the_base_into_projects_exports_and_config() {
+        let base = Path::new("/data/data/ai.storyteller.effectcraft/files");
+        assert_eq!(layout(base), Layout { projects: base.join("Projects"), exports: base.join("Exports"), config: base.join("config") });
+    }
+
+    #[test]
+    fn ensure_creates_every_folder() {
+        let base = temp_base("ok");
+        let _ = std::fs::remove_dir_all(&base);
+        let l = layout(&base);
+        ensure(&l).unwrap();
+        assert!(l.projects.is_dir() && l.exports.is_dir() && l.config.is_dir());
+        // Re-running over existing folders is fine.
+        ensure(&l).unwrap();
+        let _ = std::fs::remove_dir_all(&base);
+    }
+}
diff --git a/apps/effectcraft/src/audio_out.rs b/apps/effectcraft/src/audio_out.rs
--- a/apps/effectcraft/src/audio_out.rs
+++ b/apps/effectcraft/src/audio_out.rs
@@ -7,7 +7,7 @@
 //! channels: silence), converting to the device's sample format.
 
 use std::sync::Arc;
-use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
 use std::sync::mpsc;
 use std::thread::JoinHandle;
 use std::time::Duration;
@@ -22,6 +22,24 @@ fn preferred_buffer_frames(rate: u32) -> u32 {
     (rate / 100).clamp(256, 2048)
 }
 
+/// Frames between "handed over" and "audible": the device's output latency plus one callback's
+/// buffer. `dev_lat` is `playback − callback` from the stream timestamps, trusted only while it
+/// can be a real output latency: cpal's AAudio backend (Android) reports `playback` on
+/// CLOCK_MONOTONIC (absolute, since boot) but `callback` since the stream was created, so their
+/// difference is the device's uptime. An uptime-sized latency holds the preview's audio clock
+/// behind the frames the device consumed — the picture freezes on the first frame while the
+/// sound plays on. A value no device outputs is dropped (the buffer alone still counts).
+fn sync_latency_frames(dev_lat: f64, rate: f64, buffer_frames: usize) -> u64 {
+    const MAX_LATENCY_SECS: f64 = 1.0;
+    static WARNED: AtomicBool = AtomicBool::new(false);
+    let plausible = dev_lat.is_finite() && (0.0..=MAX_LATENCY_SECS).contains(&dev_lat);
+    if !plausible && !WARNED.swap(true, Ordering::Relaxed) {
+        log::warn!("audio output: implausible device latency {dev_lat}s; syncing A/V to this buffer alone");
+    }
+    let secs = if plausible { dev_lat } else { 0.0 };
+    (secs * rate) as u64 + buffer_frames as u64
+}
+
 /// Wait until the feeder has mixed a block, or until preview is stopped. Starting the stream
 /// before that fills the device with silence, so the first real samples are heard late (#554).
 fn wait_to_play(feed: &AudioFeed, stop: &mpsc::Receiver<()>) -> bool {
@@ -115,7 +133,7 @@ fn run<T: SizedSample + FromSample<f32>>(
             // Frames between "handed over" and "audible": device latency plus this buffer.
             let ts = info.timestamp();
             let dev_lat = ts.playback.duration_since(&ts.callback).map(|d| d.as_secs_f64()).unwrap_or(0.0);
-            latency.store((dev_lat * rate) as u64 + frames as u64, Ordering::Relaxed);
+            latency.store(sync_latency_frames(dev_lat, rate, frames), Ordering::Relaxed);
         },
         |e| log::warn!("audio output: {e}"),
         None,
@@ -230,6 +248,28 @@ mod tests {
         assert_eq!(preferred_buffer_frames(192_000), 1920);
     }
 
+    /// Real device latencies (ALSA/Pulse/PipeWire report a few to a few hundred milliseconds)
+    /// are used as they are, plus the callback's own buffer.
+    #[test]
+    fn sync_latency_keeps_a_plausible_device_latency() {
+        assert_eq!(sync_latency_frames(0.02, 48_000.0, 480), 960 + 480);
+        assert_eq!(sync_latency_frames(0.0, 48_000.0, 480), 480);
+        assert_eq!(sync_latency_frames(0.3, 44_100.0, 441), 13_230 + 441);
+    }
+
+    /// cpal's AAudio backend (Android) subtracts two different clocks — `playback` is
+    /// CLOCK_MONOTONIC since boot, `callback` since the stream was created — so the difference
+    /// is the tablet's uptime. That must not become the A/V-sync latency: pinned there, the
+    /// preview's clock never leaves its first frame (picture frozen while the sound plays).
+    #[test]
+    fn sync_latency_drops_an_uptime_sized_device_latency() {
+        // A tablet up ~5 days at 48 kHz would otherwise ask for ~2e10 frames of latency.
+        assert_eq!(sync_latency_frames(432_000.0, 48_000.0, 480), 480);
+        assert_eq!(sync_latency_frames(f64::INFINITY, 48_000.0, 480), 480);
+        assert_eq!(sync_latency_frames(f64::NAN, 48_000.0, 480), 480);
+        assert_eq!(sync_latency_frames(-0.5, 48_000.0, 480), 480);
+    }
+
     #[test]
     fn wait_to_play_returns_once_the_feeder_has_samples() {
         let feed = Arc::new(AudioFeed::default());
diff --git a/apps/effectcraft/src/lib.rs b/apps/effectcraft/src/lib.rs
new file mode 100644
--- /dev/null
+++ b/apps/effectcraft/src/lib.rs
@@ -0,0 +1,779 @@
+//! EffectCraft app library: the window, hooks and launch logic shared by the desktop
+//! binary (`main.rs`, whose `main` calls [`run`]) and Android's NativeActivity entry point
+//! (`apps/effectcraft-android`, whose `android_main` calls `run_android`).
+//!
+//! Usage: `effectcraft [--control <port>] [--demo|--empty|--home] [--] [project.ecproj | media files…]`
+//!
+//! An unknown option or a bad port prints an error and exits with status 1 before anything starts.
+//!
+//! Without a project the app starts with an empty Untitled Project, as After Effects does;
+//! `--demo` opens the demo project instead (also Help ▸ Open Demo Project).
+//!
+//! `--control <port>` (or `EFFECTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
+//! see `effectcraft_ui_egui::control` for the methods.
+//!
+//! On Android there are no command-line arguments, so the app opens an empty Untitled Project.
+//! There is no system file picker (rfd has no Android backend): Save, Open Project, exports,
+//! folder picks and media import are served by the in-app browser instead. It browses the
+//! app-private storage (`Projects/`, `Exports/`) always, and shared storage
+//! (`/storage/emulated/0`) too once the user grants "All files access" — the grant is checked
+//! and requested over JNI by the entry crate (`effectcraft-android`), which opts out of this
+//! workspace's `unsafe_code = "forbid"`; this crate stays unsafe-free and only receives
+//! [`SharedStorageAccess`] as plain closures.
+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+
+// Built everywhere so its tests run on every platform; only Android uses it (its callers are
+// cfg(android), so elsewhere these tests are the reason it compiles at all).
+#[cfg_attr(not(target_os = "android"), allow(dead_code))]
+mod android_storage;
+mod appearance;
+// Built everywhere so its tests run on every platform; only Linux AppImages use it.
+#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
+mod appimage;
+#[cfg(target_os = "macos")]
+mod apple_events;
+mod audio_out;
+mod control_server;
+mod launch_guard;
+#[cfg(target_os = "macos")]
+mod native_menu;
+// Built everywhere so it stays checked; the macOS and Linux apps use it. Android has no rfd
+// at all (no backend there), so it isn't built for Android.
+#[cfg(not(target_os = "android"))]
+#[cfg_attr(not(any(target_os = "macos", target_os = "linux")), allow(dead_code))]
+mod sheet_dialogs;
+mod window;
+
+use effectcraft_ui_egui::EffectcraftApp;
+use serde_json::json;
+
+/// The app icon for the window, taskbar and Dock (assets/app-icon, regenerated by
+/// `packaging/icons.sh`). macOS gets the padded variant so it sits on Apple's icon grid in the Dock.
+/// The 256 px app icon (Windows, Linux; the AppImage's desktop integration installs it too).
+#[cfg(not(target_os = "macos"))]
+const ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png");
+
+fn app_icon() -> egui::IconData {
+    #[cfg(target_os = "macos")]
+    let png: &[u8] = include_bytes!("../../../assets/app-icon/effectcraft-macos-512.png");
+    #[cfg(not(target_os = "macos"))]
+    let png: &[u8] = ICON_PNG;
+    eframe::icon_data::from_png_bytes(png).unwrap_or_default()
+}
+
+/// Both native JSON projects and Save a Copy As XML projects are openable on launch.
+/// Keep this classification shared by project opening and the subsequent media-import pass.
+fn is_project_file(path: &str) -> bool {
+    std::path::Path::new(path)
+        .extension()
+        .and_then(|ext| ext.to_str())
+        .is_some_and(|ext| ext.eq_ignore_ascii_case("ecproj") || ext.eq_ignore_ascii_case("ecprojx"))
+}
+
+/// winit 0.30 cannot deliver native Wayland file drops. XWayland can.
+#[cfg(target_os = "linux")]
+fn wayland_drop_notice(wayland_display: Option<&std::ffi::OsStr>, backend: Option<&std::ffi::OsStr>) -> Option<&'static str> {
+    (wayland_display.is_some_and(|v| !v.is_empty()) && backend != Some(std::ffi::OsStr::new("x11")))
+        .then_some("Native Wayland file drops are unavailable. Use File ▸ Import ▸ File… or run via XWayland (see README, Linux).")
+}
+
+/// The command line.
+#[derive(Debug, Default, PartialEq)]
+struct Args {
+    control_port: Option<u16>,
+    files: Vec<String>,
+    /// Open the demo project when no project is given (`--demo`).
+    demo: bool,
+    /// Show or skip the Home screen (`--home`), else the Startup preference decides.
+    home: Option<bool>,
+}
+
+/// Parse the arguments after the program name; `Ok(None)` for `--version`. `env_port` is
+/// `EFFECTCRAFT_CONTROL_PORT`, used when `--control` isn't given. An unknown option or a bad port
+/// is an error, so it is neither imported as a file nor dropped; after `--` every argument is a file.
+fn parse_args(args: impl IntoIterator<Item = String>, env_port: Result<String, std::env::VarError>) -> Result<Option<Args>, String> {
+    let mut out = Args::default();
+    let mut args = args.into_iter();
+    let mut options = true;
+    while let Some(a) = args.next() {
+        if !options {
+            out.files.push(a);
+            continue;
+        }
+        match a.as_str() {
+            "--" => options = false,
+            "--control" => out.control_port = Some(control_port("--control", args.next().as_deref())?),
+            "--demo" => out.demo = true,
+            "--empty" => out.demo = false,
+            "--home" => out.home = Some(true),
+            "--version" => return Ok(None),
+            _ => match a.strip_prefix("--control=") {
+                Some(v) => out.control_port = Some(control_port("--control", Some(v))?),
+                // macOS LaunchServices may pass a process serial number (`-psn_0_1234`), notably on
+                // the first launch of a downloaded app; it is not an option of ours.
+                None if a.starts_with("-psn_") => {}
+                None if a.starts_with('-') && a != "-" => return Err(format!("unknown option `{a}`")),
+                None => out.files.push(a),
+            },
+        }
+    }
+    if out.control_port.is_none() {
+        out.control_port = match env_port {
+            Ok(v) if v.is_empty() => None,
+            Ok(v) => Some(control_port("EFFECTCRAFT_CONTROL_PORT", Some(&v))?),
+            Err(std::env::VarError::NotPresent) => None,
+            Err(std::env::VarError::NotUnicode(v)) => Some(control_port("EFFECTCRAFT_CONTROL_PORT", Some(&v.to_string_lossy()))?),
+        };
+    }
+    Ok(Some(out))
+}
+
+/// A control-channel port from 1 to 65535 (0 would listen on a port nobody is told); `name` is
+/// the flag or variable it came from.
+fn control_port(name: &str, value: Option<&str>) -> Result<u16, String> {
+    let need = format!("{name} needs a port number from 1 to 65535");
+    match value {
+        None => Err(need),
+        Some(v) => v.parse().ok().filter(|&p| p != 0).ok_or(format!("{need}, not `{v}`")),
+    }
+}
+
+/// Open the window and run the app until its window closes. The desktop binary calls this
+/// from `main`; Android's NativeActivity entry point calls [`run_android`] from `android_main`
+/// (which has no arguments, so an empty Untitled Project opens).
+pub fn run() -> eframe::Result {
+    #[cfg(target_os = "android")]
+    {
+        run_inner(None, None)
+    }
+    #[cfg(not(target_os = "android"))]
+    {
+        run_inner()
+    }
+}
+
+/// Android's "All files access" capabilities (`MANAGE_EXTERNAL_STORAGE`), built by the entry
+/// crate — which owns the JNI to the activity's JVM, since this workspace forbids `unsafe` —
+/// and handed to the in-app file browser as hooks: whether shared storage may be browsed, and
+/// how to ask for the grant. `None` when the entry crate could not reach the JVM (the browser
+/// then only ever offers the app-private root).
+#[cfg(target_os = "android")]
+pub struct SharedStorageAccess {
+    /// Whether the grant holds right now (re-checked whenever the browser shows the root).
+    pub granted: Box<dyn Fn() -> bool>,
+    /// Ask for it: the system settings page on Android 11+, the permission dialog before that.
+    pub ask: Box<dyn Fn()>,
+}
+
+/// Android entry point: 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 —
+/// and with the optional shared-storage capabilities [`SharedStorageAccess`] the entry crate
+/// resolves through JNI.
+#[cfg(target_os = "android")]
+pub fn run_android(android_app: android_activity::AndroidApp, access: Option<SharedStorageAccess>) -> eframe::Result {
+    run_inner(Some(android_app), access)
+}
+
+/// Shared startup; `android_app` is the NativeActivity handle eframe needs on Android.
+fn run_inner(
+    #[cfg(target_os = "android")] android_app: Option<android_activity::AndroidApp>,
+    #[cfg(target_os = "android")] access: Option<SharedStorageAccess>,
+) -> eframe::Result {
+    // First, so the panic hook and every start-up warning are recorded. Records go to stderr
+    // and the session log; Help ▸ Enable Logging writes through this logger too, and warnings
+    // feed the compatibility report.
+    effectcraft_engine::logging::install();
+    effectcraft_engine::logging::install_panic_hook();
+    let Args { control_port, files, demo, home } = match parse_args(std::env::args().skip(1), std::env::var("EFFECTCRAFT_CONTROL_PORT")) {
+        Ok(Some(args)) => args,
+        Ok(None) => {
+            println!("effectcraft {}", env!("CARGO_PKG_VERSION"));
+            return Ok(());
+        }
+        // Before the session log, preferences and the window: nothing has started yet.
+        Err(e) => {
+            eprintln!("effectcraft: {e}");
+            std::process::exit(1);
+        }
+    };
+    // The session log lives in the settings folder (`Logs/effectcraft.log`, the previous two runs
+    // kept): a launch from the Dock or a menu has no terminal. Opened after the arguments, so
+    // `--version` leaves no file behind; records logged until now are written to it first.
+    if let Some(dir) = effectcraft_host::config_dir() {
+        match effectcraft_engine::logging::attach_session_log(&effectcraft_engine::logging::log_dir(Some(&dir))) {
+            Ok(path) => log::info!("effectcraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
+            Err(e) => log::warn!("no log file: {e}"),
+        }
+    }
+    // The font menus list the installed fonts: read their names while the window opens.
+    effectcraft_engine::text::fonts::scan_system_in_background();
+    // Wayland shows the window's icon from its desktop entry: an AppImage brings its own.
+    #[cfg(target_os = "linux")]
+    appimage::integrate_from_env(ICON_PNG);
+    // Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch
+    // stopped as its window opened (a crashing graphics driver, #243).
+    let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), std::env::var_os("WGPU_BACKEND").is_some());
+    let on_gl = launch.backends == Some(eframe::wgpu::Backends::GL);
+    let notice = launch.notice.take();
+    // eframe cannot create its event loop on Android without the activity's handle; without
+    // it `run_native` fails before the window exists and the activity ends on launch.
+    // The handle also resolves the app-private storage below (saves, exports, settings).
+    #[cfg(target_os = "android")]
+    let android_files: Option<std::path::PathBuf> = android_app.as_ref().and_then(|app| {
+        let dir = app.internal_data_path();
+        if dir.is_none() {
+            log::warn!("android storage: no internal data path; save and open stay unavailable");
+        }
+        dir
+    });
+    // Shared storage (`/storage/emulated/0`) is offered as a second browse root beside the
+    // app-private directory, so saves/exports/autosaves can land outside the app dir. Reaching it
+    // needs the user to grant "All files access" in system settings, which the browser asks for
+    // (and the app can only detect, never grant silently).
+    #[cfg(target_os = "android")]
+    let android_shared: Option<std::path::PathBuf> = android_app.as_ref().map(|_| std::path::PathBuf::from("/storage/emulated/0"));
+    let options = eframe::NativeOptions {
+        viewport: egui::ViewportBuilder::default()
+            .with_title("EffectCraft")
+            .with_inner_size(window::DEFAULT_SIZE)
+            .with_min_inner_size(window::MIN_SIZE)
+            .with_drag_and_drop(true)
+            // Started for an agent (`--control`): open without taking the user's keyboard focus.
+            .with_active(control_port.is_none())
+            .with_icon(app_icon())
+            // Wayland matches the window to `ai.storyteller.effectcraft.desktop` by this ID.
+            .with_app_id(appimage::APP_ID),
+        event_loop_builder: agent_event_loop(control_port.is_some()),
+        wgpu_options: wgpu_options(launch.backends),
+        // The activity's handle resolved above (Android only; the event loop needs it).
+        #[cfg(target_os = "android")]
+        android_app,
+        ..Default::default()
+    };
+    // Start-up milestones go to stderr and the session log, so a window that never appears
+    // shows how far start-up got (#234).
+    log::info!("effectcraft {}: opening the window", env!("CARGO_PKG_VERSION"));
+    // 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;
+    let result = eframe::run_native(
+        "EffectCraft",
+        options,
+        Box::new(move |cc| {
+            match &cc.wgpu_render_state {
+                Some(rs) => log::info!("graphics: {:?}", rs.adapter.get_info()),
+                None => log::info!("graphics: no wgpu device"),
+            }
+            // This executable owns the shared device's handlers. Install them before
+            // EffectCraft's compositor pipelines are built, without changing handlers from
+            // inside Gpu::new. eframe has already constructed its presentation renderer here.
+            let gpu_failures = effectcraft_ui_egui::gpu_failure::GpuFailureBridge::new(&cc.egui_ctx);
+            if let Some(rs) = &cc.wgpu_render_state {
+                let errors = gpu_failures.clone();
+                rs.device.on_uncaptured_error(std::sync::Arc::new(move |error| {
+                    let message = format!("uncaptured GPU error: {error}");
+                    if errors.report(&message, false) {
+                        log::error!("{message}");
+                    }
+                }));
+                let lost = gpu_failures.clone();
+                rs.device.set_device_lost_callback(move |reason, message| {
+                    lost.report(&format!("GPU device lost ({reason:?}): {message}"), true);
+                });
+            }
+            let mut session = effectcraft_host::session();
+            // Lazy open and non-blocking auto-save: footage is checked and auto-saves are
+            // written on background threads (M13.14).
+            session.check_footage_on_open = true;
+            session.autosave.background = true;
+            // Settings, shortcut presets and the crash-recovery sentinel live in the platform
+            // config directory. Agent-driven runs (`--control`) skip crash recovery.
+            if let Some(dir) = effectcraft_host::config_dir() {
+                // WebAssembly effect plug-ins in <config>/Plug-ins load before the menus are built.
+                let plugins = dir.join("Plug-ins");
+                if plugins.is_dir() {
+                    let _ = session.execute("effect.plugins.load", json!({"folder": plugins.to_string_lossy()}));
+                }
+                session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir)));
+            }
+            // Android has no platform config directory: settings, auto-save and crash recovery
+            // live in app-private storage instead (quietly off when it is unavailable).
+            #[cfg(target_os = "android")]
+            let android_layout = android_files.as_deref().map(android_storage::layout);
+            #[cfg(target_os = "android")]
+            if let Some(l) = &android_layout {
+                if let Err(e) = android_storage::ensure(l) {
+                    log::warn!("android storage: {e}");
+                }
+                session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(l.config.clone())));
+            }
+            session.load_settings();
+            // Fit the window to its monitor, or put it back where it was (still hidden).
+            let saved_window = session.config.as_deref().and_then(window::load);
+            let opened = cc.winit_window().map(|w| window::restore(w, saved_window));
+            // Render output defaults to app-private Exports on Android: the working directory is
+            // `/` there and nothing else in the fallback chain resolves to somewhere writable.
+            #[cfg(target_os = "android")]
+            if let Some(l) = &android_layout
+                && session.prefs.export.default_output_folder.trim().is_empty()
+            {
+                session.prefs.export.default_output_folder = l.exports.to_string_lossy().to_string();
+            }
+            let recovery = if control_port.is_none() { session.begin_recovery() } else { None };
+            let project = files.iter().find(|f| is_project_file(f)).cloned();
+            if let Some(p) = project {
+                if let Err(e) = session.execute("file.open", json!({"path": p})) {
+                    log::warn!("{p}: {e}");
+                }
+            } else if demo {
+                let _ = session.execute("file.openDemoProject", json!({}));
+            }
+            let media: Vec<String> = files.iter().filter(|f| !is_project_file(f)).cloned().collect();
+            if !media.is_empty()
+                && let Err(e) = session.execute("file.import", json!({"paths": media}))
+            {
+                log::warn!("import: {e}");
+            }
+            let show_home = home.unwrap_or(session.prefs.startup.show_home_on_launch && files.is_empty() && control_port.is_none());
+            // The ScriptUI panels open when the app last quit open again (a failing one is
+            // reported in a toast and forgotten).
+            let _ = session.execute("window.restoreScriptPanels", json!({}));
+            let mut app = EffectcraftApp::new(session);
+            app.set_gpu_failure_bridge(gpu_failures);
+            app.ui.start_screen = show_home;
+            if let Some(n) = notice {
+                app.ui.status = n;
+            }
+            if let Some(r) = recovery {
+                app.offer_recovery(r);
+            }
+            #[cfg(target_os = "linux")]
+            if let Some(note) = wayland_drop_notice(std::env::var_os("WAYLAND_DISPLAY").as_deref(), std::env::var_os("WINIT_UNIX_BACKEND").as_deref())
+                && app.ui.status.is_empty()
+            {
+                app.ui.status = note.into();
+            }
+            // No file dialogs on Android (rfd has no backend there): the hooks stay empty and
+            // the commands fail with "no file dialog available".
+            #[cfg(not(target_os = "android"))]
+            {
+                app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
+                    rfd::FileDialog::new()
+                        .add_filter("Media", exts)
+                        .pick_files()
+                        .unwrap_or_default()
+                        .into_iter()
+                        .map(|p| p.to_string_lossy().to_string())
+                        .collect()
+                }));
+                app.hooks.pick_save = Some(Box::new(|name: &str| {
+                    rfd::FileDialog::new()
+                        .add_filter("EffectCraft Project", &["ecproj"])
+                        .set_file_name(name)
+                        .save_file()
+                        .map(|p| p.to_string_lossy().to_string())
+                }));
+                // The window closes after a lost graphics device (#591); say why, unless an agent drives
+                // the app through the control channel. Android has no message dialogs either.
+                if control_port.is_none() {
+                    app.hooks.device_lost = Some(Box::new(|saved: Option<Result<String, String>>| {
+                        let saved = match saved {
+                            Some(Ok(path)) => format!("\n\nYour unsaved changes were saved to:\n{path}\n\nReopen EffectCraft to recover them."),
+                            Some(Err(e)) => format!("\n\nYour unsaved changes could not be saved ({e})."),
+                            None => String::new(),
+                        };
+                        rfd::MessageDialog::new()
+                            .set_level(rfd::MessageLevel::Error)
+                            .set_title("EffectCraft: graphics device lost")
+                            .set_description(format!("The graphics driver stopped responding, so EffectCraft has to close.{saved}"))
+                            .show();
+                    }));
+                }
+                app.hooks.pick_open_project = Some(Box::new(|| {
+                    rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file().map(|p| p.to_string_lossy().to_string())
+                }));
+                app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
+                app.hooks.pick_save_file = Some(Box::new(|name: &str, ext: &str| {
+                    // A default with a folder that exists opens the dialog there.
+                    let path = std::path::Path::new(name);
+                    // (No filter for a name without an extension, such as a folder to create.)
+                    let mut d = if ext.is_empty() { rfd::FileDialog::new() } else { rfd::FileDialog::new().add_filter(ext, &[ext]) };
+                    if let Some(dir) = path.parent().filter(|d| d.is_dir()) {
+                        d = d.set_directory(dir);
+                    }
+                    let file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
+                    d.set_file_name(file).save_file().map(|p| p.to_string_lossy().to_string())
+                }));
+            }
+            app.hooks.system_theme = appearance::service();
+            app.hooks.audio_device = Some(Box::new(audio_out::open));
+            app.hooks.audio_devices = Some(Box::new(audio_out::devices));
+            // The in-app Save / Open Project prompts read and write here (nothing when the
+            // storage directory was unavailable: the commands keep their "no dialog" error).
+            // Shared storage is a second browse root, gated on the "All files access" grant.
+            #[cfg(target_os = "android")]
+            {
+                if let Some(base) = android_files {
+                    app.hooks.app_files_dir = Some(base);
+                }
+                if let Some(a) = access {
+                    // The shared root joins the grant hooks: with them absent (Android 10's
+                    // decline in `capture`, or a JVM the app could not reach) the browser
+                    // would list a root it cannot read inside, next to a grant prompt
+                    // nothing answers.
+                    if let Some(root) = android_shared {
+                        app.hooks.shared_storage_root = Some(root);
+                    }
+                    app.hooks.shared_storage_granted = Some(a.granted);
+                    app.hooks.shared_storage_grant = Some(a.ask);
+                }
+            }
+            // The commands' file dialogs don't block the frame where the platform's backend
+            // would (#511, #1275): on macOS as sheets instead of nested modal loops, on Linux
+            // through the desktop portal from a worker thread — a blocking portal dialog leaves
+            // the main thread waiting on its answer without pumping events, so the window stops
+            // answering the compositor and the desktop offers to force-quit it while the Open
+            // dialog is up. Windows' blocking dialog pumps its own messages; Android has no
+            // rfd at all (the in-app browser serves the dialogs).
+            #[cfg(any(target_os = "macos", target_os = "linux"))]
+            {
+                let ctx = cc.egui_ctx.clone();
+                app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone())));
+            }
+            #[cfg(target_os = "macos")]
+            {
+                app.hooks.app_action = Some(Box::new(native_menu::app_action));
+                app.hooks.clipboard_text = Some(Box::new(native_menu::clipboard_text));
+            }
+            if let Some(port) = control_port {
+                disable_app_nap();
+                let rx = control_server::start(port, cc.egui_ctx.clone());
+                app = app.with_control(rx);
+            }
+            log::info!("app created; the window shows after its first frame");
+            Ok(Box::new(Desktop {
+                #[cfg(target_os = "macos")]
+                menu: native_menu::NativeBar::new(&cc.egui_ctx),
+                #[cfg(target_os = "macos")]
+                apple_events: apple_events.connect(&cc.egui_ctx),
+                app,
+                window: window::Tracker::new(opened, saved_window, control_port.is_none()),
+                launch,
+                drawn: false,
+            }))
+        }),
+    );
+    match &result {
+        Ok(()) => log::info!("the window closed"),
+        Err(e) => {
+            if on_gl {
+                launch_guard::opengl_failed(effectcraft_host::config_dir().as_deref());
+            }
+            startup_failed(e, !on_gl && cfg!(not(target_os = "macos")));
+        }
+    }
+    result
+}
+
+/// The window's wgpu device asks for the adapter's own limits (textures up to 16384 px)
+/// instead of eframe's fixed set: an older GPU that can't meet those (Intel HD Graphics 5500,
+/// #198) still opens the app, and the compositor then checks what it needs and falls back to
+/// the CPU when the device can't run it. `backends`: Window Graphics' choice, when it makes one.
+fn wgpu_options(backends: Option<eframe::wgpu::Backends>) -> eframe::WgpuConfiguration {
+    use eframe::egui_wgpu::WgpuSetup;
+    let mut config = eframe::WgpuConfiguration::default();
+    if let WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup {
+        if let Some(b) = backends {
+            setup.instance_descriptor.backends = b;
+        }
+        // Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs
+        // a compute shader when the device is created, one that Metal can't compile on an Intel
+        // Iris Pro: the device is lost and the window never opens.
+        // `WGPU_VALIDATION_INDIRECT_CALL=1` turns the check back on.
+        setup.instance_descriptor.flags = setup.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
+        setup.device_descriptor = std::sync::Arc::new(|adapter| device_descriptor(adapter.limits()));
+    }
+    config
+}
+
+/// The window device's descriptor: the adapter's limits ([`device_limits`]) and `MemoryUsage`,
+/// so memory the compositor frees on the shared device goes back to the driver.
+fn device_descriptor(adapter: eframe::wgpu::Limits) -> eframe::wgpu::DeviceDescriptor<'static> {
+    eframe::wgpu::DeviceDescriptor {
+        label: Some("EffectCraft device"),
+        required_limits: device_limits(adapter),
+        memory_hints: eframe::wgpu::MemoryHints::MemoryUsage,
+        ..Default::default()
+    }
+}
+
+/// The limits to request from an adapter that offers `adapter`: all of them, with textures
+/// capped at 16384 px like the headless renderer's device.
+fn device_limits(adapter: eframe::wgpu::Limits) -> eframe::wgpu::Limits {
+    eframe::wgpu::Limits { max_texture_dimension_2d: adapter.max_texture_dimension_2d.min(16384), ..adapter }
+}
+
+/// The window couldn't open (no usable graphics adapter or device): say so instead of quitting
+/// silently, since release builds on Windows have no console. `gl_next`: the launch guard has
+/// the next launch try OpenGL. Android has no message dialogs; the message is logged (stderr
+/// reaches logcat) and the activity's thread ends.
+fn startup_failed(e: &eframe::Error, gl_next: bool) {
+    let next = if gl_next { "The next launch will try OpenGL instead. " } else { "" };
+    let message = format!(
+        "EffectCraft couldn't start its window: {e}\n\n{next}Updating the graphics driver often helps. If it keeps happening, please report it at https://github.com/storytold/effectcraft/issues with this message."
+    );
+    // Standard error and the session log; standard error alone when RUST_LOG turned errors off.
+    if log::log_enabled!(log::Level::Error) {
+        log::error!("{message}");
+    } else {
+        eprintln!("effectcraft: {message}");
+    }
+    // No message dialog on Android (rfd has no backend there): stderr already carries the
+    // message into logcat, and the activity's thread ends.
+    #[cfg(not(target_os = "android"))]
+    let _ = rfd::MessageDialog::new()
+        .set_level(rfd::MessageLevel::Error)
+        .set_title("EffectCraft")
+        .set_description(message)
+        .set_buttons(rfd::MessageButtons::Ok)
+        .show();
+}
+
+/// The desktop app: the egui app plus, on macOS, the system menu bar.
+struct Desktop {
+    app: EffectcraftApp,
+    #[cfg(target_os = "macos")]
+    menu: native_menu::NativeBar,
+    /// Documents opened from Finder, the Dock or `open` (macOS).
+    #[cfg(target_os = "macos")]
+    apple_events: apple_events::Opened,
+    /// The window's size and position, saved for the next launch.
+    window: window::Tracker,
+    launch: launch_guard::Launch,
+    /// The first frame was drawn (logged once).
+    drawn: bool,
+}
+
+impl eframe::App for Desktop {
+    fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
+        #[cfg(target_os = "macos")]
+        self.menu.update(&mut self.app, ctx);
+        self.app.logic(ctx, frame);
+    }
+
+    fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
+        self.app.ui(ui, frame);
+        self.window.frame(ui.ctx(), self.app.session.config.as_deref());
+        if !self.drawn {
+            self.drawn = true;
+            log::info!("first frame drawn");
+        }
+        // The launch guard's marker goes once the window has kept drawing for a few seconds: a
+        // driver may still crash as the window is shown and its surface re-created.
+        if let Some(left) = self.launch.frame_drawn() {
+            ui.ctx().request_repaint_after(left);
+        }
+    }
+
+    fn on_exit(&mut self) {
+        self.launch.exited();
+        self.window.save(self.app.session.config.as_deref());
+        self.app.on_exit();
+    }
+
+    fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
+        #[cfg(target_os = "macos")]
+        self.apple_events.feed(self.app.is_ready(), ctx, raw_input);
+        self.app.raw_input_hook(ctx, raw_input);
+    }
+}
+
+/// Driven by an agent (`--control`): don't activate the app on launch, so the user's keyboard
+/// focus stays where it is (macOS; winit activates ignoring other apps by default).
+fn agent_event_loop(agent: bool) -> Option<eframe::EventLoopBuilderHook> {
+    #[cfg(target_os = "macos")]
+    if agent {
+        return Some(Box::new(|b| {
+            use winit::platform::macos::EventLoopBuilderExtMacOS;
+            b.with_activate_ignoring_other_apps(false);
+        }));
+    }
+    let _ = agent;
+    None
+}
+
+/// Agents drive the app while its window is covered or on another Space. macOS App Nap would
+/// throttle the event loop then, stalling the control channel; opt out for `--control` runs.
+fn disable_app_nap() {
+    #[cfg(target_os = "macos")]
+    {
+        use objc2_foundation::{NSActivityOptions, NSProcessInfo, NSString};
+        let info = NSProcessInfo::processInfo();
+        let reason = NSString::from_str("EffectCraft control channel");
+        let opts = NSActivityOptions::UserInitiatedAllowingIdleSystemSleep | NSActivityOptions::LatencyCritical;
+        // Leaked on purpose: the activity lasts for the life of the process.
+        std::mem::forget(info.beginActivityWithOptions_reason(opts, &reason));
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{Args, parse_args, sheet_dialogs};
+    use effectcraft_ui_egui::{DialogDone, FileDialog};
+    use std::env::VarError;
+
+    fn parse_env(args: &[&str], env_port: Result<String, VarError>) -> Result<Option<Args>, String> {
+        parse_args(args.iter().map(|a| a.to_string()), env_port)
+    }
+
+    fn parse(args: &[&str]) -> Option<Args> {
+        parse_env(args, Err(VarError::NotPresent)).unwrap()
+    }
+
+    fn fails(args: &[&str]) -> String {
+        parse_env(args, Err(VarError::NotPresent)).unwrap_err()
+    }
+
+    #[cfg(target_os = "linux")]
+    #[test]
+    fn wayland_drop_notice_recommends_import_or_xwayland() {
+        use std::ffi::OsStr;
+        let warning = super::wayland_drop_notice(Some(OsStr::new("wayland-0")), None).unwrap();
+        assert!(warning.contains("File ▸ Import") && warning.contains("XWayland"));
+        assert!(super::wayland_drop_notice(None, None).is_none());
+        assert!(super::wayland_drop_notice(Some(OsStr::new("")), None).is_none());
+        assert!(super::wayland_drop_notice(Some(OsStr::new("wayland-0")), Some(OsStr::new("x11"))).is_none());
+    }
+
+    /// The window's wgpu instance skips indirect-call validation (Intel Metal, #1153).
+    #[test]
+    fn the_window_instance_skips_indirect_call_validation() {
+        if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_some() {
+            return;
+        }
+        let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = super::wgpu_options(None).wgpu_setup else {
+            panic!("the default setup creates its own instance");
+        };
+        // Intel Metal can't compile the validation's compute shader, and the device is lost.
+        assert!(!setup.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
+    }
+
+    /// The window's device never asks for more than the adapter offers (#198).
+    #[test]
+    fn device_limits_fit_the_adapter() {
+        use eframe::wgpu::Limits;
+        for adapter in [Limits::downlevel_defaults(), Limits::downlevel_webgl2_defaults(), Limits::default()] {
+            let l = super::device_limits(adapter.clone());
+            assert!(l.check_limits(&adapter), "{l:?}");
+            assert_eq!(l.max_storage_buffers_per_shader_stage, adapter.max_storage_buffers_per_shader_stage);
+        }
+        let big = Limits { max_texture_dimension_2d: 32768, ..Limits::default() };
+        assert_eq!(super::device_limits(big).max_texture_dimension_2d, 16384);
+    }
+
+    /// The window device asks for `MemoryUsage`: the adapter's limits, unchanged.
+    #[test]
+    fn the_window_device_favours_memory_usage() {
+        use eframe::wgpu::Limits;
+        let big = Limits { max_texture_dimension_2d: 32768, ..Limits::default() };
+        let d = super::device_descriptor(big);
+        assert!(matches!(d.memory_hints, eframe::wgpu::MemoryHints::MemoryUsage));
+        assert_eq!(d.required_limits.max_texture_dimension_2d, 16384);
+        assert_eq!(d.label, Some("EffectCraft device"));
+    }
+
+    /// A positional .ecprojx is a project file, not an unsupported media import (#528).
+    #[test]
+    fn launch_classifies_xml_and_json_projects_as_projects() {
+        for path in ["Copy.ecprojx", "/tmp/Scene.ecproj", "C:\\\\work\\\\Shot.ECPROJX"] {
+            assert!(super::is_project_file(path), "{path}");
+        }
+        for path in ["scene.ecprojx.png", "scene.mp4", "scene", "copy.ecprojx.bak"] {
+            assert!(!super::is_project_file(path), "{path}");
+        }
+        let files = ["scene.ecprojx".to_string(), "clip.mov".to_string()];
+        assert_eq!(files.iter().find(|f| super::is_project_file(f)).map(String::as_str), Some("scene.ecprojx"));
+        assert_eq!(files.iter().filter(|f| !super::is_project_file(f)).map(String::as_str).collect::<Vec<_>>(), vec!["clip.mov"],);
+    }
+
+    /// Launching without a project opens an empty project, not the demo (#204).
+    #[test]
+    fn no_arguments_start_an_empty_project() {
+        assert_eq!(parse(&[]), Some(Args::default()));
+        assert!(parse(&["--demo"]).is_some_and(|a| a.demo));
+        assert!(parse(&["--demo", "--empty"]).is_some_and(|a| !a.demo));
+        let a = parse(&["--control", "9877", "--home", "a.ecproj"]).unwrap_or_default();
+        assert_eq!((a.control_port, a.home, a.files), (Some(9877), Some(true), vec!["a.ecproj".to_string()]));
+        assert_eq!(parse(&["--version"]), None);
+        assert_eq!(parse_env(&[], Ok("9000".into())).unwrap().map(|a| a.control_port), Some(Some(9000)));
+    }
+
+    /// A missing, non-numeric or zero `--control` value stops the launch instead of starting
+    /// without a control server, and the argument after `--control` isn't reinterpreted (#997).
+    #[test]
+    fn control_needs_a_port_from_1_to_65535() {
+        let need = "--control needs a port number from 1 to 65535";
+        assert_eq!(fails(&["--control"]), need);
+        assert_eq!(fails(&["--control", "--demo"]), format!("{need}, not `--demo`"));
+        assert_eq!(fails(&["--control", "--version"]), format!("{need}, not `--version`"));
+        for bad in ["abc", "0", "70000", "-1", ""] {
+            assert_eq!(fails(&["--control", bad]), format!("{need}, not `{bad}`"));
+            assert_eq!(fails(&[&format!("--control={bad}")]), format!("{need}, not `{bad}`"));
+        }
+        assert_eq!(parse(&["--control=9877"]).map(|a| a.control_port), Some(Some(9877)));
+        assert_eq!(parse(&["--control", "65535"]).map(|a| a.control_port), Some(Some(65535)));
+    }
+
+    /// An unknown option is an error, not a file to import (#997); `--` ends the options.
+    #[test]
+    fn unknown_options_are_errors_not_files() {
+        for bad in ["--automation-read-root", "--automation-write-root=/tmp", "-h", "--Demo", "--demo=yes"] {
+            assert_eq!(fails(&[bad, "/tmp/folder"]), format!("unknown option `{bad}`"));
+        }
+        // Parsed left to right: an error before `--version` wins.
+        assert_eq!(fails(&["--nope", "--version"]), "unknown option `--nope`");
+        // Finder's process serial number is ignored, not imported or refused.
+        assert_eq!(parse(&["-psn_0_12345"]).map(|a| a.files), Some(vec![]));
+        let a = parse(&["clip.mov", "-", "--", "--demo", "-name.png"]).unwrap_or_default();
+        assert_eq!(a.files, ["clip.mov", "-", "--demo", "-name.png"]);
+        assert!(!a.demo);
+    }
+
+    /// `--control` wins over `EFFECTCRAFT_CONTROL_PORT`; the variable is checked only when it is
+    /// used, so a bad value doesn't stop `--control` or `--version`.
+    #[test]
+    fn control_port_variable_is_checked_when_used() {
+        let need = "EFFECTCRAFT_CONTROL_PORT needs a port number from 1 to 65535";
+        assert_eq!(parse_env(&[], Ok("abc".into())).unwrap_err(), format!("{need}, not `abc`"));
+        assert_eq!(parse_env(&[], Ok("0".into())).unwrap_err(), format!("{need}, not `0`"));
+        assert_eq!(parse_env(&[], Ok(String::new())), Ok(Some(Args::default())));
+        assert_eq!(parse_env(&["--control", "9877"], Ok("abc".into())).unwrap().map(|a| a.control_port), Some(Some(9877)));
+        assert_eq!(parse_env(&["--version"], Ok("abc".into())), Ok(None));
+        #[cfg(unix)]
+        {
+            use std::os::unix::ffi::OsStringExt;
+            let not_utf8 = VarError::NotUnicode(std::ffi::OsString::from_vec(vec![0x39, 0xff]));
+            assert_eq!(parse_env(&[], Err(not_utf8)).unwrap_err(), format!("{need}, not `9\u{fffd}`"));
+        }
+    }
+
+    /// #1275: a blocking desktop-portal dialog on Linux leaves the window not answering the
+    /// compositor while it is open, so the commands' file dialogs go through the non-blocking
+    /// host hook there (as they do on macOS, #511). Windows' dialog pumps its own messages.
+    #[test]
+    fn commands_file_dialogs_go_through_the_non_blocking_hook() {
+        let mut app = crate::EffectcraftApp::new(effectcraft_engine::Session::default());
+        #[cfg(any(target_os = "macos", target_os = "linux"))]
+        {
+            let ctx = egui::Context::default();
+            app.hooks.file_dialog_async = Some(Box::new(move |dialog: FileDialog, done: DialogDone| sheet_dialogs::show(dialog, done, ctx.clone())));
+        }
+        #[cfg(any(target_os = "macos", target_os = "linux"))]
+        assert!(app.hooks.file_dialog_async.is_some(), "the commands' file dialogs must not block the frame");
+        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
+        assert!(app.hooks.file_dialog_async.is_none(), "the blocking dialog pumps its own messages here");
+    }
+}
diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs
--- a/apps/effectcraft/src/main.rs
+++ b/apps/effectcraft/src/main.rs
@@ -1,647 +1,7 @@
-//! EffectCraft desktop app.
-//!
-//! Usage: `effectcraft [--control <port>] [--demo|--empty|--home] [--] [project.ecproj | media files…]`
-//!
-//! An unknown option or a bad port prints an error and exits with status 1 before anything starts.
-//!
-//! Without a project the app starts with an empty Untitled Project, as After Effects does;
-//! `--demo` opens the demo project instead (also Help ▸ Open Demo Project).
-//!
-//! `--control <port>` (or `EFFECTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
-//! see `effectcraft_ui_egui::control` for the methods.
+//! EffectCraft desktop app binary. The app itself is the library (`lib.rs`, [`effectcraft::run`]);
+//! Android's NativeActivity entry point is the `effectcraft-android` crate.
 #![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)]
-
-// Built everywhere so its tests run on every platform; only Linux AppImages use it.
-mod appearance;
-#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
-mod appimage;
-#[cfg(target_os = "macos")]
-mod apple_events;
-mod audio_out;
-mod control_server;
-mod launch_guard;
-#[cfg(target_os = "macos")]
-mod native_menu;
-// Built everywhere so it stays checked; the macOS and Linux apps use it.
-#[cfg_attr(not(any(target_os = "macos", target_os = "linux")), allow(dead_code))]
-mod sheet_dialogs;
-mod window;
-
-use effectcraft_ui_egui::EffectcraftApp;
-use serde_json::json;
-
-/// The app icon for the window, taskbar and Dock (assets/app-icon, regenerated by
-/// `packaging/icons.sh`). macOS gets the padded variant so it sits on Apple's icon grid in the Dock.
-/// The 256 px app icon (Windows, Linux; the AppImage's desktop integration installs it too).
-#[cfg(not(target_os = "macos"))]
-const ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png");
-
-fn app_icon() -> egui::IconData {
-    #[cfg(target_os = "macos")]
-    let png: &[u8] = include_bytes!("../../../assets/app-icon/effectcraft-macos-512.png");
-    #[cfg(not(target_os = "macos"))]
-    let png: &[u8] = ICON_PNG;
-    eframe::icon_data::from_png_bytes(png).unwrap_or_default()
-}
-
-/// Both native JSON projects and Save a Copy As XML projects are openable on launch.
-/// Keep this classification shared by project opening and the subsequent media-import pass.
-fn is_project_file(path: &str) -> bool {
-    std::path::Path::new(path)
-        .extension()
-        .and_then(|ext| ext.to_str())
-        .is_some_and(|ext| ext.eq_ignore_ascii_case("ecproj") || ext.eq_ignore_ascii_case("ecprojx"))
-}
-
-/// winit 0.30 cannot deliver native Wayland file drops. XWayland can.
-#[cfg(target_os = "linux")]
-fn wayland_drop_notice(wayland_display: Option<&std::ffi::OsStr>, backend: Option<&std::ffi::OsStr>) -> Option<&'static str> {
-    (wayland_display.is_some_and(|v| !v.is_empty()) && backend != Some(std::ffi::OsStr::new("x11")))
-        .then_some("Native Wayland file drops are unavailable. Use File ▸ Import ▸ File… or run via XWayland (see README, Linux).")
-}
-
-/// The command line.
-#[derive(Debug, Default, PartialEq)]
-struct Args {
-    control_port: Option<u16>,
-    files: Vec<String>,
-    /// Open the demo project when no project is given (`--demo`).
-    demo: bool,
-    /// Show or skip the Home screen (`--home`), else the Startup preference decides.
-    home: Option<bool>,
-}
-
-/// Parse the arguments after the program name; `Ok(None)` for `--version`. `env_port` is
-/// `EFFECTCRAFT_CONTROL_PORT`, used when `--control` isn't given. An unknown option or a bad port
-/// is an error, so it is neither imported as a file nor dropped; after `--` every argument is a file.
-fn parse_args(args: impl IntoIterator<Item = String>, env_port: Result<String, std::env::VarError>) -> Result<Option<Args>, String> {
-    let mut out = Args::default();
-    let mut args = args.into_iter();
-    let mut options = true;
-    while let Some(a) = args.next() {
-        if !options {
-            out.files.push(a);
-            continue;
-        }
-        match a.as_str() {
-            "--" => options = false,
-            "--control" => out.control_port = Some(control_port("--control", args.next().as_deref())?),
-            "--demo" => out.demo = true,
-            "--empty" => out.demo = false,
-            "--home" => out.home = Some(true),
-            "--version" => return Ok(None),
-            _ => match a.strip_prefix("--control=") {
-                Some(v) => out.control_port = Some(control_port("--control", Some(v))?),
-                // macOS LaunchServices may pass a process serial number (`-psn_0_1234`), notably on
-                // the first launch of a downloaded app; it is not an option of ours.
-                None if a.starts_with("-psn_") => {}
-                None if a.starts_with('-') && a != "-" => return Err(format!("unknown option `{a}`")),
-                None => out.files.push(a),
-            },
-        }
-    }
-    if out.control_port.is_none() {
-        out.control_port = match env_port {
-            Ok(v) if v.is_empty() => None,
-            Ok(v) => Some(control_port("EFFECTCRAFT_CONTROL_PORT", Some(&v))?),
-            Err(std::env::VarError::NotPresent) => None,
-            Err(std::env::VarError::NotUnicode(v)) => Some(control_port("EFFECTCRAFT_CONTROL_PORT", Some(&v.to_string_lossy()))?),
-        };
-    }
-    Ok(Some(out))
-}
-
-/// A control-channel port from 1 to 65535 (0 would listen on a port nobody is told); `name` is
-/// the flag or variable it came from.
-fn control_port(name: &str, value: Option<&str>) -> Result<u16, String> {
-    let need = format!("{name} needs a port number from 1 to 65535");
-    match value {
-        None => Err(need),
-        Some(v) => v.parse().ok().filter(|&p| p != 0).ok_or(format!("{need}, not `{v}`")),
-    }
-}
-
-/// The commands' file dialogs open without blocking the frame where the host's backend would:
-/// on macOS as sheets instead of nested modal loops (#511), on Linux through the desktop
-/// portal from a worker thread — a blocking portal dialog leaves the main thread waiting on its
-/// answer without pumping events, so the window stops answering the compositor and the desktop
-/// offers to force-quit it while the Open dialog is up (#1275). Windows' blocking dialog pumps
-/// its own messages, so it keeps [`effectcraft_ui_egui::Hooks::pick_files`] and friends.
-fn install_file_dialogs(app: &mut EffectcraftApp, ctx: &egui::Context) {
-    #[cfg(any(target_os = "macos", target_os = "linux"))]
-    {
-        let ctx = ctx.clone();
-        app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone())));
-    }
-    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
-    let _ = (app, ctx);
-}
 
 fn main() -> eframe::Result {
-    // First, so the panic hook and every start-up warning are recorded. Records go to stderr
-    // and the session log; Help ▸ Enable Logging writes through this logger too, and warnings
-    // feed the compatibility report.
-    effectcraft_engine::logging::install();
-    effectcraft_engine::logging::install_panic_hook();
-    let Args { control_port, files, demo, home } = match parse_args(std::env::args().skip(1), std::env::var("EFFECTCRAFT_CONTROL_PORT")) {
-        Ok(Some(args)) => args,
-        Ok(None) => {
-            println!("effectcraft {}", env!("CARGO_PKG_VERSION"));
-            return Ok(());
-        }
-        // Before the session log, preferences and the window: nothing has started yet.
-        Err(e) => {
-            eprintln!("effectcraft: {e}");
-            std::process::exit(1);
-        }
-    };
-    // The session log lives in the settings folder (`Logs/effectcraft.log`, the previous two runs
-    // kept): a launch from the Dock or a menu has no terminal. Opened after the arguments, so
-    // `--version` leaves no file behind; records logged until now are written to it first.
-    if let Some(dir) = effectcraft_host::config_dir() {
-        match effectcraft_engine::logging::attach_session_log(&effectcraft_engine::logging::log_dir(Some(&dir))) {
-            Ok(path) => log::info!("effectcraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
-            Err(e) => log::warn!("no log file: {e}"),
-        }
-    }
-    // The font menus list the installed fonts: read their names while the window opens.
-    effectcraft_engine::text::fonts::scan_system_in_background();
-    // Wayland shows the window's icon from its desktop entry: an AppImage brings its own.
-    #[cfg(target_os = "linux")]
-    appimage::integrate_from_env(ICON_PNG);
-    // Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch
-    // stopped as its window opened (a crashing graphics driver, #243).
-    let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), std::env::var_os("WGPU_BACKEND").is_some());
-    let on_gl = launch.backends == Some(eframe::wgpu::Backends::GL);
-    let notice = launch.notice.take();
-    let options = eframe::NativeOptions {
-        viewport: egui::ViewportBuilder::default()
-            .with_title("EffectCraft")
-            .with_inner_size(window::DEFAULT_SIZE)
-            .with_min_inner_size(window::MIN_SIZE)
-            .with_drag_and_drop(true)
-            // Started for an agent (`--control`): open without taking the user's keyboard focus.
-            .with_active(control_port.is_none())
-            .with_icon(app_icon())
-            // Wayland matches the window to `ai.storyteller.effectcraft.desktop` by this ID.
-            .with_app_id(appimage::APP_ID),
-        event_loop_builder: agent_event_loop(control_port.is_some()),
-        wgpu_options: wgpu_options(launch.backends),
-        ..Default::default()
-    };
-    // Start-up milestones go to stderr and the session log, so a window that never appears
-    // shows how far start-up got (#234).
-    log::info!("effectcraft {}: opening the window", env!("CARGO_PKG_VERSION"));
-    // 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;
-    let result = eframe::run_native(
-        "EffectCraft",
-        options,
-        Box::new(move |cc| {
-            match &cc.wgpu_render_state {
-                Some(rs) => log::info!("graphics: {:?}", rs.adapter.get_info()),
-                None => log::info!("graphics: no wgpu device"),
-            }
-            // This executable owns the shared device's handlers. Install them before
-            // EffectCraft's compositor pipelines are built, without changing handlers from
-            // inside Gpu::new. eframe has already constructed its presentation renderer here.
-            let gpu_failures = effectcraft_ui_egui::gpu_failure::GpuFailureBridge::new(&cc.egui_ctx);
-            if let Some(rs) = &cc.wgpu_render_state {
-                let errors = gpu_failures.clone();
-                rs.device.on_uncaptured_error(std::sync::Arc::new(move |error| {
-                    let message = format!("uncaptured GPU error: {error}");
-                    if errors.report(&message, false) {
-                        log::error!("{message}");
-                    }
-                }));
-                let lost = gpu_failures.clone();
-                rs.device.set_device_lost_callback(move |reason, message| {
-                    lost.report(&format!("GPU device lost ({reason:?}): {message}"), true);
-                });
-            }
-            let mut session = effectcraft_host::session();
-            // Lazy open and non-blocking auto-save: footage is checked and auto-saves are
-            // written on background threads (M13.14).
-            session.check_footage_on_open = true;
-            session.autosave.background = true;
-            // Settings, shortcut presets and the crash-recovery sentinel live in the platform
-            // config directory. Agent-driven runs (`--control`) skip crash recovery.
-            if let Some(dir) = effectcraft_host::config_dir() {
-                // WebAssembly effect plug-ins in <config>/Plug-ins load before the menus are built.
-                let plugins = dir.join("Plug-ins");
-                if plugins.is_dir() {
-                    let _ = session.execute("effect.plugins.load", json!({"folder": plugins.to_string_lossy()}));
-                }
-                session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir)));
-            }
-            session.load_settings();
-            // Fit the window to its monitor, or put it back where it was (still hidden).
-            let saved_window = session.config.as_deref().and_then(window::load);
-            let opened = cc.winit_window().map(|w| window::restore(w, saved_window));
-            let recovery = if control_port.is_none() { session.begin_recovery() } else { None };
-            let project = files.iter().find(|f| is_project_file(f)).cloned();
-            if let Some(p) = project {
-                if let Err(e) = session.execute("file.open", json!({"path": p})) {
-                    log::warn!("{p}: {e}");
-                }
-            } else if demo {
-                let _ = session.execute("file.openDemoProject", json!({}));
-            }
-            let media: Vec<String> = files.iter().filter(|f| !is_project_file(f)).cloned().collect();
-            if !media.is_empty()
-                && let Err(e) = session.execute("file.import", json!({"paths": media}))
-            {
-                log::warn!("import: {e}");
-            }
-            let show_home = home.unwrap_or(session.prefs.startup.show_home_on_launch && files.is_empty() && control_port.is_none());
-            // The ScriptUI panels open when the app last quit open again (a failing one is
-            // reported in a toast and forgotten).
-            let _ = session.execute("window.restoreScriptPanels", json!({}));
-            let mut app = EffectcraftApp::new(session);
-            app.set_gpu_failure_bridge(gpu_failures);
-            app.ui.start_screen = show_home;
-            if let Some(n) = notice {
-                app.ui.status = n;
-            }
-            if let Some(r) = recovery {
-                app.offer_recovery(r);
-            }
-            #[cfg(target_os = "linux")]
-            if let Some(note) = wayland_drop_notice(std::env::var_os("WAYLAND_DISPLAY").as_deref(), std::env::var_os("WINIT_UNIX_BACKEND").as_deref())
-                && app.ui.status.is_empty()
-            {
-                app.ui.status = note.into();
-            }
-            app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
-                rfd::FileDialog::new().add_filter("Media", exts).pick_files().unwrap_or_default().into_iter().map(|p| p.to_string_lossy().to_string()).collect()
-            }));
-            app.hooks.pick_save = Some(Box::new(|name: &str| {
-                rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj"]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
-            }));
-            // The window closes after a lost graphics device (#591); say why, unless an agent drives
-            // the app through the control channel.
-            if control_port.is_none() {
-                app.hooks.device_lost = Some(Box::new(|saved: Option<Result<String, String>>| {
-                    let saved = match saved {
-                        Some(Ok(path)) => format!("\n\nYour unsaved changes were saved to:\n{path}\n\nReopen EffectCraft to recover them."),
-                        Some(Err(e)) => format!("\n\nYour unsaved changes could not be saved ({e})."),
-                        None => String::new(),
-                    };
-                    rfd::MessageDialog::new()
-                        .set_level(rfd::MessageLevel::Error)
-                        .set_title("EffectCraft: graphics device lost")
-                        .set_description(format!("The graphics driver stopped responding, so EffectCraft has to close.{saved}"))
-                        .show();
-                }));
-            }
-            app.hooks.pick_open_project = Some(Box::new(|| {
-                rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file().map(|p| p.to_string_lossy().to_string())
-            }));
-            app.hooks.system_theme = appearance::service();
-            app.hooks.audio_device = Some(Box::new(audio_out::open));
-            app.hooks.audio_devices = Some(Box::new(audio_out::devices));
-            app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
-            app.hooks.pick_save_file = Some(Box::new(|name: &str, ext: &str| {
-                // A default with a folder that exists opens the dialog there.
-                let path = std::path::Path::new(name);
-                // (No filter for a name without an extension, such as a folder to create.)
-                let mut d = if ext.is_empty() { rfd::FileDialog::new() } else { rfd::FileDialog::new().add_filter(ext, &[ext]) };
-                if let Some(dir) = path.parent().filter(|d| d.is_dir()) {
-                    d = d.set_directory(dir);
-                }
-                let file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
-                d.set_file_name(file).save_file().map(|p| p.to_string_lossy().to_string())
-            }));
-            // The commands' file dialogs don't block the frame where the platform's backend
-            // would (#511, #1275); see [`install_file_dialogs`].
-            install_file_dialogs(&mut app, &cc.egui_ctx);
-            #[cfg(target_os = "macos")]
-            {
-                app.hooks.app_action = Some(Box::new(native_menu::app_action));
-                app.hooks.clipboard_text = Some(Box::new(native_menu::clipboard_text));
-            }
-            if let Some(port) = control_port {
-                disable_app_nap();
-                let rx = control_server::start(port, cc.egui_ctx.clone());
-                app = app.with_control(rx);
-            }
-            log::info!("app created; the window shows after its first frame");
-            Ok(Box::new(Desktop {
-                #[cfg(target_os = "macos")]
-                menu: native_menu::NativeBar::new(&cc.egui_ctx),
-                #[cfg(target_os = "macos")]
-                apple_events: apple_events.connect(&cc.egui_ctx),
-                app,
-                window: window::Tracker::new(opened, saved_window, control_port.is_none()),
-                launch,
-                drawn: false,
-            }))
-        }),
-    );
-    match &result {
-        Ok(()) => log::info!("the window closed"),
-        Err(e) => {
-            if on_gl {
-                launch_guard::opengl_failed(effectcraft_host::config_dir().as_deref());
-            }
-            startup_failed(e, !on_gl && cfg!(not(target_os = "macos")));
-        }
-    }
-    result
-}
-
-/// The window's wgpu device asks for the adapter's own limits (textures up to 16384 px)
-/// instead of eframe's fixed set: an older GPU that can't meet those (Intel HD Graphics 5500,
-/// #198) still opens the app, and the compositor then checks what it needs and falls back to
-/// the CPU when the device can't run it. `backends`: Window Graphics' choice, when it makes one.
-fn wgpu_options(backends: Option<eframe::wgpu::Backends>) -> eframe::WgpuConfiguration {
-    use eframe::egui_wgpu::WgpuSetup;
-    let mut config = eframe::WgpuConfiguration::default();
-    if let WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup {
-        if let Some(b) = backends {
-            setup.instance_descriptor.backends = b;
-        }
-        // Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs
-        // a compute shader when the device is created, one that Metal can't compile on an Intel
-        // Iris Pro: the device is lost and the window never opens.
-        // `WGPU_VALIDATION_INDIRECT_CALL=1` turns the check back on.
-        setup.instance_descriptor.flags = setup.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
-        setup.device_descriptor = std::sync::Arc::new(|adapter| device_descriptor(adapter.limits()));
-    }
-    config
-}
-
-/// The window device's descriptor: the adapter's limits ([`device_limits`]) and `MemoryUsage`,
-/// so memory the compositor frees on the shared device goes back to the driver.
-fn device_descriptor(adapter: eframe::wgpu::Limits) -> eframe::wgpu::DeviceDescriptor<'static> {
-    eframe::wgpu::DeviceDescriptor {
-        label: Some("EffectCraft device"),
-        required_limits: device_limits(adapter),
-        memory_hints: eframe::wgpu::MemoryHints::MemoryUsage,
-        ..Default::default()
-    }
-}
-
-/// The limits to request from an adapter that offers `adapter`: all of them, with textures
-/// capped at 16384 px like the headless renderer's device.
-fn device_limits(adapter: eframe::wgpu::Limits) -> eframe::wgpu::Limits {
-    eframe::wgpu::Limits { max_texture_dimension_2d: adapter.max_texture_dimension_2d.min(16384), ..adapter }
-}
-
-/// The window couldn't open (no usable graphics adapter or device): say so instead of quitting
-/// silently, since release builds on Windows have no console. `gl_next`: the launch guard has
-/// the next launch try OpenGL.
-fn startup_failed(e: &eframe::Error, gl_next: bool) {
-    let next = if gl_next { "The next launch will try OpenGL instead. " } else { "" };
-    let message = format!(
-        "EffectCraft couldn't start its window: {e}\n\n{next}Updating the graphics driver often helps. If it keeps happening, please report it at https://github.com/storytold/effectcraft/issues with this message."
-    );
-    // Standard error and the session log; standard error alone when RUST_LOG turned errors off.
-    if log::log_enabled!(log::Level::Error) {
-        log::error!("{message}");
-    } else {
-        eprintln!("effectcraft: {message}");
-    }
-    let _ = rfd::MessageDialog::new()
-        .set_level(rfd::MessageLevel::Error)
-        .set_title("EffectCraft")
-        .set_description(message)
-        .set_buttons(rfd::MessageButtons::Ok)
-        .show();
-}
-
-/// The desktop app: the egui app plus, on macOS, the system menu bar.
-struct Desktop {
-    app: EffectcraftApp,
-    #[cfg(target_os = "macos")]
-    menu: native_menu::NativeBar,
-    /// Documents opened from Finder, the Dock or `open` (macOS).
-    #[cfg(target_os = "macos")]
-    apple_events: apple_events::Opened,
-    /// The window's size and position, saved for the next launch.
-    window: window::Tracker,
-    launch: launch_guard::Launch,
-    /// The first frame was drawn (logged once).
-    drawn: bool,
-}
-
-impl eframe::App for Desktop {
-    fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
-        #[cfg(target_os = "macos")]
-        self.menu.update(&mut self.app, ctx);
-        self.app.logic(ctx, frame);
-    }
-
-    fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
-        self.app.ui(ui, frame);
-        self.window.frame(ui.ctx(), self.app.session.config.as_deref());
-        if !self.drawn {
-            self.drawn = true;
-            log::info!("first frame drawn");
-        }
-        // The launch guard's marker goes once the window has kept drawing for a few seconds: a
-        // driver may still crash as the window is shown and its surface re-created.
-        if let Some(left) = self.launch.frame_drawn() {
-            ui.ctx().request_repaint_after(left);
-        }
-    }
-
-    fn on_exit(&mut self) {
-        self.launch.exited();
-        self.window.save(self.app.session.config.as_deref());
-        self.app.on_exit();
-    }
-
-    fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
-        #[cfg(target_os = "macos")]
-        self.apple_events.feed(self.app.is_ready(), ctx, raw_input);
-        self.app.raw_input_hook(ctx, raw_input);
-    }
-}
-
-/// Driven by an agent (`--control`): don't activate the app on launch, so the user's keyboard
-/// focus stays where it is (macOS; winit activates ignoring other apps by default).
-fn agent_event_loop(agent: bool) -> Option<eframe::EventLoopBuilderHook> {
-    #[cfg(target_os = "macos")]
-    if agent {
-        return Some(Box::new(|b| {
-            use winit::platform::macos::EventLoopBuilderExtMacOS;
-            b.with_activate_ignoring_other_apps(false);
-        }));
-    }
-    let _ = agent;
-    None
-}
-
-/// Agents drive the app while its window is covered or on another Space. macOS App Nap would
-/// throttle the event loop then, stalling the control channel; opt out for `--control` runs.
-fn disable_app_nap() {
-    #[cfg(target_os = "macos")]
-    {
-        use objc2_foundation::{NSActivityOptions, NSProcessInfo, NSString};
-        let info = NSProcessInfo::processInfo();
-        let reason = NSString::from_str("EffectCraft control channel");
-        let opts = NSActivityOptions::UserInitiatedAllowingIdleSystemSleep | NSActivityOptions::LatencyCritical;
-        // Leaked on purpose: the activity lasts for the life of the process.
-        std::mem::forget(info.beginActivityWithOptions_reason(opts, &reason));
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::{Args, parse_args};
-    use std::env::VarError;
-
-    fn parse_env(args: &[&str], env_port: Result<String, VarError>) -> Result<Option<Args>, String> {
-        parse_args(args.iter().map(|a| a.to_string()), env_port)
-    }
-
-    fn parse(args: &[&str]) -> Option<Args> {
-        parse_env(args, Err(VarError::NotPresent)).unwrap()
-    }
-
-    fn fails(args: &[&str]) -> String {
-        parse_env(args, Err(VarError::NotPresent)).unwrap_err()
-    }
-
-    #[cfg(target_os = "linux")]
-    #[test]
-    fn wayland_drop_notice_recommends_import_or_xwayland() {
-        use std::ffi::OsStr;
-        let warning = super::wayland_drop_notice(Some(OsStr::new("wayland-0")), None).unwrap();
-        assert!(warning.contains("File ▸ Import") && warning.contains("XWayland"));
-        assert!(super::wayland_drop_notice(None, None).is_none());
-        assert!(super::wayland_drop_notice(Some(OsStr::new("")), None).is_none());
-        assert!(super::wayland_drop_notice(Some(OsStr::new("wayland-0")), Some(OsStr::new("x11"))).is_none());
-    }
-
-    /// The window's wgpu instance skips indirect-call validation (Intel Metal, #1153).
-    #[test]
-    fn the_window_instance_skips_indirect_call_validation() {
-        if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_some() {
-            return;
-        }
-        let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = super::wgpu_options(None).wgpu_setup else {
-            panic!("the default setup creates its own instance");
-        };
-        // Intel Metal can't compile the validation's compute shader, and the device is lost.
-        assert!(!setup.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
-    }
-
-    /// The window's device never asks for more than the adapter offers (#198).
-    #[test]
-    fn device_limits_fit_the_adapter() {
-        use eframe::wgpu::Limits;
-        for adapter in [Limits::downlevel_defaults(), Limits::downlevel_webgl2_defaults(), Limits::default()] {
-            let l = super::device_limits(adapter.clone());
-            assert!(l.check_limits(&adapter), "{l:?}");
-            assert_eq!(l.max_storage_buffers_per_shader_stage, adapter.max_storage_buffers_per_shader_stage);
-        }
-        let big = Limits { max_texture_dimension_2d: 32768, ..Limits::default() };
-        assert_eq!(super::device_limits(big).max_texture_dimension_2d, 16384);
-    }
-
-    /// The window device asks for `MemoryUsage`: the adapter's limits, unchanged.
-    #[test]
-    fn the_window_device_favours_memory_usage() {
-        use eframe::wgpu::Limits;
-        let big = Limits { max_texture_dimension_2d: 32768, ..Limits::default() };
-        let d = super::device_descriptor(big);
-        assert!(matches!(d.memory_hints, eframe::wgpu::MemoryHints::MemoryUsage));
-        assert_eq!(d.required_limits.max_texture_dimension_2d, 16384);
-        assert_eq!(d.label, Some("EffectCraft device"));
-    }
-
-    /// A positional .ecprojx is a project file, not an unsupported media import (#528).
-    #[test]
-    fn launch_classifies_xml_and_json_projects_as_projects() {
-        for path in ["Copy.ecprojx", "/tmp/Scene.ecproj", "C:\\\\work\\\\Shot.ECPROJX"] {
-            assert!(super::is_project_file(path), "{path}");
-        }
-        for path in ["scene.ecprojx.png", "scene.mp4", "scene", "copy.ecprojx.bak"] {
-            assert!(!super::is_project_file(path), "{path}");
-        }
-        let files = ["scene.ecprojx".to_string(), "clip.mov".to_string()];
-        assert_eq!(files.iter().find(|f| super::is_project_file(f)).map(String::as_str), Some("scene.ecprojx"));
-        assert_eq!(files.iter().filter(|f| !super::is_project_file(f)).map(String::as_str).collect::<Vec<_>>(), vec!["clip.mov"],);
-    }
-
-    /// Launching without a project opens an empty project, not the demo (#204).
-    #[test]
-    fn no_arguments_start_an_empty_project() {
-        assert_eq!(parse(&[]), Some(Args::default()));
-        assert!(parse(&["--demo"]).is_some_and(|a| a.demo));
-        assert!(parse(&["--demo", "--empty"]).is_some_and(|a| !a.demo));
-        let a = parse(&["--control", "9877", "--home", "a.ecproj"]).unwrap_or_default();
-        assert_eq!((a.control_port, a.home, a.files), (Some(9877), Some(true), vec!["a.ecproj".to_string()]));
-        assert_eq!(parse(&["--version"]), None);
-        assert_eq!(parse_env(&[], Ok("9000".into())).unwrap().map(|a| a.control_port), Some(Some(9000)));
-    }
-
-    /// A missing, non-numeric or zero `--control` value stops the launch instead of starting
-    /// without a control server, and the argument after `--control` isn't reinterpreted (#997).
-    #[test]
-    fn control_needs_a_port_from_1_to_65535() {
-        let need = "--control needs a port number from 1 to 65535";
-        assert_eq!(fails(&["--control"]), need);
-        assert_eq!(fails(&["--control", "--demo"]), format!("{need}, not `--demo`"));
-        assert_eq!(fails(&["--control", "--version"]), format!("{need}, not `--version`"));
-        for bad in ["abc", "0", "70000", "-1", ""] {
-            assert_eq!(fails(&["--control", bad]), format!("{need}, not `{bad}`"));
-            assert_eq!(fails(&[&format!("--control={bad}")]), format!("{need}, not `{bad}`"));
-        }
-        assert_eq!(parse(&["--control=9877"]).map(|a| a.control_port), Some(Some(9877)));
-        assert_eq!(parse(&["--control", "65535"]).map(|a| a.control_port), Some(Some(65535)));
-    }
-
-    /// An unknown option is an error, not a file to import (#997); `--` ends the options.
-    #[test]
-    fn unknown_options_are_errors_not_files() {
-        for bad in ["--automation-read-root", "--automation-write-root=/tmp", "-h", "--Demo", "--demo=yes"] {
-            assert_eq!(fails(&[bad, "/tmp/folder"]), format!("unknown option `{bad}`"));
-        }
-        // Parsed left to right: an error before `--version` wins.
-        assert_eq!(fails(&["--nope", "--version"]), "unknown option `--nope`");
-        // Finder's process serial number is ignored, not imported or refused.
-        assert_eq!(parse(&["-psn_0_12345"]).map(|a| a.files), Some(vec![]));
-        let a = parse(&["clip.mov", "-", "--", "--demo", "-name.png"]).unwrap_or_default();
-        assert_eq!(a.files, ["clip.mov", "-", "--demo", "-name.png"]);
-        assert!(!a.demo);
-    }
-
-    /// `--control` wins over `EFFECTCRAFT_CONTROL_PORT`; the variable is checked only when it is
-    /// used, so a bad value doesn't stop `--control` or `--version`.
-    #[test]
-    fn control_port_variable_is_checked_when_used() {
-        let need = "EFFECTCRAFT_CONTROL_PORT needs a port number from 1 to 65535";
-        assert_eq!(parse_env(&[], Ok("abc".into())).unwrap_err(), format!("{need}, not `abc`"));
-        assert_eq!(parse_env(&[], Ok("0".into())).unwrap_err(), format!("{need}, not `0`"));
-        assert_eq!(parse_env(&[], Ok(String::new())), Ok(Some(Args::default())));
-        assert_eq!(parse_env(&["--control", "9877"], Ok("abc".into())).unwrap().map(|a| a.control_port), Some(Some(9877)));
-        assert_eq!(parse_env(&["--version"], Ok("abc".into())), Ok(None));
-        #[cfg(unix)]
-        {
-            use std::os::unix::ffi::OsStringExt;
-            let not_utf8 = VarError::NotUnicode(std::ffi::OsString::from_vec(vec![0x39, 0xff]));
-            assert_eq!(parse_env(&[], Err(not_utf8)).unwrap_err(), format!("{need}, not `9\u{fffd}`"));
-        }
-    }
-
-    /// #1275: a blocking desktop-portal dialog on Linux leaves the window not answering the
-    /// compositor while it is open, so the commands' file dialogs go through the non-blocking
-    /// host hook there (as they do on macOS, #511). Windows' dialog pumps its own messages.
-    #[test]
-    fn commands_file_dialogs_do_not_block_the_frame() {
-        let mut app = effectcraft_ui_egui::EffectcraftApp::new(effectcraft_engine::Session::default());
-        super::install_file_dialogs(&mut app, &egui::Context::default());
-        #[cfg(any(target_os = "macos", target_os = "linux"))]
-        assert!(app.hooks.file_dialog_async.is_some(), "the commands' file dialogs must not block the frame");
-        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
-        assert!(app.hooks.file_dialog_async.is_none(), "the blocking dialog pumps its own messages here");
-    }
+    effectcraft::run()
 }
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -169,6 +169,24 @@ pub struct Hooks {
     /// a drag over the window (#511), and on Linux a blocking desktop-portal dialog never pumps
     /// events, so the compositor marks the window as not responding (#1275).
     pub file_dialog_async: Option<Box<dyn Fn(FileDialog, DialogDone)>>,
+    /// The app-private storage directory on Android (`AndroidApp::internal_data_path`), where the
+    /// in-app Save / Open Project prompts read and write. `None` elsewhere (or when it could not
+    /// be resolved): the commands fail with "no file dialog available", as before.
+    #[cfg(target_os = "android")]
+    pub app_files_dir: Option<std::path::PathBuf>,
+    /// Android's shared internal-storage root (`/storage/emulated/0`), offered as a second
+    /// browse root beside the app-private directory once "All files access" is granted.
+    /// `None` elsewhere (or when it could not be resolved).
+    #[cfg(target_os = "android")]
+    pub shared_storage_root: Option<std::path::PathBuf>,
+    /// Whether Android's "All files access" (`MANAGE_EXTERNAL_STORAGE`) is currently granted.
+    /// `None` elsewhere.
+    #[cfg(target_os = "android")]
+    pub shared_storage_granted: Option<Box<dyn Fn() -> bool>>,
+    /// Asks the user to grant "All files access" (the system settings page on Android 11+, the
+    /// permission dialog before that). `None` elsewhere.
+    #[cfg(target_os = "android")]
+    pub shared_storage_grant: Option<Box<dyn Fn()>>,
     /// Tells the user the graphics device was lost and the window is closing, once. `saved` is
     /// the auto-save of unsaved changes (`None`: nothing was unsaved), its path or why it failed.
     pub device_lost: Option<Box<dyn Fn(Option<Result<String, String>>)>>,
