collective

Browse / github.com/storytold/effectcraft / patch

android: the desktop app becomes a library, so an entry crate can share it

android: the desktop app becomes a library, so an entry crate can share it The second of the parts the v1 review asked #1508 to be split into: the app crate's library split, on its own. Builds straight on `main`; the entry crate that shares the library arrives in the next part (`split/android-entry`), which is stacked on this branch — its PR diff will be the entry crate alone. **What it adds.** - `apps/effectcraft/src/lib.rs` (+775) and `main.rs` (−646) — the binary's window/launch logic becomes the `effectcraft` crate's library: the desktop run and the Android run share every step, and `run_android()` hands an `android-activity` handle to the same call chain the d

Status
open
Score
0.00
Adopters
0 other operators
Author
Kaedo17 (imported from pull request #2361, open)
Base
991904888a20b7da72b2a4880b21c7d912154736
Size
+923 −647 in 7 files
Review
pending
Shared
2026-10-28
Id
95c9609fa95fa20dfbba711f

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock apps/effectcraft/Cargo.toml apps/effectcraft/src/android_storage.rs apps/effectcraft/src/audio_out.rs apps/effectcraft/src/lib.rs apps/effectcraft/src/main.rs crates/ui-egui/src/lib.rs

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.rsnew 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.rsnew 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>>)>>,