#850: launch files open whatever the extension's case, with absolute paths
Problem: the desktop app picked the project among its launch files with a
case-sensitive ends_with(".fcproj"), so CUT.FCPROJ opened through a Windows
file association or the Linux MIME glob was imported as media. Relative
launch paths were stored as given and went missing from another folder.
std::env::args panicked on a non-Unicode argument. filmcraft-cli import
used canonicalize, which stores \\?\ verbatim paths on Windows.
Fix: args::project_and_media finds the project by a case-insensitive
extension and makes every launch path absolute with std::path::absolute
(no verbatim prefix, symlinks kept). Both apps read args_os; the desktop
app keeps --data-dir exactly. filmcraft-cli import uses std::path::absolute.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2ff3f5e266
commit
8e61e610cc
|
|
@ -244,6 +244,13 @@ fn register_hardware_decoders() -> filmcraft_platform::Availability {
|
|||
filmcraft_platform::register()
|
||||
}
|
||||
|
||||
/// `path` made absolute against the working directory, as the project stores it. Unlike
|
||||
/// `canonicalize`, `std::path::absolute` keeps symlinks and adds no Windows verbatim `\\?\`
|
||||
/// prefix (#850). A path it cannot resolve is kept as given.
|
||||
fn absolute(path: &str) -> String {
|
||||
std::path::absolute(path).ok().and_then(|p| p.into_os_string().into_string().ok()).unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
cli().await;
|
||||
|
|
@ -253,7 +260,8 @@ async fn main() {
|
|||
}
|
||||
|
||||
async fn cli() {
|
||||
let a = Args::parse(std::env::args().skip(1));
|
||||
// `std::env::args` panics on an argument that is not Unicode (a Linux file name in Latin-1)
|
||||
let a = Args::parse(std::env::args_os().skip(1).map(|a| a.to_string_lossy().into_owned()));
|
||||
// `--help` anywhere (`filmcraft-cli --help`, `filmcraft-cli export --help`) prints the reference:
|
||||
// the parser takes it as a flag option, so it never reaches the subcommand match.
|
||||
if a.flag("--help") {
|
||||
|
|
@ -373,8 +381,7 @@ async fn cli() {
|
|||
print(&a, &v);
|
||||
}
|
||||
"import" => {
|
||||
let paths: Vec<String> =
|
||||
a.positionals[1..].iter().map(|p| std::fs::canonicalize(p).map(|c| c.to_string_lossy().into_owned()).unwrap_or_else(|_| p.clone())).collect();
|
||||
let paths: Vec<String> = a.positionals.get(1..).unwrap_or_default().iter().map(|p| absolute(p)).collect();
|
||||
if paths.is_empty() {
|
||||
usage("import <file>...");
|
||||
}
|
||||
|
|
@ -590,6 +597,27 @@ mod format_tests {
|
|||
assert!(p.get("range").is_none());
|
||||
}
|
||||
|
||||
/// `import` used `canonicalize`, which resolves symlinks and on Windows stores `\\?\C:\…`
|
||||
/// verbatim paths in the project (#850).
|
||||
#[test]
|
||||
fn imported_paths_are_absolute_without_resolving_links() {
|
||||
let cwd = std::env::current_dir().unwrap();
|
||||
assert_eq!(super::absolute("clip.mov"), cwd.join("clip.mov").to_string_lossy());
|
||||
assert!(!super::absolute("clip.mov").starts_with(r"\\?\"));
|
||||
assert_eq!(super::absolute(""), "");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let dir = std::env::temp_dir().join(format!("filmcraft-cli-abs-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("real.mov"), b"x").unwrap();
|
||||
let link = dir.join("link.mov");
|
||||
let _ = std::fs::remove_file(&link);
|
||||
std::os::unix::fs::symlink(dir.join("real.mov"), &link).unwrap();
|
||||
assert_eq!(super::absolute(&link.to_string_lossy()), link.to_string_lossy());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_format_from_extension() {
|
||||
assert_eq!(super::format_for("out/clip.MXF"), Some("mxf-op1a"));
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
//! The desktop app's command line (`filmcraft --help`).
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// `filmcraft --help`.
|
||||
pub const USAGE: &str = "\
|
||||
Usage: filmcraft [options] [project.fcproj | media files...]
|
||||
|
|
@ -45,29 +48,35 @@ pub struct Launch {
|
|||
/// mistyped flag helps nobody, and starting without the control server that was asked for leaves
|
||||
/// a script waiting for it. That holds for the whole line, so `--version --bogus` is an error
|
||||
/// too, and for a port from the environment exactly as for one from `--control`.
|
||||
pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = String>) -> Result<Cli, String> {
|
||||
///
|
||||
/// The arguments come from `std::env::args_os`: not every argument is Unicode (a Linux file name
|
||||
/// 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 mut l = Launch { control_port: None, files: Vec::new(), demo: true, startup_flag: false, recover: None, data_dir: None };
|
||||
let (mut help, mut version) = (false, false);
|
||||
let mut args = args.into_iter();
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
while let Some(raw) = args.next() {
|
||||
let a = raw.to_string_lossy();
|
||||
match a.as_ref() {
|
||||
"-h" | "--help" => help = true,
|
||||
"--version" => version = true,
|
||||
"--control" => {
|
||||
let port = args.next().ok_or("--control needs a port number")?;
|
||||
let port = port.to_string_lossy();
|
||||
l.control_port = Some(port.parse().map_err(|_| format!("--control needs a port number, got `{port}`"))?);
|
||||
}
|
||||
"--demo" => (l.demo, l.startup_flag) = (true, true),
|
||||
"--empty" => (l.demo, l.startup_flag) = (false, true),
|
||||
"--recover" => l.recover = Some(true),
|
||||
"--no-recover" => l.recover = Some(false),
|
||||
"--data-dir" => l.data_dir = Some(args.next().map(std::path::PathBuf::from).ok_or("--data-dir needs a folder")?),
|
||||
"--data-dir" => l.data_dir = Some(args.next().map(PathBuf::from).ok_or("--data-dir needs a folder")?),
|
||||
// everything after `--` is a file, whatever it looks like
|
||||
"--" => l.files.extend(args.by_ref()),
|
||||
"--" => l.files.extend(args.by_ref().map(|f| f.to_string_lossy().into_owned())),
|
||||
// macOS adds a process serial number when it launches an app bundle
|
||||
_ if a.starts_with("-psn_") => {}
|
||||
_ if a.len() > 1 && a.starts_with('-') => return Err(format!("unknown option `{a}`")),
|
||||
_ => l.files.push(a),
|
||||
_ => l.files.push(a.into_owned()),
|
||||
}
|
||||
}
|
||||
if help {
|
||||
|
|
@ -84,12 +93,34 @@ pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = St
|
|||
Ok(Cli::Run(l))
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("fcproj"))
|
||||
}
|
||||
|
||||
/// `path` made absolute against the working directory, so a project or media file named relative
|
||||
/// to a terminal's folder is still found once the app runs from another one (Recent Projects, the
|
||||
/// media paths saved in the project). Unlike `canonicalize`, `std::path::absolute` keeps symlinks
|
||||
/// and adds no Windows verbatim `\\?\` prefix. A path it cannot resolve is kept as given.
|
||||
pub fn absolute(path: &str) -> String {
|
||||
std::path::absolute(path).ok().and_then(|p| p.into_os_string().into_string().ok()).unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
/// The launch files as the project to open (the first one, see [`is_project`]) and the media files
|
||||
/// to import (every file that is not a project), all made [`absolute`].
|
||||
pub fn project_and_media(files: &[String]) -> (Option<String>, Vec<String>) {
|
||||
let project = files.iter().find(|f| is_project(f)).map(|f| absolute(f));
|
||||
let media = files.iter().filter(|f| !is_project(f)).map(|f| absolute(f)).collect();
|
||||
(project, media)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_env(env: Option<&str>, args: &[&str]) -> Result<Cli, String> {
|
||||
parse(env.map(str::to_string), args.iter().map(|a| a.to_string()))
|
||||
parse(env.map(str::to_string), args.iter().map(OsString::from))
|
||||
}
|
||||
|
||||
fn parse_args(args: &[&str]) -> Result<Cli, String> {
|
||||
|
|
@ -168,4 +199,50 @@ mod tests {
|
|||
assert_eq!(launch(&["--", "--odd name.mov", "-h"]).files, ["--odd name.mov", "-h"]);
|
||||
assert_eq!(launch(&["-", "-psn_0_12345"]).files, ["-"]);
|
||||
}
|
||||
|
||||
/// `std::env::args` panics on an argument that is not Unicode, so such a file name (Latin-1 on
|
||||
/// Linux) stopped the app before its window opened.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn arguments_that_are_not_unicode_still_start_the_app() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let dir = OsString::from_vec(b"/tmp/d\xe9".to_vec());
|
||||
let file = OsString::from_vec(b"caf\xe9.mov".to_vec());
|
||||
let Ok(Cli::Run(l)) = parse(None, [OsString::from("--data-dir"), dir.clone(), file.clone(), OsString::from("--"), file]) else {
|
||||
panic!("should start the app");
|
||||
};
|
||||
assert_eq!(l.data_dir, Some(PathBuf::from(dir)), "the data folder is kept exactly");
|
||||
assert_eq!(l.files, ["caf\u{fffd}.mov", "caf\u{fffd}.mov"]);
|
||||
}
|
||||
|
||||
/// Windows file associations and the Linux MIME glob match `CUT.FCPROJ` too; it used to be
|
||||
/// imported as a media file instead of opened (#850).
|
||||
#[test]
|
||||
fn a_project_is_recognised_whatever_the_case_of_its_extension() {
|
||||
for p in ["cut.fcproj", "CUT.FCPROJ", "Cut.FcProj", "/edits/a b.fcproj", r"C:\Edits\CUT.FCPROJ"] {
|
||||
assert!(is_project(p), "{p}");
|
||||
}
|
||||
for p in ["cut.mp4", "fcproj", ".fcproj", "a.fcproj.mov", "a.fcprojx", ""] {
|
||||
assert!(!is_project(p), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Relative launch paths were opened and imported as given, so the project's media paths and
|
||||
/// Recent Projects pointed nowhere once the app ran from another folder (#850).
|
||||
#[test]
|
||||
fn launch_files_are_made_absolute_and_split_case_insensitively() {
|
||||
let cwd = std::env::current_dir().unwrap();
|
||||
let nested = Path::new("clips").join("a.mp4");
|
||||
let already = cwd.join("b.wav").to_string_lossy().into_owned();
|
||||
let files = ["Cut.FCPROJ".to_string(), nested.to_string_lossy().into_owned(), "other.fcproj".to_string(), already.clone()];
|
||||
let (project, media) = project_and_media(&files);
|
||||
assert_eq!(project, Some(cwd.join("Cut.FCPROJ").to_string_lossy().into_owned()));
|
||||
assert_eq!(media, [cwd.join(&nested).to_string_lossy().into_owned(), already]);
|
||||
for p in media.iter().chain(&project) {
|
||||
assert!(Path::new(p).is_absolute() && !p.starts_with(r"\\?\"), "{p}");
|
||||
}
|
||||
assert_eq!(project_and_media(&[]), (None, vec![]));
|
||||
// nothing to resolve against: kept as given rather than dropped
|
||||
assert_eq!(absolute(""), "");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ fn main() -> eframe::Result {
|
|||
let logger = logging::install();
|
||||
// lossy, so a value that is not Unicode is reported like any other bad port instead of ignored
|
||||
let env_port = std::env::var_os("FILMCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned());
|
||||
let Launch { control_port, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args().skip(1)) {
|
||||
let Launch { control_port, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args_os().skip(1)) {
|
||||
Ok(Cli::Run(l)) => l,
|
||||
Ok(Cli::Help) => {
|
||||
tell(args::USAGE, false);
|
||||
|
|
@ -157,7 +157,7 @@ fn main() -> eframe::Result {
|
|||
}
|
||||
// voice-over recording reads the microphone through cpal
|
||||
session.voiceover.input = Some(Box::new(audio_in::CpalIn::new(&session.prefs.audio_hardware.device_class)));
|
||||
let project = files.iter().find(|f| f.ends_with(".fcproj")).cloned();
|
||||
let (project, media) = args::project_and_media(&files);
|
||||
if let Some(p) = project {
|
||||
if let Err(e) = session.execute("file.open", json!({"path": p})) {
|
||||
log::error!("{e}");
|
||||
|
|
@ -179,7 +179,6 @@ fn main() -> eframe::Result {
|
|||
} else if demo {
|
||||
let _ = session.execute("file.openDemoProject", json!({}));
|
||||
}
|
||||
let media: Vec<String> = files.iter().filter(|f| !f.ends_with(".fcproj")).cloned().collect();
|
||||
if !media.is_empty() {
|
||||
let _ = session.execute("file.import", json!({"paths": media}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,6 +148,9 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
|
|||
|
||||
## G9. Windows and Linux at run time
|
||||
|
||||
- **Have:** files given at launch (terminal, Open With, file associations) open whatever the case
|
||||
of `.fcproj` and are made absolute, and arguments that are not Unicode no longer stop the start;
|
||||
`filmcraft-cli import` stores no Windows `\\?\` paths (#850).
|
||||
- **Missing:** routine runtime testing off macOS. Linux playback stutter (#617), Wayland title
|
||||
bar (#433), case-sensitive extensions (#593), "Report an issue" link (#642), Windows bins cannot
|
||||
be left (#587), Sequence Settings won't open on Windows (#586, #351), old hardware (#491).
|
||||
|
|
|
|||
Loading…
Reference in New Issue