mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-26 02:57:34 -06:00
- assets/app-icon/: master and light SVGs, 1024 px PNG, .icns (Apple grid), .ico (16-256), hicolor 16-512 + scalable, LICENSE.txt, README with palette (#e0368f / #efe9dc / #0b0b0c), tile geometry and provenance; attribution sidecars + ATTRIBUTION.md rows. - packaging/icons.sh regenerates everything with resvg, iconutil and the new `cargo xtask ico`. - Runtime icon via ViewportBuilder::with_icon (padded variant on macOS) and Wayland app ID ai.storyteller.effectcraft; packaging/linux/ai.storyteller.effectcraft.desktop. - apps/effectcraft/build.rs embeds the .ico and VERSIONINFO into effectcraft.exe (winresource, Windows targets only; a missing resource compiler is a warning). - Web favicon.svg is now the unicorn icon. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
158 lines
7.5 KiB
Rust
158 lines
7.5 KiB
Rust
//! EffectCraft desktop app.
|
|
//!
|
|
//! Usage: `effectcraft [--control <port>] [--demo|--empty|--home] [project.ecproj | media files…]`
|
|
//!
|
|
//! `--control <port>` (or `EFFECTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
|
|
//! see `effectcraft_ui_egui::control` for the methods.
|
|
|
|
mod audio_out;
|
|
mod control_server;
|
|
|
|
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.
|
|
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] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png");
|
|
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
|
|
}
|
|
|
|
fn main() -> eframe::Result {
|
|
let mut control_port: Option<u16> = std::env::var("EFFECTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
|
let mut files = Vec::new();
|
|
let mut demo = true;
|
|
let mut home: Option<bool> = None;
|
|
let mut args = std::env::args().skip(1);
|
|
while let Some(a) = args.next() {
|
|
match a.as_str() {
|
|
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
|
"--demo" => demo = true,
|
|
"--empty" => demo = false,
|
|
"--home" => home = Some(true),
|
|
"--version" => {
|
|
println!("effectcraft {}", env!("CARGO_PKG_VERSION"));
|
|
return Ok(());
|
|
}
|
|
_ => files.push(a),
|
|
}
|
|
}
|
|
let options = eframe::NativeOptions {
|
|
viewport: egui::ViewportBuilder::default()
|
|
.with_title("EffectCraft")
|
|
.with_inner_size([1680.0, 1020.0])
|
|
.with_min_inner_size([960.0, 600.0])
|
|
.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("ai.storyteller.effectcraft"),
|
|
event_loop_builder: agent_event_loop(control_port.is_some()),
|
|
..Default::default()
|
|
};
|
|
eframe::run_native(
|
|
"EffectCraft",
|
|
options,
|
|
Box::new(move |cc| {
|
|
let mut session = effectcraft_host::session();
|
|
// 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) = config_dir() {
|
|
session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir)));
|
|
}
|
|
session.load_settings();
|
|
let recovery = if control_port.is_none() { session.begin_recovery() } else { None };
|
|
let project = files.iter().find(|f| f.ends_with(".ecproj")).cloned();
|
|
if let Some(p) = project {
|
|
if let Err(e) = session.execute("file.open", json!({"path": p})) {
|
|
eprintln!("effectcraft: {e}");
|
|
}
|
|
} else if demo {
|
|
let _ = session.execute("file.openDemoProject", json!({}));
|
|
}
|
|
let media: Vec<String> = files.iter().filter(|f| !f.ends_with(".ecproj")).cloned().collect();
|
|
if !media.is_empty()
|
|
&& let Err(e) = session.execute("file.import", json!({"paths": media}))
|
|
{
|
|
eprintln!("effectcraft: {e}");
|
|
}
|
|
let show_home = home.unwrap_or(session.prefs.startup.show_home_on_launch && files.is_empty() && control_port.is_none());
|
|
let mut app = EffectcraftApp::new(session);
|
|
app.ui.start_screen = show_home;
|
|
if let Some(r) = recovery {
|
|
app.offer_recovery(r);
|
|
}
|
|
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())
|
|
}));
|
|
app.hooks.pick_open_project =
|
|
Some(Box::new(|| rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj"]).pick_file().map(|p| p.to_string_lossy().to_string())));
|
|
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| {
|
|
rfd::FileDialog::new().add_filter(ext, &[ext]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
|
|
}));
|
|
if let Some(port) = control_port {
|
|
disable_app_nap();
|
|
let rx = control_server::start(port, cc.egui_ctx.clone());
|
|
app = app.with_control(rx);
|
|
}
|
|
Ok(Box::new(app))
|
|
}),
|
|
)
|
|
}
|
|
|
|
/// The platform config directory for EffectCraft (`EFFECTCRAFT_CONFIG_DIR` overrides):
|
|
/// `~/Library/Application Support/EffectCraft` (macOS), `%APPDATA%\EffectCraft` (Windows),
|
|
/// `$XDG_CONFIG_HOME/effectcraft` or `~/.config/effectcraft` (Linux and others).
|
|
fn config_dir() -> Option<std::path::PathBuf> {
|
|
use std::path::PathBuf;
|
|
if let Some(d) = std::env::var_os("EFFECTCRAFT_CONFIG_DIR") {
|
|
return Some(PathBuf::from(d));
|
|
}
|
|
let home = || std::env::var_os("HOME").map(PathBuf::from);
|
|
if cfg!(target_os = "macos") {
|
|
return home().map(|h| h.join("Library/Application Support/EffectCraft"));
|
|
}
|
|
if cfg!(target_os = "windows") {
|
|
return std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("EffectCraft"));
|
|
}
|
|
std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| home().map(|h| h.join(".config"))).map(|c| c.join("effectcraft"))
|
|
}
|
|
|
|
/// 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));
|
|
}
|
|
}
|