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
@@ -1174,6 +1174,7 @@ dependencies = [ name = "effectcraft" version = "0.16.0" dependencies = [+ "android-activity", "cpal", "effectcraft-engine", "effectcraft-host",@@ -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.new file mode 100644@@ -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);+ }+}@@ -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());new file mode 100644@@ -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");+ }+}@@ -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() }@@ -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>>)>>,