Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4375f66
feat(updater): add filmcraft-updater crate for crash-resilient self-u…
ARYAN007H Oct 13, 2026
7aad46a
feat(ui,app): wire self-updater CLI helper, startup hooks, and menus
ARYAN007H Oct 13, 2026
785a315
contributors: add ARYAN007H
ARYAN007H Oct 13, 2026
b4b3fc3
fix(ui): ensure manual update check bypasses throttle and ready state…
ARYAN007H Oct 14, 2026
721bd16
Merge branch 'storytold:main' into main
ARYAN007H Oct 15, 2026
876920b
app: drop unrelated MimeType hunk from dev desktop entry
claude Oct 20, 2026
1d67be3
updater: sync Cargo.lock with workspace version 0.8.0
claude Oct 20, 2026
787bdbf
ui: translate self-update strings
claude Oct 20, 2026
e4373ca
docs(updater): describe the app wiring and remaining gaps
claude Oct 20, 2026
eb08f7d
fix(updater): always relaunch after apply and on rollback
claude Oct 20, 2026
3b37e1b
Merge remote-tracking branch 'upstream/main'
claude Oct 20, 2026
15d2b52
updater: sync Cargo.lock with workspace version 0.10.0
claude Oct 20, 2026
587c846
fix(ui): hide update badge while jobs run and move help.checkForUpdat…
claude Oct 20, 2026
c7a4e4b
fix(updater): roll back binary swap if writing update state fails
claude Oct 20, 2026
9223836
fix(updater): restart with the launch arguments after an update
claude Oct 21, 2026
2d557e4
Merge branch 'storytold:main' into main
ARYAN007H Oct 22, 2026
d677725
Merge storytold/main: adapt the updater to the ui-egui module split
ARYAN007H Oct 28, 2026
5a5ca0a
fix(updater): address the CodeRabbit review findings
ARYAN007H Oct 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ filmcraft-engine = { path = "crates/engine" }
filmcraft-ui-egui = { path = "crates/ui-egui" }
filmcraft-automation = { path = "crates/automation" }
filmcraft-platform = { path = "crates/platform" }
filmcraft-updater = { path = "crates/updater" }
filmcraft-testkit = { path = "crates/testkit" }

# foundation
Expand Down
2 changes: 2 additions & 0 deletions apps/filmcraft/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ filmcraft-time = { workspace = true }
filmcraft-gpu = { workspace = true }
filmcraft-ui-egui = { workspace = true }
filmcraft-platform = { workspace = true }
filmcraft-updater = { workspace = true }
egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11"] }
serde_json = { workspace = true }
Expand All @@ -37,6 +38,7 @@ cpal = "0.16"
# Files and images on the system clipboard for Paste (#611); egui already uses it for text.
arboard = "3"
chrono = { workspace = true, features = ["clock"] }
semver = "1"
sys-locale = "0.3"
# Help menu / About / Discord links in the system browser (MIT; as PhotoCraft).
open = "5"
Expand Down
175 changes: 175 additions & 0 deletions apps/filmcraft/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,33 @@ Options:
`--graphics default` undoes an automatic fallback
--version print the version and exit
-h, --help print this help and exit

Internal updater helper (started by FilmCraft only):
--update-apply --pid <pid> --staged <dir> --target <path> [-- <launch arguments...>]
";

/// What the command line asks for.
#[derive(Debug, PartialEq)]
pub enum Cli {
Run(Launch),
/// The short-lived process that replaces an already staged update after its parent exits.
UpdateApply(UpdateApply),
Help,
Version,
}

/// Arguments required by `--update-apply`. All paths stay as `OsString`-backed `PathBuf`s.
#[derive(Clone, Debug, PartialEq)]
pub struct UpdateApply {
pub pid: u32,
pub staged: PathBuf,
pub target: PathBuf,
/// The command line the editor itself was started with, verbatim (everything after `--`). The
/// restart hands it back to the new build, so `--data-dir`, `--empty`, `--control`, project and
/// media paths survive the update. It is never interpreted here.
pub launch_args: Vec<OsString>,
}

