diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1e9c18d99..d6c721792 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -57,6 +57,7 @@ pub mod media; pub mod memory; pub mod merge; mod model_download; +pub mod open_path; pub mod originals; mod pdf; pub mod preset_import; diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index d60d60582..5f39188a1 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -562,10 +562,54 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, original, }); } + refuse_non_media(&mut out); ensure_read_active(cancel)?; Ok(out) } +/// Drop the catalog's images whose original is not a file type LightCraft reads, saying so in the +/// import's warnings. +/// +/// The catalog decides these paths, and a record it leaves behind is one click from the system +/// opener (Photo ▸ Edit In ▸ Open Original in Other Editor). A record for `payload.exe`, +/// `start.lnk` or `Evil.app` would make a `.lrcat` someone was sent a way to have a program run +/// (issue #2135), so such an image never becomes a photo: not imported, not relinkable, not +/// openable. A virtual copy of a refused original goes with it — it has no original of its own. +/// +/// Called where the catalog is read and again where its photos become records, so neither route +/// can keep one; the second pass finds nothing left to say. +pub(crate) fn refuse_non_media(data: &mut CatalogImport) { + // masters first: a virtual copy may come before its master in the table + let refused: HashSet = data + .photos + .iter() + .filter(|p| number(&p.image, "masterImage") == 0 && !crate::open_path::is_media(Path::new(&p.path))) + .map(|p| p.source_id) + .collect(); + if refused.is_empty() { + return; + } + let mut warnings = Vec::new(); + data.photos.retain(|p| { + let master = number(&p.image, "masterImage"); + // `masterImage` is 0 for a master, so a refused id of 0 (a malformed catalog) must not + // take every master of the catalog with it + if master != 0 { + if !refused.contains(&master) { + return true; + } + warnings.push(format!("image {}: not imported; its original was not imported", p.source_id)); + return false; + } + if !refused.contains(&p.source_id) { + return true; + } + warnings.push(format!("image {}: not imported; {} isn't one of the file types LightCraft reads", p.source_id, p.path)); + false + }); + data.warnings.extend(warnings); +} + fn ensure_read_active(cancel: &AtomicBool) -> Result<(), String> { if cancel.load(Ordering::Relaxed) { Err("Lightroom import cancelled".into()) } else { Ok(()) } } @@ -741,8 +785,10 @@ pub(crate) struct ApplyPrepared<'a> { pub(crate) undo0: usize, } -pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result { +pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result { let ApplyPrepared { update_existing, files, index, archive_path, missing_sources, undo0 } = context; + // nothing but a file type LightCraft reads becomes a record, whichever route got here (#2135) + refuse_non_media(&mut data); validate(&data).map_err(error)?; let mut warnings = data.warnings.clone(); let duplicates: HashMap<_, _> = files.duplicates.iter().filter_map(|d| d.existing.map(|id| (key(&d.path), PhotoId(id)))).collect(); @@ -1018,6 +1064,48 @@ mod tests { warnings: Vec::new(), } } + /// A crafted `.lrcat` that names a program as a photo's original gets no record for it, so + /// Photo ▸ Edit In ▸ Open Original has nothing to hand the system opener (#2135). Its virtual + /// copies go with it; the catalog's real photos still import. + #[test] + fn a_catalog_original_that_is_not_a_photo_file_is_not_imported() { + let mut data = sample(); + let mut exe = data.photos[1].clone(); + exe.source_id = 3; + exe.uuid = "payload".into(); + exe.path = "/Users/sky/Downloads/payload.exe".into(); + exe.image = row(json!({"id_local":3,"fileFormat":"ARW","fileWidth":20,"fileHeight":10})); + let mut exe_copy = exe.clone(); + exe_copy.source_id = 4; + exe_copy.uuid = "payload-copy".into(); + exe_copy.path = "/Users/sky/Downloads/payload.exe".into(); + exe_copy.image = row(json!({"id_local":4,"masterImage":3,"copyName":"Mono"})); + // the copy comes before its master, as a catalog's table order may have it + data.photos.insert(0, exe_copy); + data.photos.push(exe); + for bad in [r"C:\Users\sky\start.lnk", "/Applications/Evil.app", "/Users/sky/go.command", "/Users/sky/run.bat"] { + let mut one = data.clone(); + one.photos.retain(|p| p.source_id < 3); + let mut planted = one.photos[1].clone(); + planted.source_id = 5; + planted.uuid = "planted".into(); + planted.path = bad.into(); + planted.image = row(json!({"id_local":5,"fileFormat":"ARW"})); + one.photos.push(planted); + let mut s = crate::Session::new(); + let r = apply(&mut s, one, false).unwrap(); + assert_eq!(r["photos"], 2, "{bad} is not catalogued: {r}"); + assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path == bad)), "{bad} became a library record"); + } + let mut s = crate::Session::new(); + let r = apply(&mut s, data, false).unwrap(); + assert_eq!(r["photos"], 2, "only the real master and its copy: {r}"); + assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path.ends_with(".exe"))), "the program was catalogued"); + let warnings = r["warnings"].to_string(); + assert!(warnings.contains("payload.exe") && warnings.contains("file types LightCraft reads"), "{warnings}"); + assert!(warnings.contains("image 4: not imported"), "the virtual copy of the refused original goes with it: {warnings}"); + } + #[test] fn offline_local_master_and_copy_join_library_with_one_undo_step() { let mut s = crate::Session::new(); diff --git a/crates/engine/src/open_path.rs b/crates/engine/src/open_path.rs new file mode 100644 index 000000000..d80039729 --- /dev/null +++ b/crates/engine/src/open_path.rs @@ -0,0 +1,117 @@ +//! Whether a path the library holds may be handed to the operating system. +//! +//! A photo's `Source::File` path is not always something a user typed: it can come from an +//! imported Lightroom catalog, an `.lrcat` someone was sent, a relink or a synchronised folder. +//! Photo ▸ Edit In ▸ *Open Original in Other Editor* and the system default opener run whatever +//! the path names, so a record pointing at `payload.exe`, `thing.lnk`, `run.bat`, `page.hta` +//! (Windows) or `Evil.app`, `go.command` (macOS) would be executed on one click (issue #2135). +//! +//! [`openable`] is the one gate every such hand-over goes through: the path must be a regular +//! file whose extension is one LightCraft itself reads ([`crate::import::EXTENSIONS`], which +//! already covers the Edit In copy formats `tif`/`tiff`/`psd`), and what a link resolves to must +//! be one too, so a `photo.jpg` pointing at `/bin/sh` is refused as well. What comes back is the +//! absolute path to hand over; what comes back on refusal is the sentence to show the user. +//! +//! Nothing here opens, stats-for-content or reads the file: it only decides whether the path may +//! leave LightCraft. Catalog import applies the same test ([`crate::lightroom_catalog`]), so a +//! refused original never becomes a library record in the first place. + +use std::path::Path; + +/// The absolute path to hand to an opener or external editor, or the reason it is refused. +/// +/// `label` names the thing in the error (a file name, a photo's name); an empty `label` uses +/// the path's own file name. +pub fn openable(path: &str, label: &str) -> Result { + let p = Path::new(path); + let name = match label.trim() { + "" => p.file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().into_owned()), + l => l.to_string(), + }; + if !is_media(p) { + return Err(format!("{name} isn't one of the file types LightCraft reads, so it isn't handed to another application")); + } + match std::fs::metadata(p) { + Ok(m) if m.is_file() => {} + Ok(_) => return Err(format!("{name} isn't a file, so it isn't handed to another application")), + Err(e) => return Err(format!("the original is missing: {path} ({e})")), + } + // a link may name a photo and point at a program; what runs is what it points at + if let Ok(target) = p.canonicalize() + && !is_media(&target) + { + return Err(format!("{name} points at {}, which isn't a file type LightCraft reads", target.display())); + } + let absolute = std::path::absolute(p).map_err(|e| format!("{name}: {e}"))?; + Ok(absolute.to_string_lossy().into_owned()) +} + +/// Whether `path`'s extension is one LightCraft reads (the Edit In copy formats included). +pub fn is_media(path: &Path) -> bool { + crate::import::is_supported(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(dir: &Path, name: &str) -> String { + let p = dir.join(name); + std::fs::write(&p, b"x").unwrap(); + p.to_string_lossy().into_owned() + } + + #[test] + fn only_file_types_lightcraft_reads_are_handed_over() { + let dir = std::env::temp_dir().join(format!("lc-openable-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for good in ["DSC_1.NEF", "a.jpg", "copy-Edit.tif", "copy-Edit.psd"] { + let path = write(&dir, good); + assert_eq!(openable(&path, "").unwrap(), path, "{good} is a photo LightCraft reads"); + } + for bad in ["payload.exe", "shortcut.lnk", "run.bat", "page.hta", "go.command", "no-extension"] { + let path = write(&dir, bad); + let e = openable(&path, "").unwrap_err(); + assert!(e.contains(bad) && e.contains("file types LightCraft reads"), "{bad}: {e}"); + } + // a bundle (macOS `.app`) and any other folder are not files + let bundle = dir.join("Evil.app"); + std::fs::create_dir_all(&bundle).unwrap(); + let e = openable(&bundle.to_string_lossy(), "").unwrap_err(); + assert!(e.contains("isn't one of the file types") || e.contains("isn't a file"), "{e}"); + let e = openable(&dir.join("gone.jpg").to_string_lossy(), "").unwrap_err(); + assert!(e.contains("missing"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A relative path is made absolute: the opener runs with its own working directory. + #[test] + fn the_path_handed_over_is_absolute() { + let dir = std::env::temp_dir().join(format!("lc-openable-abs-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = write(&dir, "a.jpg"); + let dotted = Path::new(&path).parent().unwrap().join(".").join("a.jpg"); + let out = openable(&dotted.to_string_lossy(), "").unwrap(); + assert!(Path::new(&out).is_absolute(), "{out}"); + assert!(out.ends_with("a.jpg"), "{out}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A link named like a photo that points at a program is refused: what the opener runs is + /// the target, not the name. + #[cfg(unix)] + #[test] + fn a_link_to_a_program_is_refused() { + let dir = std::env::temp_dir().join(format!("lc-openable-link-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let target = write(&dir, "payload.command"); + let link = dir.join("holiday.jpg"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let e = openable(&link.to_string_lossy(), "").unwrap_err(); + assert!(e.contains("points at") && e.contains("payload.command"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/slide_overlay.rs b/crates/engine/src/slide_overlay.rs index 409739335..a27f75d49 100644 --- a/crates/engine/src/slide_overlay.rs +++ b/crates/engine/src/slide_overlay.rs @@ -903,7 +903,7 @@ fn shadow(img: &mut Rgba8, tl: &crate::text::TextLayout, x: f64, y: f64, sigma: let my0 = (y - pad).floor().max(-pad); let mx1 = (x + tl.width as f64 + pad).ceil().min(iw + pad); let my1 = (y + tl.height as f64 + pad).ceil().min(ih + pad); - if !(mx1 > mx0 && my1 > my0) || !mx0.is_finite() || !my0.is_finite() { + if !(mx1 > mx0 && my1 > my0 && mx0.is_finite() && my0.is_finite()) { return; } let (w, h) = ((mx1 - mx0) as usize, (my1 - my0) as usize); diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 2f1a05116..0ac8df804 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -1024,21 +1024,24 @@ fn same_folder_as_original_refuses_photos_without_a_folder() { let mut s = Session::with_demo(); let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect(); let mut written = Vec::new(); - let mut write = |p: &str, _: &[u8]| -> Result<(), String> { - written.push(p.to_string()); - Ok(()) + // the writer lives in a block of its own, so its borrow of `written` has ended by the time + // the assertion below reads it + let files = { + let mut write = |p: &str, _: &[u8]| -> Result<(), String> { + written.push(p.to_string()); + Ok(()) + }; + let err = crate::export::export_batch(&mut s, &ids, &o, &to, &mut write, &|_| false).unwrap_err(); + assert!(err.contains("no folder on disk"), "{err}"); + // without stopping: one error per photo, nothing written + let items = crate::export::prepare_batch(&mut s, &ids, &o).unwrap(); + crate::export::run_batch(items, &o, &to, &mut write, &|_| false, false, &mut |_, _| true).unwrap() }; - let err = crate::export::export_batch(&mut s, &ids, &o, &to, &mut write, &|_| false).unwrap_err(); - assert!(err.contains("no folder on disk"), "{err}"); - // without stopping: one error per photo, nothing written - let items = crate::export::prepare_batch(&mut s, &ids, &o).unwrap(); - let files = crate::export::run_batch(items, &o, &to, &mut write, &|_| false, false, &mut |_, _| true).unwrap(); assert_eq!(files.len(), 2); for (f, id) in files.iter().zip(&ids) { assert_eq!(f["photo"], id.0, "{f}"); assert!(f["error"].as_str().unwrap().contains("no folder on disk"), "{f}"); } - drop(write); assert!(written.is_empty(), "{written:?}"); // a photo on disk whose folder is gone (moved, an unplugged drive) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9fcfc8b9d..d575d7490 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2144,12 +2144,13 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { "app.exportPrevious" => app.session.active().is_some() && app.session.last_export.is_some(), "dialog.pasteSettings" => app.session.active().is_some() && app.session.clipboard.is_some(), "photo.openOriginalInExternal" => { + // only a file type LightCraft reads can be handed over (#2135); the extension alone + // decides, so no disk or share is touched while a menu is drawn app.services.open_with.is_some() - && app - .session - .active() - .and_then(|id| app.session.catalog.photo(id)) - .is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. })) + && app.session.active().and_then(|id| app.session.catalog.photo(id)).is_some_and(|p| match &p.source { + lightcraft_engine::catalog::Source::File { path } => lightcraft_engine::open_path::is_media(std::path::Path::new(path)), + _ => false, + }) } "app.showInFinder" => { app.services.reveal.is_some() @@ -2324,8 +2325,15 @@ fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: if let Some(id) = id { app.ui.external_edits.push(*id); } - if let Some(Err(e)) = app.services.open_with.as_mut().map(|f| f(path, &editor)) { - failed = Some(e); + // the copy is LightCraft's own render, but it goes through the same gate as any other + // path handed to the system opener (#2135) + match lightcraft_engine::open_path::openable(path, "") { + Err(e) => failed = Some(e), + Ok(absolute) => { + if let Some(Err(e)) = app.services.open_with.as_mut().map(|f| f(&absolute, &editor)) { + failed = Some(e); + } + } } } match (failed, copies.as_slice()) { @@ -2377,11 +2385,13 @@ fn open_originals(app: &mut LightcraftApp, p: &Value) -> Result { let mut files = Vec::new(); let mut skipped = Vec::new(); for id in ids { - match app.session.catalog.photo(id).map(|ph| &ph.source) { - Some(lightcraft_engine::catalog::Source::File { path }) if std::path::Path::new(path).is_file() => files.push((id, path.clone())), - Some(lightcraft_engine::catalog::Source::File { path }) => { - skipped.push(json!({"id": id.0, "reason": format!("the original is missing: {path}")})) - } + // the path may come from an imported catalog: only a photo file LightCraft reads is + // handed to the opener, never a program (#2135) + match app.session.catalog.photo(id).map(|ph| (ph.file_name.clone(), ph.source.clone())) { + Some((name, lightcraft_engine::catalog::Source::File { path })) => match lightcraft_engine::open_path::openable(&path, &name) { + Ok(absolute) => files.push((id, absolute)), + Err(reason) => skipped.push(json!({"id": id.0, "reason": reason})), + }, Some(_) => skipped.push(json!({"id": id.0, "reason": "a demo photo has no original file"})), None => skipped.push(json!({"id": id.0, "reason": "no such photo"})), } diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index bbea5356e..4e397208e 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -1177,6 +1177,70 @@ fn open_original_in_external_editor_hands_over_the_raw_itself() { h.settle(SETTLE); } +/// Issue #2135: a library record whose original is a program (an imported `.lrcat` names the +/// paths) is never handed to the opener — the launcher is asked for nothing — and the user is +/// told why. The path LightCraft does hand over is absolute, whatever the record's spelling. +#[test] +fn open_original_refuses_anything_that_is_not_a_photo_file() { + use lightcraft_catalog::{Op, Photo, Source}; + let dir = std::env::temp_dir().join(format!("lc-ui-gate-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let opened: std::sync::Arc>> = Default::default(); + let batches: std::sync::Arc>>> = Default::default(); + let (o, bt) = (opened.clone(), batches.clone()); + let services = Services { + png: None, + open_with: Some(Box::new(move |path: &str, app: &str| { + o.lock().unwrap().push((path.to_string(), app.to_string())); + Ok(()) + })), + open_all_with: Some(Box::new(move |paths: &[String], _: &str| { + bt.lock().unwrap().push(paths.to_vec()); + Ok(()) + })), + check_editor: Some(Box::new(|_: &str| Ok(()))), + ..Default::default() + }; + let mut session = lightcraft_engine::Session::with_demo().with_fs(); + let mut record = |name: &str| { + let path = dir.join(name); + std::fs::write(&path, b"MZ").unwrap(); + let id = session.catalog.alloc_photo_id(); + let p = Photo::new(id, Source::File { path: path.to_string_lossy().into_owned() }, name, "ARW", 60, 40, "2026-01-01T10:00:00"); + session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id + }; + // what a crafted catalog can name as a photo's original + let programs = ["payload.exe", "start.lnk", "run.bat", "page.hta", "go.command", "noext"]; + let bad: Vec<_> = programs.iter().map(|n| record(n)).collect(); + let good = record("DSC_0001.NEF"); + let app = LightcraftApp::new(session, services); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.app.ui.settings.external_editor = "DxO PhotoLab".into(); + + for (id, name) in bad.iter().zip(programs) { + let r = h.request("engine.execute", json!({"command": "photo.openOriginalInExternal", "params": {"ids": [id.0]}}), T); + assert_eq!(r["ok"], false, "{name} was opened: {r}"); + assert!(r.to_string().contains("file types LightCraft reads"), "{name}: {r}"); + // the menu item is greyed out for such a record, so it is never offered either + exec(&mut h, "library.select", json!({"ids": [id.0]})); + assert!(!crate::menus::ui_enabled(&h.app, "photo.openOriginalInExternal"), "{name} is offered in the menu"); + } + assert!(opened.lock().unwrap().is_empty() && batches.lock().unwrap().is_empty(), "the launcher was asked to open a program"); + + // a program selected together with a photo: only the photo is handed over, by absolute path + let r = exec(&mut h, "photo.openOriginalInExternal", json!({"ids": [bad[0].0, good.0]})); + assert_eq!(r["opened"].as_array().map(Vec::len), Some(1), "{r}"); + assert!(r["skipped"].to_string().contains("payload.exe"), "{r}"); + let handed = batches.lock().unwrap().clone(); + assert_eq!(handed.len(), 1); + assert_eq!(handed[0].len(), 1, "{handed:?}"); + assert!(std::path::Path::new(&handed[0][0]).is_absolute() && handed[0][0].ends_with("DSC_0001.NEF"), "{handed:?}"); + let _ = std::fs::remove_dir_all(&dir); + h.settle(SETTLE); +} + #[test] fn second_window_shows_the_active_photo() { let mut h = detail("panel.edit"); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 95dcd2aef..de7ba697f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -407,7 +407,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests_export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy | | LR-EXP-RENDERDNG | Render to DNG (Classic 15.5) | P2 | 🟡 | `cmd:app.export` (`format: rendereddng`), `crates/raw/src/dngwrite.rs` (`write_rendered_dng`), `crates/raw/tests/rendered_dng.rs` | export format `rendereddng` (dialog: Format DNG, "DNG of: Rendered photo"): the rendered photo (edit, crop, size, sharpening, watermark baked in) as a LinearRaw DNG of 16-bit float Deflate tiles in the export colour space, with `ColorMatrix1` / `ForwardMatrix1` for that space, a neutral `AsShotNeutral` and an identity `ProfileToneCurve` so raw converters show it as rendered; HDR edits keep highlights above white. Not a menu command on selected photos (Lightroom adds the DNG to the catalog next to the original); export only | | LR-EXP-PHOTOS | Export to the system photo library | P2 | 🟡 | `cmd:app.export` (`addToPhotos`, `photosAlbum`), `cmd:export.addToPhotos`, `cmd:export.photosImports`, `crates/engine/src/apple_photos.rs`, `crates/ui-egui/src/panels/dialogs.rs`, `packaging/macos/entitlements.plist`, `docs/apple-photos.md` | macOS: the exported files go to Apple Photos through Apple Events to its import command (a constant AppleScript run by `/usr/bin/osascript`; album names and paths are separate arguments), into a top-level album of exactly that name, made when missing (several of that name: refused); the Export dialog's Add to Apple Photos, Export with Previous, presets, MCP and `lightcraft-cli` all reach it; imports are jobs, off the UI thread in the desktop app and awaited in one-shot headless runs, one at a time, reserved by an export before it writes; permission, missing Photos, timeouts and partial imports are reported; the packaged app carries the Apple Events entitlement and usage text. Tested end to end with the packaged app on macOS 26.6.2 / Photos 11.0 (manual steps in `docs/apple-photos.md`). Other platforms' photo libraries are not covered | -| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. Open Original in Other Editor (RAW, no copy) hands that editor each selected photo's original file itself instead (a raw stays a raw, e.g. for DxO PhotoLab: no render, no copy, nothing added; several photos go to one start of the editor, which DxO PhotoLab 8 needs to get them all; demo photos and missing files are skipped with the reason). No "Edit a copy / Edit original" choice for non-raw files yet | +| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. Open Original in Other Editor (RAW, no copy) hands that editor each selected photo's original file itself instead (a raw stays a raw, e.g. for DxO PhotoLab: no render, no copy, nothing added; several photos go to one start of the editor, which DxO PhotoLab 8 needs to get them all; demo photos, missing files and originals that are not a file type LightCraft reads are skipped with the reason, so a path an imported catalog named cannot hand a program to the system opener, issue #2135). No "Edit a copy / Edit original" choice for non-raw files yet | ## T. Share (SHARE)