Files
effectcraft/apps/effectcraft/src/main.rs
T

706 lines
36 KiB
Rust

//! 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.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
// 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(std::ffi::OsStr::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.
/// The dialogs asked for in a frame show at its end ([`sheet_dialogs::show_asked`]), where the
/// main window's handle is at hand.
fn install_file_dialogs(app: &mut EffectcraftApp) -> Option<std::sync::mpsc::Receiver<sheet_dialogs::Asked>> {
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
let (tx, rx) = std::sync::mpsc::channel::<sheet_dialogs::Asked>();
app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| {
if tx.send((dialog, done)).is_err() {
log::error!("file dialog: the window is gone");
}
}));
Some(rx)
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
let _ = app;
None
}
}
/// Run a normal desktop launch: install logging and the panic hook, parse arguments (`--version` exits before the
/// session log is attached), scan fonts, honor the launch guard's backend choice and open the eframe window.
/// Builds the session, loads settings, plugins, the requested project/media and the optional control server, then
/// reports startup failure through `startup_failed`. Returns the event loop's result.
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::EFFECTCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// First, so the panic hook and every start-up warning are recorded. 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| {
EffectcraftApp::install_ime_reset_hooks(&cc.egui_ctx);
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));
// Scripts in Scripts/Startup run before a project opens (a failing one is reported in
// a toast).
let _ = session.execute("file.runStartupScripts", json!({}));
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_startup_update_check(true);
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`].
let file_dialogs = install_file_dialogs(&mut app);
#[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,
file_dialogs,
window: window::Tracker::new(opened, saved_window, control_port.is_none()),
launch,
drawn: false,
}))
}),
);
if let Err(e) = &result {
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.
///
/// The surface is eframe's own default, [`eframe::SurfaceConfig::LOW_LATENCY`]: vsync with one
/// frame in flight, so a drag or a scrub shows one frame sooner. The bare
/// `WgpuConfiguration::default()` is `HIGH_THROUGHPUT` instead (two frames in flight, three
/// drawables on Metal): one more frame of pointer-to-screen lag on every interaction, about 8 ms
/// at 120 Hz and 17 ms at 60 Hz, and a third 15 MB drawable held for the window.
fn wgpu_options(backends: Option<eframe::wgpu::Backends>) -> eframe::WgpuConfiguration {
use eframe::egui_wgpu::WgpuSetup;
let mut config = eframe::WgpuConfiguration::default().with_surface_config(eframe::SurfaceConfig::LOW_LATENCY);
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,
/// The commands' file dialogs, shown at the end of the frame that asked for them.
file_dialogs: Option<std::sync::mpsc::Receiver<sheet_dialogs::Asked>>,
#[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 {
/// The desktop host's logic tick: update the macOS menu bar, run the shared app tick, close the IME pass, show the
/// file dialogs asked for and, last, hand a close to the platform quit.
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
self.menu.update(&mut self.app, ctx);
self.app.tick(ctx, frame);
self.app.finish_ime_logic(ctx);
if let Some(asked) = &self.file_dialogs {
sheet_dialogs::show_asked(asked, frame, ctx);
}
effectcraft_ui_egui::panels::unsaved::quit_on_close(ctx);
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.app.ui(ui, frame);
if let Some(asked) = &self.file_dialogs {
sheet_dialogs::show_asked(asked, frame, ui.ctx());
}
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) {
// On macOS the app quits through `-[NSApplication terminate:]`, and the process ends
// before `eframe::run_native` returns (#341).
log::info!("the window is closing");
self.launch.exited();
self.window.save(self.app.session.config.as_deref());
self.app.on_exit();
// Scripts in Scripts/Shutdown run with the project closed.
let _ = self.app.session.execute("file.runShutdownScripts", json!({}));
}
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 surface keeps one frame in flight, like eframe's own default, not the two
/// of the bare `WgpuConfiguration::default()` (one extra frame of lag on every drag).
#[test]
fn the_window_surface_is_low_latency() {
for backends in [None, Some(eframe::wgpu::Backends::METAL), Some(eframe::wgpu::Backends::GL)] {
let surface = super::wgpu_options(backends).surface;
assert_eq!(surface, eframe::SurfaceConfig::LOW_LATENCY, "{backends:?}");
assert_eq!(surface.desired_maximum_frame_latency, Some(1));
assert_eq!(surface.present_mode, eframe::wgpu::PresentMode::AutoVsync);
}
assert_eq!(super::wgpu_options(None).surface, eframe::NativeOptions::default().wgpu_options.surface, "eframe's default surface");
}
/// 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());
let asked = super::install_file_dialogs(&mut app);
#[cfg(any(target_os = "macos", target_os = "linux"))]
assert!(app.hooks.file_dialog_async.is_some() && asked.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() && asked.is_none(), "the blocking dialog pumps its own messages here");
}
}