/// Options of a normal start.
#[derive(Clone, Debug, PartialEq)]
pub struct Launch {
Expand Down Expand Up @@ -62,6 +79,10 @@ pub struct Launch {
/// in Latin-1), and `std::env::args` panics on one. A file name that is not is passed on lossily
/// (its import then reports it missing); `--data-dir` keeps the folder exactly.
pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = OsString>) -> Result<Cli, String> {
let args: Vec<OsString> = args.into_iter().collect();
if update_apply_requested(&args) {
return parse_update_apply(args);
}
let mut l = Launch {
control_port: None,
control_input_only: false,
Expand Down Expand Up @@ -119,6 +140,83 @@ pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = Os
Ok(Cli::Run(l))
}

/// `--` protects a media file named `--update-apply`, just as it protects every other option.
fn update_apply_requested(args: &[OsString]) -> bool {
for arg in args {
let arg = arg.to_string_lossy();
if arg == "--" {
return false;
}
if arg == "--update-apply" {
return true;
}
}
false
}

/// Parse the updater's intentionally narrow helper protocol. It refuses extra arguments so a
/// malformed command can never accidentally fall through to normal GUI startup. The one open part
/// is the optional `-- <launch arguments...>` tail, which is carried through untouched.
fn parse_update_apply(args: Vec<OsString>) -> Result<Cli, String> {
let mut update_apply = false;
let mut pid = None;
let mut staged = None;
let mut target = None;
let mut launch_args = Vec::new();
let mut args = args.into_iter();
while let Some(arg) = args.next() {
match arg.to_string_lossy().as_ref() {
// Everything after `--` is the editor's own command line, kept exactly as given (it may
// hold `--update-apply` or a non-Unicode file name) and handed to the restarted build.
"--" => launch_args = args.by_ref().collect(),
"--update-apply" if !update_apply => update_apply = true,
"--update-apply" => return Err("--update-apply was passed more than once".into()),
"--pid" => {
if pid.is_some() {
return Err("--pid was passed more than once".into());
}
let value = args.next().ok_or("--pid needs a process id")?;
let value = value.to_string_lossy();
let parsed: u32 = value.parse().map_err(|_| format!("--pid needs a positive process id, got `{value}`"))?;
if parsed == 0 {
return Err("--pid needs a positive process id".into());
}
pid = Some(parsed);
}
"--staged" => {
if staged.is_some() {
return Err("--staged was passed more than once".into());
}
let value = args.next().ok_or("--staged needs a folder")?;
if value.is_empty() {
return Err("--staged needs a folder".into());
}
staged = Some(PathBuf::from(value));
}
"--target" => {
if target.is_some() {
return Err("--target was passed more than once".into());
}
let value = args.next().ok_or("--target needs an executable path")?;
if value.is_empty() {
return Err("--target needs an executable path".into());
}
target = Some(PathBuf::from(value));
}
other => return Err(format!("unknown updater helper option `{other}`")),
}
}
if !update_apply {
return Err("missing --update-apply".into());
}
Ok(Cli::UpdateApply(UpdateApply {
pid: pid.ok_or("--update-apply needs --pid")?,
staged: staged.ok_or("--update-apply needs --staged")?,
target: target.ok_or("--update-apply needs --target")?,
launch_args,
}))
}

/// Whether a launch file is a FilmCraft project. The extension's case does not matter: Windows file
/// associations and the Linux MIME glob start the app for `CUT.FCPROJ` too (#850).
pub fn is_project(path: &str) -> bool {
Expand Down Expand Up @@ -185,6 +283,83 @@ mod tests {
}
}

#[test]
fn update_apply_requires_the_complete_helper_protocol() {
let parsed = parse_args(&["--update-apply", "--pid", "42", "--staged", "/tmp/staged", "--target", "/opt/filmcraft/bin/filmcraft"]);
assert_eq!(
parsed,
Ok(Cli::UpdateApply(UpdateApply {
pid: 42,
staged: PathBuf::from("/tmp/staged"),
target: PathBuf::from("/opt/filmcraft/bin/filmcraft"),
launch_args: Vec::new(),
}))
);
for args in [
vec!["--update-apply"],
vec!["--update-apply", "--pid", "0", "--staged", "/tmp/a", "--target", "/tmp/b"],
vec!["--update-apply", "--pid", "1", "--staged", "/tmp/a", "--target", "/tmp/b", "clip.mov"],
] {
assert!(parse_args(&args).is_err(), "{args:?}");
}
}

fn helper_line(launch: &[&str]) -> Vec<String> {
let mut line: Vec<String> =
["--update-apply", "--pid", "42", "--staged", "/tmp/staged", "--target", "/opt/filmcraft/bin/filmcraft", "--"].map(String::from).to_vec();
line.extend(launch.iter().map(|a| a.to_string()));
line
}

fn helper(line: &[String]) -> UpdateApply {
let line: Vec<&str> = line.iter().map(String::as_str).collect();
match parse_args(&line) {
Ok(Cli::UpdateApply(apply)) => apply,
other => panic!("{line:?} should be the updater helper, got {other:?}"),
}
}

/// The restart after an update must reopen the editor the way it was started: a custom
/// `--data-dir` (its recovery journal was just flushed there), `--empty`, project and media paths.
#[test]
fn update_apply_hands_the_launch_arguments_to_the_restart() {
let launch = ["--data-dir", "/custom/path", "--empty", "--control", "7777", "cut.fcproj", "clip.mov"];
let apply = helper(&helper_line(&launch));
assert_eq!(apply.launch_args, launch.iter().map(OsString::from).collect::<Vec<_>>());

// The restarted editor parses them again and gets the same launch.
let Ok(Cli::Run(l)) = parse(None, apply.launch_args) else { panic!("the launch arguments should start the app") };
assert_eq!(l.data_dir, Some(PathBuf::from("/custom/path")));
assert_eq!((l.demo, l.startup_flag), (false, true), "--empty");
assert_eq!(l.control_port, Some(7777));
assert_eq!(l.files, vec!["cut.fcproj".to_string(), "clip.mov".to_string()]);
}

/// Nothing after `--` is read as a helper option: a media file may even be called `--update-apply`.
#[test]
fn update_apply_launch_arguments_are_not_interpreted() {
let launch = ["--", "--update-apply", "--pid", "1", "--bogus"];
let apply = helper(&helper_line(&launch));
assert_eq!(apply.pid, 42);
assert_eq!(apply.launch_args, launch.iter().map(OsString::from).collect::<Vec<_>>());
// and without a tail the helper still restarts with no arguments
assert!(helper(&helper_line(&[])).launch_args.is_empty());
}

/// A Linux file name need not be Unicode; the restart must pass it on byte for byte.
#[cfg(unix)]
#[test]
fn update_apply_keeps_a_non_unicode_launch_argument_exactly() {
use std::os::unix::ffi::OsStringExt;
let latin1 = OsString::from_vec(b"caf\xe9.mov".to_vec());
let mut line: Vec<OsString> = ["--update-apply", "--pid", "42", "--staged", "/s", "--target", "/t", "--", "--data-dir"].map(OsString::from).to_vec();
line.push(OsString::from_vec(b"/data/\xe9t\xe9".to_vec()));
line.push(latin1.clone());
let Ok(Cli::UpdateApply(apply)) = parse(None, line) else { panic!("should be the updater helper") };
assert_eq!(apply.launch_args[2], latin1);
assert_eq!(apply.launch_args[1], OsString::from_vec(b"/data/\xe9t\xe9".to_vec()));
}

#[test]
fn unknown_options_are_errors() {
assert_eq!(parse_args(&["--frobnicate"]), Err("unknown option `--frobnicate`".to_string()));
Expand Down
Loading