mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
LR-EXP-PHOTOS: add exported files to Apple Photos (macOS, #236)
The Export dialog gets "Add to Apple Photos" with an optional album (macOS
only). app.export takes addToPhotos / photosAlbum (dialog, Export with
Previous, presets, MCP, lightcraft-cli run and render --opt); the new
export.addToPhotos {paths, album?, wait?} adds files already on disk and
export.photosImports {job?} reports how imports went.
A constant AppleScript run by /usr/bin/osascript asks Photos' import
command (Apple Events, not synthetic input) to import the files into a
top-level album of exactly that name (made when missing; albums in folders
are never used; several of that name: refused), skip check duplicates
false, and returns the media item ids. Album names and paths are passed
only as separate arguments (on run argv), never spliced into the script.
The runner is injectable (Session::apple_photos): with_fs installs the real
one on macOS only, and tests use fakes, so no test starts osascript or
Photos.
Imports are jobs (Session::apple_photos_imports), one at a time. The
desktop app (Session::apple_photos_background, set in open_session) never
waits for Photos on its UI thread: the background export adds on its
worker, app.export without background and export.addToPhotos answer with
the running job, and the app announces the outcome. Without a window (MCP,
lightcraft-cli) imports wait by default, and both headless front ends (the
MCP/CLI backend and the snapshot UI) wait for a running one before the
process ends, on every exit path, through one engine helper
(Session::wait_for_photos_imports) whose deadline follows the job: a
writing allowance while an export still writes, then the import's own
time limit from when Photos was asked, under an overall cap. A runner that panics, waiting or on a
worker, ends its import as failed and frees the slot (panic guard plus a
completion guard). Session::after_export reserves
the import slot before an export writes anything and frees it on every
exit path (handed the files, cancelled, failed, dropped), so a competing
import is refused with the export's job number instead of the export
failing its Photos step after writing. Permission (-1743), missing Photos,
cancel, timeouts, ambiguous albums, busy imports, other osascript failures
and partial imports produce actionable messages; the export's files are
kept either way. Hidden on other platforms; the web build compiles it out.
Packaging: the hardened-runtime entitlements gain
com.apple.security.automation.apple-events (app and CLI) and Info.plist
gains NSAppleEventsUsageDescription, without which macOS refuses the
events without asking.
Not yet tested end to end against a real Photos library: manual steps,
with the packaged app, in docs/apple-photos.md. Parity row LR-EXP-PHOTOS is
now partial.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
0ba118de92
commit
a6431c152d
40 files changed
+2416
-38
No files matched your search
@@ -62,6 +62,8 @@ USAGE:
|
||||
string), e.g. --opt colorSpace=displayP3 --opt bitDepth=16
|
||||
--opt percent=50 --opt shortEdge=1080 --opt ppi=300
|
||||
--opt format=original (copy + XMP sidecar) --opt metadata=none
|
||||
--opt addToPhotos=true --opt photosAlbum=NAME (macOS: then add
|
||||
the file to Apple Photos, into that album)
|
||||
lightcraft-cli snapshot [OPTIONS] [FILES/FOLDERS…]
|
||||
Run the full app UI headlessly (no window, no GPU: CPU-rasterized egui) and write PNGs.
|
||||
Options:
|
||||
@@ -681,6 +683,8 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
o.format = ExportFormat::parse(&ext)
|
||||
.ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?;
|
||||
}
|
||||
// Add to Apple Photos: refused before anything is written when it can't be done
|
||||
let after = s.after_export(&o)?;
|
||||
let e = export_photo(&mut s, lightcraft_engine::catalog::PhotoId(id), &o, 1)?;
|
||||
// never over the input (or its sidecar), however it is spelled: `render IMG.jpg -o IMG.jpg`
|
||||
let sidecars: Vec<(String, &Vec<u8>)> =
|
||||
@@ -695,6 +699,12 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
eprintln!("lightcraft-cli: wrote {sc}");
|
||||
}
|
||||
eprintln!("lightcraft-cli: wrote {output} ({}×{})", e.width, e.height);
|
||||
if let Some(photos) = after.run(&[json!({"path": output})], false) {
|
||||
if let Some(err) = photos.get("error").and_then(Value::as_str) {
|
||||
return Err(format!("render: {output} was written, but {err}"));
|
||||
}
|
||||
eprintln!("lightcraft-cli: added to Apple Photos: {photos}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -583,6 +583,8 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
||||
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
|
||||
let (mut s, problem) = open_library_session(in_memory, dir, seed_demo);
|
||||
s.face_models_dir = lightcraft_engine::config::default_face_models_dir();
|
||||
// Control requests are answered on the UI thread: Add to Apple Photos must not wait there
|
||||
s.apple_photos_background = true;
|
||||
(s, problem)
|
||||
}
|
||||
|
||||
@@ -902,6 +904,16 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
}
|
||||
|
||||
/// Issue #236: the window answers control requests on its UI thread, so the desktop session
|
||||
/// adds to Apple Photos on a worker (headless sessions wait instead).
|
||||
#[test]
|
||||
fn the_desktop_session_adds_to_apple_photos_in_the_background() {
|
||||
let (s, problem) = open_session(true, None, false);
|
||||
assert!(problem.is_none());
|
||||
assert!(s.apple_photos_background);
|
||||
assert!(!lightcraft_engine::Session::new().apple_photos_background);
|
||||
}
|
||||
|
||||
/// Issue #103: a damaged ui.json (which holds the library location) is kept aside and
|
||||
/// reported, not silently replaced by the defaults.
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -78,8 +78,69 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
/// `export.addToPhotos`: there is a runner (the desktop app and lightcraft-cli on macOS).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn photos_enabled(s: &Session) -> std::result::Result<(), String> {
|
||||
match &s.apple_photos {
|
||||
Some(_) => Ok(()),
|
||||
None => Err(crate::apple_photos::unavailable().into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `export.addToPhotos {paths, album?, wait?}`: add files already on disk to Apple Photos, as a
|
||||
/// job: here (the default without a window: lightcraft-cli, the MCP server, scripts), or on a
|
||||
/// worker thread (the default in the desktop app, which answers control requests on its UI
|
||||
/// thread and must not wait minutes for Photos; see [`Session::apple_photos_background`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn add_to_photos(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "export.addToPhotos";
|
||||
let runner = s.apple_photos.clone().ok_or_else(|| bad(ID, crate::apple_photos::unavailable()))?;
|
||||
if let Some(k) = p.as_object().and_then(|o| o.keys().find(|k| !matches!(k.as_str(), "paths" | "album" | "wait"))) {
|
||||
return Err(bad(ID, format!("unknown parameter `{k}` (one of paths, album, wait)")));
|
||||
}
|
||||
let paths: Vec<String> = match p.get("paths") {
|
||||
Some(Value::Array(a)) => a
|
||||
.iter()
|
||||
.map(|v| v.as_str().map(str::to_string))
|
||||
.collect::<Option<_>>()
|
||||
.ok_or_else(|| bad(ID, "`paths` must be an array of file paths"))?,
|
||||
_ => return Err(bad(ID, "missing `paths` (an array of absolute file paths)")),
|
||||
};
|
||||
let album = match p.get("album") {
|
||||
None | Some(Value::Null) => "",
|
||||
Some(Value::String(a)) => a.as_str(),
|
||||
Some(_) => return Err(bad(ID, "`album` must be a string")),
|
||||
};
|
||||
// a window (UI thread) doesn't wait by default; a one-shot process must, or it would end
|
||||
// in the middle of the import
|
||||
let wait = match p.get("wait") {
|
||||
None | Some(Value::Null) => !s.apple_photos_background,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(_) => return Err(bad(ID, "`wait` must be true or false")),
|
||||
};
|
||||
let job = crate::apple_photos::import_job(&runner, &s.apple_photos_imports, paths, album, !wait).map_err(|e| bad(ID, e))?;
|
||||
match job.outcome() {
|
||||
Some(crate::apple_photos::Outcome::Failed(e)) => Err(bad(ID, e)),
|
||||
_ => Ok(job.json()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `export.photosImports {job?}`: this session's Apple Photos imports, or one of them.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn photos_imports(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "export.photosImports";
|
||||
match p.get("job") {
|
||||
None | Some(Value::Null) => Ok(json!({"imports": s.apple_photos_imports.all().iter().map(|j| j.json()).collect::<Vec<_>>()})),
|
||||
Some(v) => {
|
||||
let id = v.as_u64().ok_or_else(|| bad(ID, "`job` must be an import number"))?;
|
||||
s.apple_photos_imports.get(id).map(|j| j.json()).ok_or_else(|| bad(ID, format!("no import {id} (see export.photosImports)")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
#[allow(unused_mut)]
|
||||
let mut v = vec![
|
||||
cmd!(query "export.contactSheet", "Export Contact Sheet PDF", [], None,
|
||||
"{path, ids?: photo ids (default selection), paper?: a4|letter, landscape?: bool, columns?: 1..8, rows?: 1..10, captions?: bool} → {path, pages, photos, bytes}; replaces an existing output, never an original",
|
||||
always, |s, p| {
|
||||
@@ -123,5 +184,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"path": path}))
|
||||
}
|
||||
),
|
||||
]
|
||||
];
|
||||
// Apple Events to Photos' import command (macOS); not in the web build. Not journaled: a
|
||||
// replay must not import again.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
v.push(cmd!(
|
||||
query "export.addToPhotos",
|
||||
"Add to Apple Photos",
|
||||
[],
|
||||
None,
|
||||
"{paths: [absolute file paths], album?: name (an album at the top level of Photos, made when missing; default none), wait?} → once Photos is done, {job, running: false, requested, album, imported, ids: [Photos media item ids], warning?}; with wait: false, at once {job, running: true, requested, album} and the outcome later from export.photosImports {job}. wait defaults to true without a window (lightcraft-cli, the MCP server) and to false in the desktop app (its control requests are answered on the UI thread). macOS: asks Photos to import the files (the first time, macOS asks the user to allow LightCraft to control Photos). One import runs at a time, and an export adding to Photos reserves it before writing: another is refused until it ends. app.export does this for the files it writes with `addToPhotos: true`",
|
||||
photos_enabled,
|
||||
add_to_photos
|
||||
));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
v.push(cmd!(
|
||||
query "export.photosImports",
|
||||
"Apple Photos Imports",
|
||||
[],
|
||||
None,
|
||||
"{job?} → {imports: [{job, running, exporting?, requested, album, imported?, ids?, warning?, error?, skipped?}]} (this session's, oldest first), or the one job: how Add to Apple Photos is getting on (exporting: reserved by an export still writing its files)",
|
||||
always,
|
||||
photos_imports
|
||||
));
|
||||
v
|
||||
}
|
||||
+142
-1
@@ -605,6 +605,10 @@ pub struct ExportOptions {
|
||||
/// not applied), AVIF as 10-bit Rec. 2020 PQ, 32-bit float TIFF with the highlights above SDR
|
||||
/// white kept. Other formats, and photos without an HDR edit, are SDR.
|
||||
pub hdr: bool,
|
||||
/// After the export, add the files written to Apple Photos (macOS only; see [`AfterExport`]).
|
||||
pub add_to_photos: bool,
|
||||
/// The Photos album they go into, made when missing (empty = none).
|
||||
pub photos_album: String,
|
||||
}
|
||||
|
||||
impl Default for ExportOptions {
|
||||
@@ -629,6 +633,8 @@ impl Default for ExportOptions {
|
||||
color_space: OutputSpace::Srgb,
|
||||
bit_depth: None,
|
||||
hdr: false,
|
||||
add_to_photos: false,
|
||||
photos_album: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -665,8 +671,16 @@ pub const OPTION_PARAMS: &[&str] = &[
|
||||
"colorSpace",
|
||||
"bitDepth",
|
||||
"hdr",
|
||||
"addToPhotos",
|
||||
"photosAlbum",
|
||||
];
|
||||
|
||||
/// Why an export can't add its files to Apple Photos on this platform (`None`: it can, when the
|
||||
/// session has the runner; see [`crate::Session::after_export`]).
|
||||
pub fn apple_photos_unsupported() -> Option<&'static str> {
|
||||
(!cfg!(target_os = "macos")).then_some("Apple Photos is only available on macOS")
|
||||
}
|
||||
|
||||
/// The keys of a `watermark` object ([`Watermark`], camelCase).
|
||||
pub const WATERMARK_PARAMS: &[&str] = &["text", "vertical", "size", "opacity", "anchor", "inset", "color", "shadow", "image", "imageWidth"];
|
||||
|
||||
@@ -799,6 +813,18 @@ impl ExportOptions {
|
||||
"dontEnlarge" | "removeLocation" | "background" | "hdr" => {
|
||||
boolean(k, v)?;
|
||||
}
|
||||
"addToPhotos" => {
|
||||
if boolean(k, v)?
|
||||
&& let Some(why) = apple_photos_unsupported()
|
||||
{
|
||||
return Err(bad(format!("`{k}`: {why}")));
|
||||
}
|
||||
}
|
||||
"photosAlbum" => {
|
||||
if string(cmd, k, v)?.contains('\0') {
|
||||
return Err(bad(format!("`{k}` can't contain a NUL character")));
|
||||
}
|
||||
}
|
||||
"naming" | "subfolder" | "path" | "dir" | "preset" => {
|
||||
string(cmd, k, v)?;
|
||||
}
|
||||
@@ -884,7 +910,8 @@ impl ExportOptions {
|
||||
}
|
||||
|
||||
/// Read options from command params (`format`, `quality`, `limitKb`, `sharpen`, `sharpenAmount`,
|
||||
/// `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`, `bitDepth`, `ppi`) and the
|
||||
/// `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`, `bitDepth`, `ppi`,
|
||||
/// `addToPhotos`, `photosAlbum`) and the
|
||||
/// size: one of `longEdge`, `shortEdge`, `width`, `height` (both = fit inside W × H),
|
||||
/// `megapixels`, `percent` (absent or 0 = full size), or a [`Resize`] object as `resize`; plus
|
||||
/// `dontEnlarge` (default true).
|
||||
@@ -930,6 +957,8 @@ impl ExportOptions {
|
||||
color_space: s("colorSpace").and_then(OutputSpace::parse).unwrap_or(d.color_space),
|
||||
bit_depth: u("bitDepth").filter(|b| matches!(b, 8 | 10 | 16 | 32)).map(|b| b as u8),
|
||||
hdr: p.get("hdr").and_then(Value::as_bool).unwrap_or(d.hdr),
|
||||
add_to_photos: p.get("addToPhotos").and_then(Value::as_bool).unwrap_or(d.add_to_photos),
|
||||
photos_album: s("photosAlbum").map_or(d.photos_album, |a| a.trim().to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1547,6 +1576,102 @@ pub struct Destination {
|
||||
pub exact: Option<String>,
|
||||
}
|
||||
|
||||
/// What happens after an export has written its files (Lightroom's Post-Processing): nothing, or
|
||||
/// adding them to Apple Photos ([`ExportOptions::add_to_photos`]). Made by
|
||||
/// [`crate::Session::after_export`] before the export starts, so an export asking for something
|
||||
/// this session can't do fails before writing anything; for Photos it holds the import slot
|
||||
/// (a [`crate::apple_photos::Reservation`]) until the files are handed over, and frees it when
|
||||
/// dropped unused (the export failed or never ran). `Send`: a background export takes it to its
|
||||
/// worker.
|
||||
#[derive(Default)]
|
||||
pub struct AfterExport {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
apple_photos: Option<crate::apple_photos::Reservation>,
|
||||
}
|
||||
|
||||
impl AfterExport {
|
||||
/// Add the files to Apple Photos with `runner`, into `album` when it isn't blank, reserving
|
||||
/// the import slot of `imports` now. (Front ends get this from
|
||||
/// [`crate::Session::after_export`], which checks the platform first.)
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn apple_photos(runner: crate::apple_photos::Runner, album: &str, imports: &crate::apple_photos::Imports) -> Result<Self, String> {
|
||||
Ok(Self { apple_photos: Some(imports.reserve(runner, album)?) })
|
||||
}
|
||||
|
||||
/// Nothing to do.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn is_none(&self) -> bool {
|
||||
self.apple_photos.is_none()
|
||||
}
|
||||
|
||||
/// Nothing to do.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn is_none(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Do it now for an export's per-photo results ([`run_batch`]'s: only the files written
|
||||
/// count), waiting for Photos: for a worker thread (the app's background export) or a caller
|
||||
/// without a window (MCP, lightcraft-cli). `None` when there was nothing to do, else the
|
||||
/// outcome to report as the export's `applePhotos`: the finished job (`{job, running: false,
|
||||
/// requested, album}` with `{imported, ids, warning?}`, `{error}`, or `{skipped}` when the
|
||||
/// export was cancelled or wrote nothing). Never fails the export: its files are written
|
||||
/// either way.
|
||||
pub fn run(self, files: &[serde_json::Value], cancelled: bool) -> Option<serde_json::Value> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(r) = self.apple_photos {
|
||||
return Some(r.run(files, cancelled));
|
||||
}
|
||||
let _ = (files, cancelled);
|
||||
None
|
||||
}
|
||||
|
||||
/// Like [`AfterExport::run`], without waiting: Photos works on a worker thread and the
|
||||
/// outcome is the running job (`{job, running: true, requested, album}`; its result comes
|
||||
/// from `export.photosImports`, and the desktop app announces it). For a caller on the UI
|
||||
/// thread, which must not wait minutes for Photos.
|
||||
pub fn start(self, files: &[serde_json::Value]) -> Option<serde_json::Value> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(r) = self.apple_photos {
|
||||
return Some(r.start(files));
|
||||
}
|
||||
let _ = files;
|
||||
None
|
||||
}
|
||||
|
||||
/// [`AfterExport::start`] where the session answers on a UI thread
|
||||
/// ([`crate::Session::apple_photos_background`]), else [`AfterExport::run`].
|
||||
pub fn finish(self, s: &crate::Session, files: &[serde_json::Value]) -> Option<serde_json::Value> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if s.apple_photos_background {
|
||||
return self.start(files);
|
||||
}
|
||||
let _ = s;
|
||||
self.run(files, false)
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
/// What an export with `o` does after writing its files; an error, before anything is written,
|
||||
/// when it asks for Apple Photos and this platform or session has no way to reach it, or
|
||||
/// another import is reserved or running. For Photos the import slot is reserved from now on.
|
||||
pub fn after_export(&self, o: &ExportOptions) -> Result<AfterExport, String> {
|
||||
if !o.add_to_photos {
|
||||
return Ok(AfterExport::default());
|
||||
}
|
||||
if let Some(why) = apple_photos_unsupported() {
|
||||
return Err(why.into());
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let runner = self.apple_photos.clone().ok_or_else(|| crate::apple_photos::unavailable().to_string())?;
|
||||
AfterExport::apple_photos(runner, &o.photos_album, &self.apple_photos_imports)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err("Apple Photos is only available on macOS".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Export `ids` in order ([`prepare_batch`] + [`run_batch`]), stopping at the first error.
|
||||
pub fn export_batch(
|
||||
session: &mut crate::Session,
|
||||
@@ -2497,6 +2622,22 @@ mod tests {
|
||||
j[k] = v;
|
||||
}
|
||||
assert_eq!(ExportOptions::from_params(&j).unwrap(), full);
|
||||
// Add to Apple Photos (issue #236): a switch and an album name; on only where Photos is
|
||||
assert!(err(json!({"addToPhotos": "yes"})).contains("`addToPhotos` must be true or false"));
|
||||
assert!(err(json!({"photosAlbum": 3})).contains("`photosAlbum` must be a string"));
|
||||
assert!(err(json!({"photosAlbum": "a\0b"})).contains("NUL"));
|
||||
assert!(err(json!({"photosAlbm": "x"})).contains("did you mean `photosAlbum`"));
|
||||
let album = ExportOptions { photos_album: "Trip “2026” \\ \"x\"".into(), ..Default::default() };
|
||||
assert_eq!(ExportOptions::from_params(&album.to_json()).unwrap(), album);
|
||||
let on = ExportOptions { add_to_photos: true, ..album };
|
||||
if cfg!(target_os = "macos") {
|
||||
assert_eq!(ExportOptions::from_params(&on.to_json()).unwrap(), on);
|
||||
} else {
|
||||
assert!(err(on.to_json()).contains("Apple Photos is only available on macOS"));
|
||||
// stored settings (a preset or Export with Previous made on a Mac) still read
|
||||
assert!(ExportOptions::from_json(&on.to_json()).add_to_photos);
|
||||
}
|
||||
assert_eq!(ExportOptions::from_json(&json!({"photosAlbum": " Trip "})).photos_album, "Trip");
|
||||
assert_eq!(ExportOptions::from_params(&ExportOptions::default().to_json()).unwrap(), ExportOptions::default());
|
||||
// null is "not given", as a dropped key is
|
||||
ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap();
|
||||
|
||||
@@ -660,13 +660,18 @@ pub fn fs_hooks() -> (FileLoader, FileProbe) {
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
/// Install the filesystem file hooks (desktop, CLI, MCP headless).
|
||||
/// Install the filesystem file hooks (desktop, CLI, MCP headless), and on macOS the Apple Photos
|
||||
/// runner ([`crate::apple_photos::osascript`]; installed, not run).
|
||||
pub fn with_fs(mut self) -> Self {
|
||||
let (l, p) = fs_hooks();
|
||||
self.media.file_loader = Some(l);
|
||||
self.media.file_probe = Some(p);
|
||||
self.media.preview_loader = Some(fs_preview_loader());
|
||||
self.media.denoise.loader = Some(fs_pair_loader());
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
self.apple_photos = Some(crate::apple_photos::osascript());
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
extern crate lightcraft_denoise_core as lightcraft_denoise;
|
||||
|
||||
pub mod activity;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod apple_photos;
|
||||
pub mod availability;
|
||||
pub(crate) mod camera_preview;
|
||||
pub mod camera_profiles;
|
||||
@@ -276,6 +278,20 @@ pub struct Session {
|
||||
/// Untouched Local records of folders not browsed for this many days are forgotten when the
|
||||
/// library opens (0 = never; persisted in prefs.json). See `cmd/browse.rs`.
|
||||
pub forget_local_days: u32,
|
||||
/// Adds exported files to Apple Photos ([`apple_photos`]): `/usr/bin/osascript`, installed by
|
||||
/// [`Session::with_fs`] on macOS; `None` elsewhere and in sessions without a file system. Tests
|
||||
/// put a fake here.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub apple_photos: Option<apple_photos::Runner>,
|
||||
/// This session's Apple Photos imports, running or finished (`export.photosImports`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub apple_photos_imports: apple_photos::Imports,
|
||||
/// Apple Photos imports run on a worker by default (`export.addToPhotos` without `wait`,
|
||||
/// `app.export` without `background`): set by the desktop app, which answers control requests
|
||||
/// on its UI thread. Off (the default), they wait for Photos, so a one-shot process
|
||||
/// (lightcraft-cli, a script) never ends in the middle of one.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub apple_photos_background: bool,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -355,6 +371,12 @@ impl Session {
|
||||
cache_mb: 0,
|
||||
smart_previews_dir: None,
|
||||
forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
apple_photos: None,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
apple_photos_imports: Default::default(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
apple_photos_background: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -440,3 +440,283 @@ fn hdr_avif_export_is_pq_rec2020() {
|
||||
// a photo without an HDR edit exports as an ordinary (sRGB) AVIF
|
||||
assert!(nclx(&sdr.bytes).is_none_or(|c| c[3] != 16));
|
||||
}
|
||||
|
||||
/// Issue #236: Add to Apple Photos after an export. A fake runner stands in for osascript: tests
|
||||
/// never start it, nor Photos.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod apple_photos {
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::disk_batch;
|
||||
use crate::Session;
|
||||
use crate::apple_photos::{Imports, MARKER, Output, Runner};
|
||||
use crate::export::{AfterExport, Destination, ExportOptions};
|
||||
use crate::tests_xmp::temp_dir;
|
||||
|
||||
type Calls = Arc<Mutex<Vec<Vec<String>>>>;
|
||||
|
||||
/// Records the arguments; Photos "imports" the first `keep` files (all: `usize::MAX`).
|
||||
fn fake(keep: usize) -> (Runner, Calls) {
|
||||
let calls: Calls = Arc::default();
|
||||
let c = calls.clone();
|
||||
let r: Runner = Arc::new(move |args: &[String], _| {
|
||||
c.lock().unwrap().push(args.to_vec());
|
||||
let files = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2;
|
||||
let ids: Vec<String> = (0..files.min(keep)).map(|i| format!("ID-{i}/L0/001")).collect();
|
||||
Ok(Output { status: Some(0), stdout: ids.join("\n") + "\n", stderr: String::new() })
|
||||
});
|
||||
(r, calls)
|
||||
}
|
||||
|
||||
/// The paths after the album: the files Photos was asked to import.
|
||||
fn imported_paths(args: &[String]) -> Vec<String> {
|
||||
args[args.iter().position(|a| a == MARKER).unwrap() + 2..].to_vec()
|
||||
}
|
||||
|
||||
fn export_two(s: &mut Session, tag: &str) -> Vec<Value> {
|
||||
let dir = temp_dir(tag);
|
||||
let ids: Vec<_> = s.visible().iter().copied().take(2).collect();
|
||||
let o = ExportOptions::from_json(&json!({"longEdge": 48, "naming": "{name}-{seq}"}));
|
||||
let to = Destination { dir: dir.to_string_lossy().into(), exact: None };
|
||||
let mut files = Vec::new();
|
||||
for id in ids {
|
||||
files.extend(disk_batch(s, id, &o, &to).unwrap());
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_files_go_to_photos_in_order() {
|
||||
let mut s = Session::with_demo();
|
||||
let files = export_two(&mut s, "photos-flow");
|
||||
let written: Vec<String> = files.iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(written.len(), 2);
|
||||
let (runner, calls) = fake(usize::MAX);
|
||||
let imports = Imports::default();
|
||||
let after = AfterExport::apple_photos(runner.clone(), " Trip \"2026\"\n ", &imports).unwrap();
|
||||
// a skipped file and a failed photo are not sent
|
||||
let mut results = files.clone();
|
||||
results.insert(1, json!({"skipped": "/elsewhere/x.jpg"}));
|
||||
results.push(json!({"photo": 9, "file": "y", "error": "no source"}));
|
||||
let out = after.run(&results, false).unwrap();
|
||||
assert_eq!(out["imported"], 2, "{out}");
|
||||
assert_eq!(out["requested"], 2);
|
||||
assert_eq!(out["ids"], json!(["ID-0/L0/001", "ID-1/L0/001"]));
|
||||
assert_eq!(out["album"], "Trip \"2026\"");
|
||||
assert!(out.get("warning").is_none());
|
||||
let args = calls.lock().unwrap()[0].clone();
|
||||
assert_eq!(imported_paths(&args), written, "the files written, in order, as whole arguments");
|
||||
assert!(written.iter().all(|p| std::path::Path::new(p).is_absolute()));
|
||||
assert_eq!(args[args.iter().position(|a| a == MARKER).unwrap() + 1], "Trip \"2026\"");
|
||||
// a cancelled export adds nothing
|
||||
let out = AfterExport::apple_photos(runner.clone(), "", &imports).unwrap().run(&files, true).unwrap();
|
||||
assert!(out["skipped"].as_str().unwrap().contains("cancelled"));
|
||||
assert_eq!(calls.lock().unwrap().len(), 1);
|
||||
// Photos took one of the two: said so, not an error
|
||||
let (runner, _) = fake(1);
|
||||
let out = AfterExport::apple_photos(runner, "", &Imports::default()).unwrap().run(&files, false).unwrap();
|
||||
assert_eq!((out["imported"].as_u64(), out["requested"].as_u64()), (Some(1), Some(2)));
|
||||
assert!(out["warning"].as_str().unwrap().contains("1 of 2"), "{out}");
|
||||
assert!(out["album"].is_null());
|
||||
// permission refused: the outcome says where to allow it; the files stay written
|
||||
let denied: Runner = Arc::new(|_: &[String], _| {
|
||||
Ok(Output {
|
||||
status: Some(1),
|
||||
stdout: String::new(),
|
||||
stderr: "execution error: Not authorized to send Apple events to Photos. (-1743)".into(),
|
||||
})
|
||||
});
|
||||
let out = AfterExport::apple_photos(denied, "", &Imports::default()).unwrap().run(&files, false).unwrap();
|
||||
assert!(out["error"].as_str().unwrap().contains("Privacy & Security › Automation"), "{out}");
|
||||
assert!(written.iter().all(|p| std::path::Path::new(p).is_file()));
|
||||
// nothing asked: nothing done
|
||||
assert!(AfterExport::default().is_none());
|
||||
assert_eq!(AfterExport::default().run(&files, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photos_is_refused_before_anything_is_written_when_it_cannot_be_reached() {
|
||||
let mut s = Session::with_demo();
|
||||
assert!(s.apple_photos.is_none(), "only with_fs installs the runner");
|
||||
let on = ExportOptions { add_to_photos: true, ..Default::default() };
|
||||
let e = s.after_export(&on).err().unwrap();
|
||||
if cfg!(target_os = "macos") {
|
||||
assert!(e.contains("isn't connected in this session"), "{e}");
|
||||
} else {
|
||||
assert_eq!(e, "Apple Photos is only available on macOS");
|
||||
}
|
||||
assert!(s.after_export(&ExportOptions::default()).unwrap().is_none());
|
||||
// with a runner: macOS adds, other platforms still refuse
|
||||
let (runner, calls) = fake(usize::MAX);
|
||||
s.apple_photos = Some(runner);
|
||||
let after = s.after_export(&on);
|
||||
assert_eq!(after.is_ok(), cfg!(target_os = "macos"), "{:?}", after.as_ref().err());
|
||||
match after {
|
||||
Ok(after) => {
|
||||
let files = export_two(&mut s, "photos-session");
|
||||
assert_eq!(after.run(&files, false).unwrap()["imported"], 2);
|
||||
assert_eq!(calls.lock().unwrap().len(), 1);
|
||||
}
|
||||
Err(e) => assert!(e.contains("only available on macOS"), "{e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_add_to_photos_command_needs_a_runner_and_checks_its_input() {
|
||||
let mut s = Session::with_demo();
|
||||
let files = export_two(&mut s, "photos-cmd");
|
||||
let paths: Vec<String> = files.iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
|
||||
let info = |s: &Session| s.commands().into_iter().find(|c| c.id == "export.addToPhotos").unwrap();
|
||||
// no runner: listed, disabled, with the reason
|
||||
let i = info(&s);
|
||||
assert!(!i.enabled);
|
||||
let why = i.disabled_reason.unwrap();
|
||||
assert!(why.contains(if cfg!(target_os = "macos") { "isn't connected" } else { "only available on macOS" }), "{why}");
|
||||
let e = s.execute("export.addToPhotos", &json!({"paths": paths})).unwrap_err().to_string();
|
||||
assert!(e.contains("Apple Photos"), "{e}");
|
||||
let (runner, calls) = fake(usize::MAX);
|
||||
s.apple_photos = Some(runner);
|
||||
assert!(info(&s).enabled);
|
||||
// no window: it waits for Photos by default
|
||||
let r = s.execute("export.addToPhotos", &json!({"paths": paths, "album": "Läufe/日本 \\ \"q\""})).unwrap();
|
||||
assert_eq!((r["imported"].as_u64(), r["running"].as_bool(), r["job"].as_u64()), (Some(2), Some(false), Some(1)), "{r}");
|
||||
assert_eq!(r["album"], "Läufe/日本 \\ \"q\"");
|
||||
assert_eq!(imported_paths(&calls.lock().unwrap()[0]), paths);
|
||||
// bad input never reaches the runner (paths from the temp folder: absolute on every platform)
|
||||
let missing = std::env::temp_dir().join("lc-photos-nonexistent").join("a.jpg").to_string_lossy().into_owned();
|
||||
for (p, msg) in [
|
||||
(json!({}), "missing `paths`"),
|
||||
(json!({"paths": "a.jpg"}), "missing `paths`"),
|
||||
(json!({"paths": [1]}), "array of file paths"),
|
||||
(json!({"paths": []}), "no files"),
|
||||
(json!({"paths": ["relative.jpg"]}), "absolute"),
|
||||
(json!({"paths": [missing]}), "no such file"),
|
||||
(json!({"paths": paths, "album": 3}), "`album` must be a string"),
|
||||
(json!({"paths": paths, "wait": "yes"}), "`wait` must be true or false"),
|
||||
(json!({"paths": paths, "albm": "x"}), "unknown parameter `albm`"),
|
||||
] {
|
||||
let e = s.execute("export.addToPhotos", &p).unwrap_err().to_string();
|
||||
assert!(e.contains(msg), "{p}: {e}");
|
||||
}
|
||||
assert_eq!(calls.lock().unwrap().len(), 1);
|
||||
// not journaled: replaying actions must not import again
|
||||
assert!(!s.journal.iter().any(|(id, _)| id == "export.addToPhotos"));
|
||||
}
|
||||
|
||||
/// The desktop app answers control requests on its UI thread: `export.addToPhotos` must not
|
||||
/// wait there for Photos (minutes, or a permission prompt). It returns the running job; the
|
||||
/// outcome comes from `export.photosImports`; a second import, or an export asking for Photos,
|
||||
/// is refused until it ends, so a retry never adds the files twice.
|
||||
#[test]
|
||||
fn a_slow_photos_import_runs_in_the_background() {
|
||||
let mut s = Session::with_demo();
|
||||
let files = export_two(&mut s, "photos-slow");
|
||||
let paths: Vec<String> = files.iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
|
||||
let (go, wait) = std::sync::mpsc::channel::<()>();
|
||||
let wait = Mutex::new(wait);
|
||||
let runner: Runner = Arc::new(move |args: &[String], _| {
|
||||
// a regression (waiting on the caller's thread) fails after 20 s instead of hanging
|
||||
let _ = wait.lock().unwrap().recv_timeout(std::time::Duration::from_secs(20));
|
||||
let n = args.len() - args.iter().position(|a| a == MARKER).unwrap() - 2;
|
||||
Ok(Output { status: Some(0), stdout: (0..n).map(|i| format!("ID-{i}\n")).collect(), stderr: String::new() })
|
||||
});
|
||||
s.apple_photos = Some(runner);
|
||||
s.apple_photos_background = true; // as in the desktop app
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = s.execute("export.addToPhotos", &json!({"paths": paths, "album": "Slow"})).unwrap();
|
||||
assert!(t0.elapsed() < std::time::Duration::from_secs(5), "answered without waiting for Photos");
|
||||
assert_eq!(r, json!({"job": 1, "running": true, "requested": 2, "album": "Slow"}));
|
||||
let status = |s: &mut Session| s.execute("export.photosImports", &json!({"job": 1})).unwrap();
|
||||
assert_eq!(status(&mut s)["running"], true);
|
||||
// a retry is refused, and says why
|
||||
let e = s.execute("export.addToPhotos", &json!({"paths": paths})).unwrap_err().to_string();
|
||||
assert!(e.contains("still adding 2 file(s) from an earlier request (import 1)"), "{e}");
|
||||
if cfg!(target_os = "macos") {
|
||||
let on = ExportOptions { add_to_photos: true, ..Default::default() };
|
||||
assert!(s.after_export(&on).err().unwrap().contains("still adding"), "refused before writing anything");
|
||||
}
|
||||
go.send(()).unwrap();
|
||||
while status(&mut s)["running"] == true {
|
||||
assert!(t0.elapsed() < std::time::Duration::from_secs(20), "the import finishes");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
let done = status(&mut s);
|
||||
assert_eq!((done["imported"].as_u64(), done["album"].as_str()), (Some(2), Some("Slow")), "{done}");
|
||||
let all = s.execute("export.photosImports", &json!({})).unwrap();
|
||||
assert_eq!(all["imports"].as_array().map(Vec::len), Some(1));
|
||||
assert!(s.execute("export.photosImports", &json!({"job": 7})).unwrap_err().to_string().contains("no import 7"));
|
||||
// finished: the next one may start
|
||||
go.send(()).unwrap();
|
||||
assert_eq!(s.execute("export.addToPhotos", &json!({"paths": paths, "wait": true})).unwrap()["job"], 2);
|
||||
}
|
||||
|
||||
/// Review of #236: an export adding to Photos reserves the import before it writes its files.
|
||||
/// While it renders, `export.addToPhotos` is refused, naming the export's job, instead of
|
||||
/// taking Photos and leaving the export to fail its Photos step after writing.
|
||||
#[test]
|
||||
fn an_export_holds_photos_from_before_it_writes() {
|
||||
let mut s = Session::with_demo();
|
||||
let (runner, calls) = fake(usize::MAX);
|
||||
s.apple_photos = Some(runner.clone());
|
||||
let elsewhere = export_two(&mut s, "photos-hold-other");
|
||||
let other: Vec<String> = elsewhere.iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
|
||||
// what Session::after_export does on macOS (it refuses other platforms first)
|
||||
let after = AfterExport::apple_photos(runner, "Held", &s.apple_photos_imports).unwrap();
|
||||
let job = s.execute("export.photosImports", &json!({"job": 1})).unwrap();
|
||||
assert_eq!((job["running"].as_bool(), job["exporting"].as_bool()), (Some(true), Some(true)), "{job}");
|
||||
let e = s.execute("export.addToPhotos", &json!({"paths": other})).unwrap_err().to_string();
|
||||
assert!(e.contains("An export is about to add its files to Apple Photos (import 1)"), "{e}");
|
||||
if cfg!(target_os = "macos") {
|
||||
let on = ExportOptions { add_to_photos: true, ..Default::default() };
|
||||
assert!(s.after_export(&on).err().unwrap().contains("(import 1)"), "a second export is refused before writing");
|
||||
}
|
||||
assert!(calls.lock().unwrap().is_empty());
|
||||
// the export writes its files and hands them over: imported under its own job
|
||||
let files = export_two(&mut s, "photos-hold");
|
||||
let out = after.run(&files, false).unwrap();
|
||||
assert_eq!((out["job"].as_u64(), out["imported"].as_u64(), out["album"].as_str()), (Some(1), Some(2), Some("Held")), "{out}");
|
||||
// then the slot is free
|
||||
assert_eq!(s.execute("export.addToPhotos", &json!({"paths": other})).unwrap()["job"], 2);
|
||||
// an export that fails (its AfterExport dropped) frees it too
|
||||
drop(AfterExport::apple_photos(fake(usize::MAX).0, "", &s.apple_photos_imports).unwrap());
|
||||
assert!(s.apple_photos_imports.running().is_none());
|
||||
assert_eq!(s.execute("export.addToPhotos", &json!({"paths": other})).unwrap()["job"], 4);
|
||||
}
|
||||
|
||||
/// Review of #236: a runner that panics while the command or an export waits for it is that
|
||||
/// import's error; the slot is freed, so the next import isn't refused as "still running"
|
||||
/// and a process ending doesn't wait for it.
|
||||
#[test]
|
||||
fn a_panicking_runner_never_holds_photos() {
|
||||
let mut s = Session::with_demo();
|
||||
let files = export_two(&mut s, "photos-panic");
|
||||
let paths: Vec<String> = files.iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
|
||||
s.apple_photos = Some(Arc::new(|_: &[String], _| panic!("runner exploded")));
|
||||
// the command (no window: it waits)
|
||||
let e = s.execute("export.addToPhotos", &json!({"paths": paths})).unwrap_err().to_string();
|
||||
assert!(e.contains("stopped unexpectedly"), "{e}");
|
||||
assert!(s.apple_photos_imports.running().is_none());
|
||||
assert_eq!(s.execute("export.photosImports", &json!({"job": 1})).unwrap()["running"], false);
|
||||
// an export's reservation
|
||||
let runner = s.apple_photos.clone().unwrap();
|
||||
let out = AfterExport::apple_photos(runner, "", &s.apple_photos_imports).unwrap().run(&files, false).unwrap();
|
||||
assert!(out["error"].as_str().unwrap().contains("stopped unexpectedly"), "{out}");
|
||||
assert!(s.apple_photos_imports.running().is_none());
|
||||
assert!(!s.wait_for_photos_imports(), "nothing left to wait for");
|
||||
// and the next import runs
|
||||
let (good, _) = fake(usize::MAX);
|
||||
s.apple_photos = Some(good);
|
||||
assert_eq!(s.execute("export.addToPhotos", &json!({"paths": paths})).unwrap()["imported"], 2);
|
||||
}
|
||||
|
||||
/// The desktop app, lightcraft-cli and the MCP server get the real runner from `with_fs`, on
|
||||
/// macOS only. (Installed, never run here.)
|
||||
#[test]
|
||||
fn with_fs_installs_the_runner_on_macos_only() {
|
||||
let s = Session::new().with_fs();
|
||||
assert_eq!(s.apple_photos.is_some(), cfg!(target_os = "macos"));
|
||||
assert_eq!(s.commands().into_iter().find(|c| c.id == "export.addToPhotos").unwrap().enabled, cfg!(target_os = "macos"));
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,12 @@ pub struct Headless {
|
||||
}
|
||||
|
||||
impl Drop for Headless {
|
||||
/// Snapshot a persistent library on exit (a no-op for in-memory sessions).
|
||||
/// Snapshot a persistent library on exit (a no-op for in-memory sessions), after any Apple
|
||||
/// Photos import still running (`export.addToPhotos {wait: false}`) has ended: the process
|
||||
/// must not end in the middle of one, abandoning osascript and the import's result.
|
||||
fn drop(&mut self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.session.wait_for_photos_imports();
|
||||
let _ = self.session.close_library();
|
||||
}
|
||||
}
|
||||
@@ -83,6 +87,8 @@ impl Headless {
|
||||
};
|
||||
let dir = p.get("dir").and_then(Value::as_str).unwrap_or("");
|
||||
let write = &mut lightcraft_engine::export::write_file;
|
||||
// Add to Apple Photos: refused before anything is written when it can't be done
|
||||
let after = self.session.after_export(&opts)?;
|
||||
let items = prepare_batch(&mut self.session, &ids, &opts)?;
|
||||
let total = items.len();
|
||||
let mut hook = self.progress.take();
|
||||
@@ -107,6 +113,9 @@ impl Headless {
|
||||
}
|
||||
// Single-photo exports also report path/width/height at the top level (back-compat).
|
||||
let mut out = files.first().cloned().unwrap_or_else(|| json!({}));
|
||||
if let Some(photos) = after.run(&files, false) {
|
||||
out["applePhotos"] = photos;
|
||||
}
|
||||
out["files"] = json!(files);
|
||||
Ok(out)
|
||||
}
|
||||
@@ -131,7 +140,7 @@ impl Backend for Headless {
|
||||
"engine.commands" => {
|
||||
let mut v: Vec<Value> = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
|
||||
v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null,
|
||||
"params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, inset? (margin, 0..0.4 of the short edge; 0.025), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default",
|
||||
"params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, addToPhotos?: bool (macOS: then add the files written to Apple Photos; outcome in `applePhotos`), photosAlbum?: top-level album name (made when missing), watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, inset? (margin, 0..0.4 of the short edge; 0.025), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default",
|
||||
"enabled": self.session.active().is_some()}));
|
||||
Ok(Value::Array(v))
|
||||
}
|
||||
|
||||
@@ -152,6 +152,99 @@ fn export_tool_forwards_its_advertised_options() {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// Issue #236: `app.export {addToPhotos, photosAlbum}` through the headless backend (the MCP
|
||||
/// `export` tool, `lightcraft-cli run`): the files written go to Photos and the result says how
|
||||
/// it went; where Photos isn't (not macOS), it is refused before anything is written. A fake
|
||||
/// runner replaces the real one first: tests never start osascript or Photos.
|
||||
#[test]
|
||||
fn export_adds_to_apple_photos_where_there_is_photos() {
|
||||
use std::sync::{Arc, Mutex};
|
||||
let mut b = Headless::demo();
|
||||
let calls = Arc::new(Mutex::new(Vec::<Vec<String>>::new()));
|
||||
let c = calls.clone();
|
||||
b.session.apple_photos = Some(Arc::new(move |args: &[String], _| {
|
||||
c.lock().unwrap().push(args.to_vec());
|
||||
Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID-1/L0/001\n".into(), stderr: String::new() })
|
||||
}));
|
||||
let base = std::env::temp_dir().join(format!("lc-mcp-export-photos-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let r = call_tool(&mut b, "export", &json!({"dir": base.to_string_lossy(), "longEdge": 32, "addToPhotos": true, "photosAlbum": "From \"MCP\""}));
|
||||
if cfg!(target_os = "macos") {
|
||||
assert!(!r.is_error, "{r:?}");
|
||||
let out = r.structured.unwrap();
|
||||
assert_eq!(out["applePhotos"]["imported"], 1, "{out}");
|
||||
assert_eq!(out["applePhotos"]["album"], "From \"MCP\"");
|
||||
let written = out["files"][0]["path"].as_str().unwrap().to_string();
|
||||
let args = calls.lock().unwrap().clone();
|
||||
assert_eq!(args.len(), 1);
|
||||
assert_eq!(args[0].last(), Some(&written));
|
||||
} else {
|
||||
assert!(r.is_error, "{r:?}");
|
||||
assert!(format!("{:?}", r.content).contains("only available on macOS"), "{r:?}");
|
||||
assert!(!base.exists(), "nothing written");
|
||||
assert!(calls.lock().unwrap().is_empty());
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// Review of #236: a one-shot process (`lightcraft-cli run`, an MCP server reaching EOF) must not
|
||||
/// end in the middle of an Apple Photos import, abandoning osascript and the result. The test runs
|
||||
/// itself as a child process (`LC_PHOTOS_CHILD`): the child adds a file with a fake runner that
|
||||
/// takes a while and then leaves a marker, prints the reply and ends as the CLI does (dropping
|
||||
/// its backend). The parent checks the import had finished: by default the call waited for
|
||||
/// Photos, and with `wait: false` the backend waited before the process ended. Never osascript:
|
||||
/// the child's session has no file-system hooks and only the fake runner.
|
||||
#[test]
|
||||
fn a_one_shot_process_never_ends_during_a_photos_import() {
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
if let (Ok(dir), Ok(mode)) = (std::env::var("LC_PHOTOS_CHILD"), std::env::var("LC_PHOTOS_CHILD_MODE")) {
|
||||
let dir = std::path::PathBuf::from(dir);
|
||||
let file = dir.join("a.jpg");
|
||||
let marker = dir.join("imported");
|
||||
let mut h = Headless::new(lightcraft_engine::Session::with_demo());
|
||||
h.session.apple_photos = Some(Arc::new(move |args: &[String], _| {
|
||||
std::thread::sleep(Duration::from_millis(400));
|
||||
std::fs::write(&marker, args.last().cloned().unwrap_or_default()).unwrap();
|
||||
Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID-1\n".into(), stderr: String::new() })
|
||||
}));
|
||||
let mut p = json!({"paths": [file.to_string_lossy()]});
|
||||
if mode == "nowait" {
|
||||
p["wait"] = json!(false);
|
||||
}
|
||||
let r = h.call("engine.execute", json!({"command": "export.addToPhotos", "params": p})).unwrap();
|
||||
println!("CHILD-REPLY {r}");
|
||||
drop(h); // what lightcraft-cli does before it exits
|
||||
return;
|
||||
}
|
||||
for mode in ["default", "nowait"] {
|
||||
let dir = std::env::temp_dir().join(format!("lc-mcp-photos-child-{mode}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("a.jpg"), b"jpeg").unwrap();
|
||||
let out = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "tests::a_one_shot_process_never_ends_during_a_photos_import", "--nocapture", "--test-threads=1"])
|
||||
.env("LC_PHOTOS_CHILD", &dir)
|
||||
.env("LC_PHOTOS_CHILD_MODE", mode)
|
||||
.output()
|
||||
.unwrap();
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(out.status.success(), "{mode}: {stdout}\n{}", String::from_utf8_lossy(&out.stderr));
|
||||
let reply: Value = stdout
|
||||
.lines()
|
||||
.find_map(|l| l.split_once("CHILD-REPLY ").map(|(_, j)| serde_json::from_str(j).unwrap()))
|
||||
.unwrap_or_else(|| panic!("{mode}: no reply in {stdout}"));
|
||||
let marker = std::fs::read_to_string(dir.join("imported")).unwrap_or_else(|_| panic!("{mode}: the process ended before the import did"));
|
||||
assert!(marker.ends_with("a.jpg"), "{marker}");
|
||||
if mode == "default" {
|
||||
assert_eq!((reply["running"].as_bool(), reply["imported"].as_u64()), (Some(false), Some(1)), "waited: {reply}");
|
||||
} else {
|
||||
assert_eq!(reply["running"], true, "{reply}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// The `import` helper moves: renamed into the folder template, the source removed.
|
||||
#[test]
|
||||
fn import_tool_moves_with_a_folder_template() {
|
||||
|
||||
@@ -259,6 +259,8 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
||||
"colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"},
|
||||
"bitDepth": {"type": "integer", "enum": [8, 10, 16, 32], "description": "Bits per channel: PNG 8|16 (default 8), TIFF 8|16|32 (default 16; 32 = linear float with a linear profile), AVIF 8|10; JPEG/WebP are 8-bit"},
|
||||
"hdr": {"type": "boolean", "description": "HDR output for photos edited in HDR (develop.hdr): JPEG as an ISO 21496-1 gain map JPEG, AVIF as 10-bit Rec. 2020 PQ, 32-bit TIFF with highlights above 1; other formats and SDR edits export as usual"},
|
||||
"addToPhotos": {"type": "boolean", "description": "macOS: after writing the files, add them to Apple Photos (Apple Events to Photos' import; the first time, macOS asks the user to allow LightCraft to control Photos). The outcome is `applePhotos` in the result, the import job: {job, running, requested, album, imported?, ids?, warning?, error?}; headless waits for Photos, the desktop app returns the running job (outcome: command export.photosImports {job}). The files are written either way. An error before exporting on other platforms, or while another import is reserved or running"},
|
||||
"photosAlbum": {"type": "string", "description": "With addToPhotos: the album to add them to, a top-level Photos album of exactly this name, made when missing (several of that name: nothing is added); default none"},
|
||||
"watermark": {"description": "Text, or {text, vertical (boolean; defaults to false: upright columns right to left), size (text height as a fraction of the short edge, 0.005..0.5; default 0.035), opacity (0..1; default 0.7), anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset (margin as a fraction of the short edge, 0..0.4; default 0.025), color [r,g,b] (sRGB 0..255), shadow (boolean), image (path of a graphic drawn instead of the text), imageWidth (fraction of the photo's width, 0.01..1; default 0.2)}. Unknown keys and out-of-range sizes are errors"}
|
||||
}),
|
||||
&[],
|
||||
@@ -745,6 +747,8 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
|
||||
"conflict",
|
||||
"tiffCompression",
|
||||
"dngCompression",
|
||||
"addToPhotos",
|
||||
"photosAlbum",
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -228,5 +228,8 @@
|
||||
"+{n} more": "+{n} weitere",
|
||||
"1 task is still running: {names}. Quitting stops it.": "1 Aufgabe läuft noch: {names}. Beenden bricht sie ab.",
|
||||
"{n} tasks are still running: {names}. Quitting stops them.": "{n} Aufgaben laufen noch: {names}. Beenden bricht sie ab.",
|
||||
"{what} merge cancelled": "{what}-Zusammenfügung abgebrochen"
|
||||
"{what} merge cancelled": "{what}-Zusammenfügung abgebrochen",
|
||||
"not added to Apple Photos: {e}": "nicht zu Apple Fotos hinzugefügt: {e}",
|
||||
"{n} added to Apple Photos album “{album}”": "{n} zum Apple-Fotos-Album „{album}“ hinzugefügt",
|
||||
"{n} added to Apple Photos": "{n} zu Apple Fotos hinzugefügt"
|
||||
}
|
||||
@@ -2039,5 +2039,9 @@
|
||||
"Downloading model": "Modell wird heruntergeladen",
|
||||
"Finding faces": "Gesichter werden gesucht",
|
||||
"Synchronizing folder": "Ordner wird synchronisiert",
|
||||
"Looking for changes": "Änderungen werden gesucht"
|
||||
"Looking for changes": "Änderungen werden gesucht",
|
||||
"Add to Apple Photos": "Zu Apple Fotos hinzufügen",
|
||||
"Adding to Apple Photos…": "Wird zu Apple Fotos hinzugefügt…",
|
||||
"not added to Apple Photos (cancelled)": "nicht zu Apple Fotos hinzugefügt (abgebrochen)",
|
||||
"The first time, macOS asks whether LightCraft may control Photos.": "Beim ersten Mal fragt macOS, ob LightCraft Fotos steuern darf."
|
||||
}
|
||||
@@ -228,5 +228,8 @@
|
||||
"+{n} more": "+{n} más",
|
||||
"1 task is still running: {names}. Quitting stops it.": "1 tarea sigue en curso: {names}. Salir la detiene.",
|
||||
"{n} tasks are still running: {names}. Quitting stops them.": "{n} tareas siguen en curso: {names}. Salir las detiene.",
|
||||
"{what} merge cancelled": "Combinación {what} cancelada"
|
||||
"{what} merge cancelled": "Combinación {what} cancelada",
|
||||
"not added to Apple Photos: {e}": "no se añadieron a Fotos de Apple: {e}",
|
||||
"{n} added to Apple Photos album “{album}”": "{n} añadidas al álbum “{album}” de Fotos de Apple",
|
||||
"{n} added to Apple Photos": "{n} añadidas a Fotos de Apple"
|
||||
}
|
||||
@@ -1884,5 +1884,9 @@
|
||||
"Downloading model": "Descargando modelo",
|
||||
"Finding faces": "Buscando caras",
|
||||
"Synchronizing folder": "Sincronizando carpeta",
|
||||
"Looking for changes": "Buscando cambios"
|
||||
"Looking for changes": "Buscando cambios",
|
||||
"Add to Apple Photos": "Añadir a Fotos de Apple",
|
||||
"Adding to Apple Photos…": "Añadiendo a Fotos de Apple…",
|
||||
"not added to Apple Photos (cancelled)": "no se añadieron a Fotos de Apple (cancelado)",
|
||||
"The first time, macOS asks whether LightCraft may control Photos.": "La primera vez, macOS pregunta si LightCraft puede controlar Fotos."
|
||||
}
|
||||
@@ -228,5 +228,8 @@
|
||||
"+{n} more": "+{n} autres",
|
||||
"1 task is still running: {names}. Quitting stops it.": "1 tâche est encore en cours : {names}. Quitter l'arrête.",
|
||||
"{n} tasks are still running: {names}. Quitting stops them.": "{n} tâches sont encore en cours : {names}. Quitter les arrête.",
|
||||
"{what} merge cancelled": "Fusion {what} annulée"
|
||||
"{what} merge cancelled": "Fusion {what} annulée",
|
||||
"not added to Apple Photos: {e}": "non ajouté à Apple Photos : {e}",
|
||||
"{n} added to Apple Photos album “{album}”": "{n} ajouté(s) à l'album Apple Photos « {album} »",
|
||||
"{n} added to Apple Photos": "{n} ajouté(s) à Apple Photos"
|
||||
}
|
||||
@@ -1884,5 +1884,9 @@
|
||||
"Panels": "Panels",
|
||||
"Reset Panel Layout": "Reset Panel Layout",
|
||||
"Resize panel window": "Resize panel window",
|
||||
"Resize panels": "Resize panels"
|
||||
"Resize panels": "Resize panels",
|
||||
"Add to Apple Photos": "Ajouter à Apple Photos",
|
||||
"Adding to Apple Photos…": "Ajout à Apple Photos…",
|
||||
"not added to Apple Photos (cancelled)": "non ajouté à Apple Photos (annulé)",
|
||||
"The first time, macOS asks whether LightCraft may control Photos.": "La première fois, macOS demande si LightCraft peut contrôler Photos."
|
||||
}
|
||||
@@ -228,5 +228,8 @@
|
||||
"+{n} more": "ほか{n}件",
|
||||
"1 task is still running: {names}. Quitting stops it.": "1件のタスクが実行中です:{names}。終了すると停止します。",
|
||||
"{n} tasks are still running: {names}. Quitting stops them.": "{n}件のタスクが実行中です:{names}。終了すると停止します。",
|
||||
"{what} merge cancelled": "{what}合成をキャンセルしました"
|
||||
"{what} merge cancelled": "{what}合成をキャンセルしました",
|
||||
"not added to Apple Photos: {e}": "Appleの写真に追加されませんでした:{e}",
|
||||
"{n} added to Apple Photos album “{album}”": "{n}件をAppleの写真のアルバム「{album}」に追加しました",
|
||||
"{n} added to Apple Photos": "{n}件をAppleの写真に追加しました"
|
||||
}
|
||||
@@ -1886,5 +1886,9 @@
|
||||
"Downloading model": "モデルをダウンロード中",
|
||||
"Finding faces": "顔を検索中",
|
||||
"Synchronizing folder": "フォルダを同期中",
|
||||
"Looking for changes": "変更を確認中"
|
||||
"Looking for changes": "変更を確認中",
|
||||
"Add to Apple Photos": "Appleの写真に追加",
|
||||
"Adding to Apple Photos…": "Appleの写真に追加中…",
|
||||
"not added to Apple Photos (cancelled)": "Appleの写真には追加されませんでした(キャンセル)",
|
||||
"The first time, macOS asks whether LightCraft may control Photos.": "初回は、LightCraftに写真の操作を許可するかmacOSが確認します。"
|
||||
}
|
||||
@@ -228,5 +228,8 @@
|
||||
"+{n} more": "+{n} mais",
|
||||
"1 task is still running: {names}. Quitting stops it.": "1 tarefa ainda está em andamento: {names}. Sair a interrompe.",
|
||||
"{n} tasks are still running: {names}. Quitting stops them.": "{n} tarefas ainda estão em andamento: {names}. Sair as interrompe.",
|
||||
"{what} merge cancelled": "{what} mesclagem cancelada"
|
||||
"{what} merge cancelled": "{what} mesclagem cancelada",
|
||||
"not added to Apple Photos: {e}": "não adicionadas ao Fotos da Apple: {e}",
|
||||
"{n} added to Apple Photos album “{album}”": "{n} adicionadas ao álbum “{album}” do Fotos da Apple",
|
||||
"{n} added to Apple Photos": "{n} adicionadas ao Fotos da Apple"
|
||||
}
|
||||
@@ -1884,5 +1884,9 @@
|
||||
"Downloading model": "Baixando modelo",
|
||||
"Finding faces": "Procurando rostos",
|
||||
"Synchronizing folder": "Sincronizando pasta",
|
||||
"Looking for changes": "Procurando alterações"
|
||||
"Looking for changes": "Procurando alterações",
|
||||
"Add to Apple Photos": "Adicionar ao Fotos da Apple",
|
||||
"Adding to Apple Photos…": "Adicionando ao Fotos da Apple…",
|
||||
"not added to Apple Photos (cancelled)": "não adicionadas ao Fotos da Apple (cancelado)",
|
||||
"The first time, macOS asks whether LightCraft may control Photos.": "Na primeira vez, o macOS pergunta se o LightCraft pode controlar o Fotos."
|
||||
}
|
||||
@@ -228,5 +228,8 @@
|
||||
"+{n} more": "ещё {n}",
|
||||
"1 task is still running: {names}. Quitting stops it.": "Ещё выполняется задача: {names}. Выход прервёт её.",
|
||||
"{n} tasks are still running: {names}. Quitting stops them.": "Ещё выполняется задач: {n} — {names}. Выход прервёт их.",
|
||||
"{what} merge cancelled": "{what}: слияние отменено"
|
||||
"{what} merge cancelled": "{what}: слияние отменено",
|
||||
"not added to Apple Photos: {e}": "не добавлено в Apple Фото: {e}",
|
||||
"{n} added to Apple Photos album “{album}”": "Добавлено в альбом «{album}» Apple Фото: {n}",
|
||||
"{n} added to Apple Photos": "Добавлено в Apple Фото: {n}"
|
||||
}
|
||||
@@ -1598,5 +1598,9 @@
|
||||
"Finding faces": "Поиск лиц",
|
||||
"Quit Anyway": "Всё равно выйти",
|
||||
"Synchronizing folder": "Синхронизация папки",
|
||||
"Looking for changes": "Поиск изменений"
|
||||
"Looking for changes": "Поиск изменений",
|
||||
"Add to Apple Photos": "Добавить в Apple Фото",
|
||||
"Adding to Apple Photos…": "Добавление в Apple Фото…",
|
||||
"not added to Apple Photos (cancelled)": "не добавлено в Apple Фото (отменено)",
|
||||
"The first time, macOS asks whether LightCraft may control Photos.": "В первый раз macOS спросит, может ли LightCraft управлять приложением «Фото»."
|
||||
}
|
||||
@@ -228,5 +228,8 @@
|
||||
"+{n} more": "+{n} ще",
|
||||
"1 task is still running: {names}. Quitting stops it.": "1 завдання ще виконується: {names}. Вихід зупинить його.",
|
||||
"{n} tasks are still running: {names}. Quitting stops them.": "Ще виконуються завдання ({n}): {names}. Вихід зупинить їх.",
|
||||
"{what} merge cancelled": "Об’єднання {what} скасовано"
|
||||
"{what} merge cancelled": "Об’єднання {what} скасовано",
|
||||
"not added to Apple Photos: {e}": "не додано до Apple Фото: {e}",
|
||||
"{n} added to Apple Photos album “{album}”": "Додано до альбому Apple Фото «{album}»: {n}",
|
||||
"{n} added to Apple Photos": "Додано до Apple Фото: {n}"
|
||||
}
|
||||
@@ -2078,5 +2078,9 @@
|
||||
"Panels": "Panels",
|
||||
"Reset Panel Layout": "Reset Panel Layout",
|
||||
"Resize panel window": "Resize panel window",
|
||||
"Resize panels": "Resize panels"
|
||||
"Resize panels": "Resize panels",
|
||||
"Add to Apple Photos": "Додати до Apple Фото",
|
||||
"Adding to Apple Photos…": "Додавання до Apple Фото…",
|
||||
"not added to Apple Photos (cancelled)": "не додано до Apple Фото (скасовано)",
|
||||
"The first time, macOS asks whether LightCraft may control Photos.": "Першого разу macOS запитає, чи може LightCraft керувати програмою «Фото»."
|
||||
}
|
||||
@@ -228,5 +228,8 @@
|
||||
"+{n} more": "还有 {n} 项",
|
||||
"1 task is still running: {names}. Quitting stops it.": "仍有 1 个任务在运行:{names}。退出将停止它。",
|
||||
"{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 个任务在运行:{names}。退出将停止它们。",
|
||||
"{what} merge cancelled": "已取消 {what} 合并"
|
||||
"{what} merge cancelled": "已取消 {what} 合并",
|
||||
"not added to Apple Photos: {e}": "未添加到 Apple 照片:{e}",
|
||||
"{n} added to Apple Photos album “{album}”": "已将 {n} 个添加到 Apple 照片相册“{album}”",
|
||||
"{n} added to Apple Photos": "已将 {n} 个添加到 Apple 照片"
|
||||
}
|
||||
@@ -1884,5 +1884,9 @@
|
||||
"Downloading model": "正在下载模型",
|
||||
"Finding faces": "正在查找人脸",
|
||||
"Synchronizing folder": "正在同步文件夹",
|
||||
"Looking for changes": "正在查找更改"
|
||||
"Looking for changes": "正在查找更改",
|
||||
"Add to Apple Photos": "添加到 Apple 照片",
|
||||
"Adding to Apple Photos…": "正在添加到 Apple 照片…",
|
||||
"not added to Apple Photos (cancelled)": "未添加到 Apple 照片(已取消)",
|
||||
"The first time, macOS asks whether LightCraft may control Photos.": "首次使用时,macOS 会询问是否允许 LightCraft 控制“照片”。"
|
||||
}
|
||||
@@ -228,5 +228,8 @@
|
||||
"+{n} more": "還有 {n} 項",
|
||||
"1 task is still running: {names}. Quitting stops it.": "仍有 1 個工作在執行:{names}。結束將停止它。",
|
||||
"{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 個工作在執行:{names}。結束將停止它們。",
|
||||
"{what} merge cancelled": "已取消{what}合併"
|
||||
"{what} merge cancelled": "已取消{what}合併",
|
||||
"not added to Apple Photos: {e}": "未加入 Apple 照片:{e}",
|
||||
"{n} added to Apple Photos album “{album}”": "已將 {n} 個加入 Apple 照片相簿「{album}」",
|
||||
"{n} added to Apple Photos": "已將 {n} 個加入 Apple 照片"
|
||||
}
|
||||
@@ -1884,5 +1884,9 @@
|
||||
"Downloading model": "正在下載模型",
|
||||
"Finding faces": "正在尋找臉孔",
|
||||
"Synchronizing folder": "正在同步資料夾",
|
||||
"Looking for changes": "正在尋找變更"
|
||||
"Looking for changes": "正在尋找變更",
|
||||
"Add to Apple Photos": "加入 Apple 照片",
|
||||
"Adding to Apple Photos…": "正在加入 Apple 照片…",
|
||||
"not added to Apple Photos (cancelled)": "未加入 Apple 照片(已取消)",
|
||||
"The first time, macOS asks whether LightCraft may control Photos.": "第一次使用時,macOS 會詢問是否允許 LightCraft 控制「照片」。"
|
||||
}
|
||||
@@ -472,13 +472,23 @@ pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result<Value, String
|
||||
return Err(NO_EXPORT_FOLDER.into());
|
||||
}
|
||||
let to = Destination { dir: dir.clone(), exact };
|
||||
// Add to Apple Photos: refused here, before anything is written, when it can't be done
|
||||
let after = app.session.after_export(&opts)?;
|
||||
let background = p.get("background").and_then(Value::as_bool).unwrap_or(false) && app.services.write_shared.is_some();
|
||||
let out = if background {
|
||||
let items = lightcraft_engine::export::prepare_batch(&mut app.session, &ids, &opts)?;
|
||||
crate::export_task::start(app, items, opts, to)?
|
||||
crate::export_task::start(app, items, opts, to, after)?
|
||||
} else {
|
||||
let w = app.services.write.as_mut().ok_or("no writer")?;
|
||||
json!({"files": export_batch(&mut app.session, &ids, &opts, &to, &mut |path, bytes| w(path, bytes), &|path| std::path::Path::new(path).exists())?})
|
||||
let files = export_batch(&mut app.session, &ids, &opts, &to, &mut |path, bytes| w(path, bytes), &|path| std::path::Path::new(path).exists())?;
|
||||
let mut out = json!({"files": files});
|
||||
// Photos may take minutes (and waits for the permission prompt): in the desktop app on a
|
||||
// worker, never on this (UI) thread; the app announces the outcome and
|
||||
// `export.photosImports` reports it. A window-less run (snapshot scripts) waits.
|
||||
if let Some(photos) = after.finish(&app.session, &files) {
|
||||
out["applePhotos"] = photos;
|
||||
}
|
||||
out
|
||||
};
|
||||
// remember for Export with Previous (and to prefill the dialog)
|
||||
let mut last = p.clone();
|
||||
|
||||
@@ -12,7 +12,7 @@ use std::sync::mpsc::{Receiver, channel};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use lightcraft_engine::activity::{Cancel, TaskGuard};
|
||||
use lightcraft_engine::export::{Destination, ExportOptions, PreparedExport, run_batch};
|
||||
use lightcraft_engine::export::{AfterExport, Destination, ExportOptions, PreparedExport, run_batch};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::LightcraftApp;
|
||||
@@ -22,7 +22,8 @@ pub struct ExportTask {
|
||||
/// Photos started so far and the file in progress.
|
||||
pub progress: Arc<Mutex<(usize, String)>>,
|
||||
pub cancel: Arc<AtomicBool>,
|
||||
rx: Receiver<Result<Vec<Value>, String>>,
|
||||
/// The files (per-photo results) and what [`AfterExport`] did with them (`applePhotos`).
|
||||
rx: Receiver<Result<(Vec<Value>, Option<Value>), String>>,
|
||||
/// The export's row in the activity stack (`activity.cancel` sets `cancel`).
|
||||
guard: TaskGuard,
|
||||
}
|
||||
@@ -35,8 +36,9 @@ impl ExportTask {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start exporting `items` in the background. Errors per photo are collected, not fatal.
|
||||
pub fn start(app: &mut LightcraftApp, items: Vec<PreparedExport>, opts: ExportOptions, to: Destination) -> Result<Value, String> {
|
||||
/// Start exporting `items` in the background, then do `after` with the files written (Add to Apple
|
||||
/// Photos). Errors per photo are collected, not fatal.
|
||||
pub fn start(app: &mut LightcraftApp, items: Vec<PreparedExport>, opts: ExportOptions, to: Destination, after: AfterExport) -> Result<Value, String> {
|
||||
if app.export.is_some() {
|
||||
return Err("an export is already running".into());
|
||||
}
|
||||
@@ -54,6 +56,15 @@ pub fn start(app: &mut LightcraftApp, items: Vec<PreparedExport>, opts: ExportOp
|
||||
}
|
||||
!c.load(Ordering::Relaxed)
|
||||
});
|
||||
let r = r.map(|files| {
|
||||
if !after.is_none()
|
||||
&& let Ok(mut g) = p.lock()
|
||||
{
|
||||
g.1 = crate::i18n::tr("Adding to Apple Photos…").to_string();
|
||||
}
|
||||
let photos = after.run(&files, c.load(Ordering::Relaxed));
|
||||
(files, photos)
|
||||
});
|
||||
let _ = tx.send(r);
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -100,7 +111,8 @@ pub fn start_contact_sheet(app: &mut LightcraftApp, params: &Value) -> Result<Va
|
||||
}
|
||||
Ok(vec![json!({"path": path, "pages": doc.pages, "photos": doc.photos, "bytes": doc.bytes.len(), "contactSheet": true})])
|
||||
});
|
||||
let _ = tx.send(result);
|
||||
// a PDF isn't something Photos imports: contact sheets never add to Apple Photos
|
||||
let _ = tx.send(result.map(|files| (files, None)));
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::thread::Builder::new().name("contact-sheet".into()).spawn(work).map_err(|e| e.to_string())?;
|
||||
@@ -112,6 +124,53 @@ pub fn start_contact_sheet(app: &mut LightcraftApp, params: &Value) -> Result<Va
|
||||
Ok(json!({"background": true, "total": total}))
|
||||
}
|
||||
|
||||
/// The toast's account of [`AfterExport::run`]'s Apple Photos outcome.
|
||||
pub fn apple_photos_note(outcome: &Value) -> String {
|
||||
if let Some(e) = outcome.get("error").and_then(Value::as_str) {
|
||||
return crate::i18n::tr_format!("not added to Apple Photos: {e}", e = e);
|
||||
}
|
||||
if let Some(why) = outcome.get("skipped").and_then(Value::as_str) {
|
||||
if why.contains("cancelled") {
|
||||
return crate::i18n::tr("not added to Apple Photos (cancelled)").to_string();
|
||||
}
|
||||
return crate::i18n::tr_format!("not added to Apple Photos: {e}", e = why);
|
||||
}
|
||||
let n = outcome.get("imported").and_then(Value::as_u64).unwrap_or(0);
|
||||
let mut m = match outcome.get("album").and_then(Value::as_str) {
|
||||
Some(album) => crate::i18n::tr_format!("{n} added to Apple Photos album “{album}”", n = n, album = album),
|
||||
None => crate::i18n::tr_format!("{n} added to Apple Photos", n = n),
|
||||
};
|
||||
if let Some(w) = outcome.get("warning").and_then(Value::as_str) {
|
||||
m += &format!(" ({w})");
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// Per frame: when an Apple Photos import that no export task reports finishes (`app.export`
|
||||
/// without `background`, `export.addToPhotos`), say how it went.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn poll_photos(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let jobs = app.session.apple_photos_imports.all();
|
||||
if jobs.iter().any(|j| !j.finished()) {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(250));
|
||||
}
|
||||
for job in jobs {
|
||||
if job.take_announcement() {
|
||||
let outcome = job.json();
|
||||
let msg = apple_photos_note(&outcome);
|
||||
if outcome.get("error").is_some() {
|
||||
app.toast_for(ctx, msg, 15.0);
|
||||
} else {
|
||||
app.toast(ctx, msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// No Apple Photos on the web.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn poll_photos(_: &mut LightcraftApp, _: &egui::Context) {}
|
||||
|
||||
/// Per frame: keep the activity row up to date; when the batch finishes, report it.
|
||||
pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(task) = &app.export else { return };
|
||||
@@ -120,8 +179,10 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let cancelled = task.cancel.load(Ordering::Relaxed);
|
||||
let total = task.total;
|
||||
app.export = None;
|
||||
// Photos refused (permission, not installed…): leave the way out on screen longer
|
||||
let photos_failed = matches!(&r, Ok((_, Some(p))) if p.get("error").is_some());
|
||||
let msg = match r {
|
||||
Ok(files) => {
|
||||
Ok((files, photos)) => {
|
||||
let sheet = files.first().filter(|f| f["contactSheet"] == true);
|
||||
let ok = files.iter().filter(|f| f.get("path").is_some()).count();
|
||||
let failed: Vec<&Value> = files.iter().filter(|f| f.get("error").is_some()).collect();
|
||||
@@ -140,12 +201,20 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
if cancelled {
|
||||
m += " · cancelled";
|
||||
}
|
||||
app.last_export_result = Some(json!({"files": files, "cancelled": cancelled}));
|
||||
if let Some(photos) = &photos {
|
||||
m += " · ";
|
||||
m += &apple_photos_note(photos);
|
||||
}
|
||||
app.last_export_result = Some(json!({"files": files, "cancelled": cancelled, "applePhotos": photos}));
|
||||
m
|
||||
}
|
||||
Err(e) => e,
|
||||
};
|
||||
app.toast(ctx, msg);
|
||||
if photos_failed {
|
||||
app.toast_for(ctx, msg, 15.0);
|
||||
} else {
|
||||
app.toast(ctx, msg);
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => {
|
||||
let (done, current) = task.progress.lock().map(|g| g.clone()).unwrap_or_default();
|
||||
@@ -248,3 +317,24 @@ mod contact_sheet_tests {
|
||||
assert!(task.rx.recv_timeout(Duration::from_secs(20)).unwrap().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The toast's Apple Photos part (issue #236): the count and album, a partial import's
|
||||
/// warning, an error with what to do, a cancelled export.
|
||||
#[test]
|
||||
fn apple_photos_outcomes_read_well() {
|
||||
assert_eq!(apple_photos_note(&json!({"imported": 3, "requested": 3, "ids": [], "album": null})), "3 added to Apple Photos");
|
||||
assert_eq!(apple_photos_note(&json!({"imported": 1, "album": "Trip"})), "1 added to Apple Photos album “Trip”");
|
||||
let partial = apple_photos_note(&json!({"imported": 1, "requested": 2, "warning": "Photos added 1 of 2 files"}));
|
||||
assert_eq!(partial, "1 added to Apple Photos (Photos added 1 of 2 files)");
|
||||
let denied = apple_photos_note(
|
||||
&json!({"error": "LightCraft isn't allowed to control Photos. Allow it in System Settings › Privacy & Security › Automation"}),
|
||||
);
|
||||
assert!(denied.starts_with("not added to Apple Photos: ") && denied.contains("Automation"), "{denied}");
|
||||
assert_eq!(apple_photos_note(&json!({"skipped": "the export was cancelled"})), "not added to Apple Photos (cancelled)");
|
||||
assert_eq!(apple_photos_note(&json!({"skipped": "the export wrote no files"})), "not added to Apple Photos: the export wrote no files");
|
||||
}
|
||||
}
|
||||
@@ -234,6 +234,16 @@ pub struct Headless {
|
||||
pub last_cursor: egui::CursorIcon,
|
||||
}
|
||||
|
||||
/// A headless run (`lightcraft-cli snapshot`, tests) is a one-shot process: however it ends, it
|
||||
/// first waits for an Apple Photos import its script left running (`export.addToPhotos {wait:
|
||||
/// false}`), bounded, so it never abandons osascript or the import's result.
|
||||
impl Drop for Headless {
|
||||
fn drop(&mut self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.app.session.wait_for_photos_imports();
|
||||
}
|
||||
}
|
||||
|
||||
impl Headless {
|
||||
/// Wrap `app` (its control channel is replaced by the driver's).
|
||||
/// Invalid dimensions fall back to 1600×1000 at scale 1; external callers can use
|
||||
@@ -1804,6 +1814,226 @@ mod tests {
|
||||
assert!(last["files"][0]["width"].as_u64().is_some_and(|w| w <= 64));
|
||||
}
|
||||
|
||||
/// Issue #236: the Export dialog's Add to Apple Photos is there on macOS only; there the
|
||||
/// background export hands the files it wrote to Photos (a fake runner: tests never start
|
||||
/// osascript or Photos) and the toast says how it went.
|
||||
#[test]
|
||||
fn export_dialog_adds_to_apple_photos_on_macos() {
|
||||
let mut h = demo([1200.0, 1100.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
let out = std::env::temp_dir().join(format!("lc-photos-dialog-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&out);
|
||||
h.app.services.write_shared = Some(Arc::new(|p: &str, bytes: &[u8]| lightcraft_engine::export::write_file(p, bytes)));
|
||||
let calls = Arc::new(std::sync::Mutex::new(Vec::<Vec<String>>::new()));
|
||||
let c = calls.clone();
|
||||
h.app.session.apple_photos = Some(Arc::new(move |args: &[String], _| {
|
||||
c.lock().unwrap().push(args.to_vec());
|
||||
Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "A/L0/001\nB/L0/001\n".into(), stderr: String::new() })
|
||||
}));
|
||||
let ids: Vec<u64> = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect();
|
||||
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": ids}}), t);
|
||||
h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t);
|
||||
h.settle(SETTLE);
|
||||
let shown = h.request("ui.widgets", json!({"filter": "exportAddToPhotos"}), t)["result"].as_array().map_or(0, Vec::len);
|
||||
assert_eq!(shown > 0, cfg!(target_os = "macos"), "the option is offered where Photos is");
|
||||
if !cfg!(target_os = "macos") {
|
||||
return;
|
||||
}
|
||||
h.request("ui.clickWidget", json!({"id": "exportAddToPhotos"}), t);
|
||||
h.settle(SETTLE);
|
||||
if let Some(crate::state::Dialog::Export { opts, full_size, resize, dir, .. }) = &mut h.app.ui.dialog {
|
||||
assert!(opts.add_to_photos, "the checkbox turns it on");
|
||||
opts.photos_album = "Trip “2026”".into();
|
||||
*full_size = false;
|
||||
*resize = lightcraft_engine::export::Resize::long_edge(48);
|
||||
*dir = out.to_string_lossy().into_owned();
|
||||
}
|
||||
let r = h.request("ui.dialog.confirm", json!({}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let t0 = Instant::now();
|
||||
while h.app.export.is_some() && t0.elapsed() < Duration::from_secs(60) {
|
||||
h.step();
|
||||
}
|
||||
let last = h.request("ui.inspect", json!({}), t)["result"]["export"]["last"].clone();
|
||||
let written: Vec<String> = last["files"].as_array().unwrap().iter().map(|f| f["path"].as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(written.len(), 2, "{last}");
|
||||
assert_eq!(last["applePhotos"]["imported"], 2, "{last}");
|
||||
assert_eq!(last["applePhotos"]["album"], "Trip “2026”");
|
||||
let args = calls.lock().unwrap().clone();
|
||||
assert_eq!(args.len(), 1);
|
||||
assert_eq!(args[0][args[0].len() - 2..], written[..], "the files written go to Photos");
|
||||
let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();
|
||||
assert!(toast.contains("2 added to Apple Photos album “Trip “2026””"), "{toast}");
|
||||
// Export with Previous repeats it
|
||||
assert_eq!(h.app.session.last_export.as_ref().unwrap()["addToPhotos"], true);
|
||||
let _ = std::fs::remove_dir_all(&out);
|
||||
}
|
||||
|
||||
/// Issue #236 review: control requests run on the UI thread, which must not wait minutes for
|
||||
/// Photos. `export.addToPhotos` and `app.export {addToPhotos}` without `background` answer at
|
||||
/// once with the running job; the app announces the outcome when Photos is done.
|
||||
#[test]
|
||||
fn a_slow_photos_import_never_blocks_the_window() {
|
||||
let mut h = demo([1000.0, 700.0]);
|
||||
let t = Duration::from_secs(30);
|
||||
let out = std::env::temp_dir().join(format!("lc-photos-slow-ui-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&out);
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
let file = out.join("a.jpg");
|
||||
std::fs::write(&file, b"jpeg").unwrap();
|
||||
h.app.services.write = Some(Box::new(|p: &str, bytes: &[u8]| lightcraft_engine::export::write_file(p, bytes)));
|
||||
let (go, wait) = std::sync::mpsc::channel::<()>();
|
||||
let wait = std::sync::Mutex::new(wait);
|
||||
h.app.session.apple_photos_background = true; // as the desktop app sets it
|
||||
h.app.session.apple_photos = Some(Arc::new(move |args: &[String], _| {
|
||||
// a regression (Photos on the UI thread) fails the timing below instead of hanging
|
||||
let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20));
|
||||
let n = args.len() - args.iter().position(|a| a == lightcraft_engine::apple_photos::MARKER).unwrap() - 2;
|
||||
Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".repeat(n), stderr: String::new() })
|
||||
}));
|
||||
let t0 = Instant::now();
|
||||
let r =
|
||||
h.request("engine.execute", json!({"command": "export.addToPhotos", "params": {"paths": [file.to_string_lossy()], "album": "Slow"}}), t);
|
||||
assert!(t0.elapsed() < Duration::from_secs(5), "answered at once: {r}");
|
||||
assert_eq!(r["result"]["running"], true, "{r}");
|
||||
go.send(()).unwrap();
|
||||
while !h.app.ui.toast.as_ref().is_some_and(|t| t.0.contains("Apple Photos")) {
|
||||
assert!(t0.elapsed() < Duration::from_secs(20), "the outcome is announced");
|
||||
h.step();
|
||||
}
|
||||
assert_eq!(h.app.ui.toast.as_ref().unwrap().0, "1 added to Apple Photos album “Slow”");
|
||||
if cfg!(target_os = "macos") {
|
||||
let t1 = Instant::now();
|
||||
let p = json!({"dir": out.to_string_lossy(), "longEdge": 32, "addToPhotos": true});
|
||||
let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t);
|
||||
assert!(t1.elapsed() < Duration::from_secs(10), "the export answers without waiting for Photos: {r}");
|
||||
assert_eq!(r["result"]["applePhotos"]["running"], true, "{r}");
|
||||
assert_eq!(r["result"]["applePhotos"]["job"], 2, "{r}");
|
||||
go.send(()).unwrap();
|
||||
while !h.app.ui.toast.as_ref().is_some_and(|t| t.0.starts_with("1 added to Apple Photos") && !t.0.contains("Slow")) {
|
||||
assert!(t1.elapsed() < Duration::from_secs(20), "the outcome is announced");
|
||||
h.step();
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&out);
|
||||
}
|
||||
|
||||
/// Review of #236: a headless run (snapshot scripts) ending while an import it started with
|
||||
/// `wait: false` is still running waits for it, on every exit path (dropping the `Headless`).
|
||||
#[test]
|
||||
fn a_headless_run_waits_for_its_photos_import_before_ending() {
|
||||
let mut h = demo([800.0, 600.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
let dir = std::env::temp_dir().join(format!("lc-photos-headless-drop-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("a.jpg");
|
||||
std::fs::write(&file, b"jpeg").unwrap();
|
||||
let marker = dir.join("imported");
|
||||
let m = marker.clone();
|
||||
// Photos "imports" when `go` arrives (at most 20 s): released 0.8 s into the drop below
|
||||
let (go, wait) = std::sync::mpsc::channel::<()>();
|
||||
let wait = std::sync::Mutex::new(wait);
|
||||
h.app.session.apple_photos = Some(Arc::new(move |_: &[String], _| {
|
||||
let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20));
|
||||
std::fs::write(&m, b"done").unwrap();
|
||||
Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".into(), stderr: String::new() })
|
||||
}));
|
||||
let r =
|
||||
h.request("engine.execute", json!({"command": "export.addToPhotos", "params": {"paths": [file.to_string_lossy()], "wait": false}}), t);
|
||||
assert_eq!(r["result"]["running"], true, "{r}");
|
||||
assert!(!marker.exists(), "still importing");
|
||||
let release = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(800));
|
||||
let _ = go.send(());
|
||||
});
|
||||
drop(h);
|
||||
assert!(marker.exists(), "the import finished before the run ended");
|
||||
release.join().unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Review of #236: a snapshot that ends (an error in its script, say) while its background
|
||||
/// export is still writing waits through the writing and then the import the export starts.
|
||||
#[test]
|
||||
fn a_headless_run_waits_for_its_exports_photos_import() {
|
||||
if !cfg!(target_os = "macos") {
|
||||
return; // addToPhotos is refused before exporting elsewhere
|
||||
}
|
||||
let mut h = demo([800.0, 600.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
let dir = std::env::temp_dir().join(format!("lc-photos-headless-export-drop-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let marker = dir.join("imported");
|
||||
h.app.services.write_shared = Some(Arc::new(|p: &str, bytes: &[u8]| {
|
||||
std::thread::sleep(Duration::from_millis(300)); // a slow disk: still writing at the drop
|
||||
lightcraft_engine::export::write_file(p, bytes)
|
||||
}));
|
||||
let m = marker.clone();
|
||||
h.app.session.apple_photos = Some(Arc::new(move |_: &[String], _| {
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
std::fs::write(&m, b"done").unwrap();
|
||||
Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".into(), stderr: String::new() })
|
||||
}));
|
||||
let p = json!({"dir": dir.join("out").to_string_lossy(), "longEdge": 32, "addToPhotos": true, "background": true});
|
||||
let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.app.session.apple_photos_imports.running().is_some(), "reserved while writing");
|
||||
drop(h);
|
||||
assert!(marker.exists(), "the export's import finished before the run ended");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Review of #236: a background export adding to Photos holds the import from before it
|
||||
/// writes. While its files are being written, `export.addToPhotos` is refused with the
|
||||
/// export's job number; the export then adds its files under that job.
|
||||
#[test]
|
||||
fn a_background_export_holds_photos_while_it_writes() {
|
||||
if !cfg!(target_os = "macos") {
|
||||
return; // addToPhotos is refused before exporting elsewhere
|
||||
}
|
||||
let mut h = demo([1000.0, 700.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
let out = std::env::temp_dir().join(format!("lc-photos-hold-ui-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&out);
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
let other = out.join("other.jpg");
|
||||
std::fs::write(&other, b"jpeg").unwrap();
|
||||
// the writer waits for `go`: the export is still writing while we try another import
|
||||
let (go, wait) = std::sync::mpsc::channel::<()>();
|
||||
let wait = std::sync::Mutex::new(wait);
|
||||
h.app.services.write_shared = Some(Arc::new(move |p: &str, bytes: &[u8]| {
|
||||
let _ = wait.lock().unwrap().recv_timeout(Duration::from_secs(20));
|
||||
lightcraft_engine::export::write_file(p, bytes)
|
||||
}));
|
||||
let calls = Arc::new(std::sync::Mutex::new(Vec::<Vec<String>>::new()));
|
||||
let c = calls.clone();
|
||||
h.app.session.apple_photos_background = true;
|
||||
h.app.session.apple_photos = Some(Arc::new(move |args: &[String], _| {
|
||||
c.lock().unwrap().push(args.to_vec());
|
||||
Ok(lightcraft_engine::apple_photos::Output { status: Some(0), stdout: "ID\n".into(), stderr: String::new() })
|
||||
}));
|
||||
let p = json!({"dir": out.join("export").to_string_lossy(), "longEdge": 32, "addToPhotos": true, "photosAlbum": "Held", "background": true});
|
||||
let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.app.export.is_some(), "writing in the background");
|
||||
let r = h.request("engine.execute", json!({"command": "export.addToPhotos", "params": {"paths": [other.to_string_lossy()]}}), t);
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
assert!(r["error"].as_str().unwrap().contains("An export is about to add its files to Apple Photos (import 1)"), "{r}");
|
||||
go.send(()).unwrap();
|
||||
let t0 = Instant::now();
|
||||
while h.app.export.is_some() {
|
||||
assert!(t0.elapsed() < Duration::from_secs(20), "the export finishes");
|
||||
h.step();
|
||||
}
|
||||
let last = h.app.last_export_result.clone().unwrap();
|
||||
assert_eq!((last["applePhotos"]["job"].as_u64(), last["applePhotos"]["imported"].as_u64()), (Some(1), Some(1)), "{last}");
|
||||
let args = calls.lock().unwrap().clone();
|
||||
assert_eq!(args.len(), 1, "only the export's files went to Photos");
|
||||
assert!(args[0].last().unwrap().contains("export"), "{:?}", args[0].last());
|
||||
let _ = std::fs::remove_dir_all(&out);
|
||||
}
|
||||
|
||||
/// Dragging grid photos onto an album row adds them to the album.
|
||||
#[test]
|
||||
fn drag_photos_onto_an_album() {
|
||||
|
||||
@@ -1048,6 +1048,7 @@ impl LightcraftApp {
|
||||
panels::library_problem::show(self, &ctx);
|
||||
export_task::poll(self, &ctx);
|
||||
panels::activity::show(self, &ctx);
|
||||
export_task::poll_photos(self, &ctx);
|
||||
pick::poll(self, &ctx);
|
||||
panels::grid::drag_feedback(self, &ctx);
|
||||
panels::left::album_drag_feedback(self, &ctx);
|
||||
|
||||
@@ -731,6 +731,27 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
&[(K::Unique, "Add number"), (K::Overwrite, "Overwrite"), (K::Skip, "Skip")],
|
||||
&mut opts.conflict,
|
||||
);
|
||||
// After export: Apple Photos imports the files written (macOS only)
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let r = ui.checkbox(&mut opts.add_to_photos, crate::i18n::tr("Add to Apple Photos"));
|
||||
crate::widgets::register(ui.ctx(), "exportAddToPhotos", r.rect);
|
||||
if opts.add_to_photos {
|
||||
field(ui, "Album", |ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut opts.photos_album).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY))
|
||||
});
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::tr("The first time, macOS asks whether LightCraft may control Photos."))
|
||||
.size(11.0)
|
||||
.color(t.text_dim),
|
||||
);
|
||||
}
|
||||
}
|
||||
// nowhere to add them: a setting carried over from a Mac is dropped, not refused
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
opts.add_to_photos = false;
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
field(ui, "Save preset", |ui| {
|
||||
trailing_button_row(ui, |ui| {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# Add to Apple Photos (macOS)
|
||||
|
||||
An export can hand the files it wrote to Apple Photos, optionally into an album (issue #236, parity row
|
||||
`LR-EXP-PHOTOS`). macOS only: on other platforms the option is hidden in the Export dialog and refused by
|
||||
`app.export`. The web build doesn't include it.
|
||||
|
||||
> **Status: not yet tested end to end.** The code and its tests (which use a stand-in for osascript) are in
|
||||
> place, but no import into a real Photos library has been run yet, and the AppleScript has never been run.
|
||||
> Please try the [manual test](#manual-test) below, with the packaged app, and report what happens.
|
||||
|
||||
## Using it
|
||||
|
||||
- **Export dialog:** tick **Add to Apple Photos** under *If file exists* and, if you like, type an **Album**
|
||||
name (empty = no album; see [Which album](#which-album)). The files are exported as usual, into the folder
|
||||
you chose, and then added to Photos. The toast says how many Photos took, or why it took none.
|
||||
- **Export with Previous** and saved export presets repeat it (the setting is part of the export options).
|
||||
- **Agents and scripts:** `app.export {…, addToPhotos: true, photosAlbum?: "Name"}` (desktop app, MCP `export`
|
||||
tool, `lightcraft-cli run`); `lightcraft-cli render in.dng -o out.jpg --opt addToPhotos=true --opt
|
||||
photosAlbum=Name`. The export's result carries `applePhotos` (below). A failed Photos step does not fail
|
||||
the export: the files are written either way.
|
||||
- **Files already on disk:** `export.addToPhotos {paths: [absolute paths], album?, wait?}`.
|
||||
- **How an import went:** `export.photosImports` lists this session's imports, oldest first;
|
||||
`export.photosImports {job}` gives one.
|
||||
|
||||
### Waiting, or not
|
||||
|
||||
An import can take minutes, and the first one waits for the user to answer macOS's permission prompt. Each
|
||||
import is a job: `{job, running, requested, album}`, plus, once Photos is done, `{imported, ids, warning?}`,
|
||||
`{error}` or `{skipped}` (`exporting: true` while an export that will add its files is still writing them).
|
||||
|
||||
- In the **desktop app** nothing waits for Photos on the window's thread. The Export dialog's background export
|
||||
adds the files on its worker and reports them in its toast. `app.export` without `background` and
|
||||
`export.addToPhotos` answer at once with the running job (`running: true`); the app shows a toast when Photos
|
||||
is done, and `export.photosImports {job}` gives the outcome. (`export.addToPhotos {wait: true}` waits there
|
||||
too, holding the window until Photos is done.)
|
||||
- **Without a window** (the MCP server, `lightcraft-cli`, the headless snapshot) everything waits for Photos by
|
||||
default: `app.export` and `export.addToPhotos` return the finished job, so a one-shot command never ends in
|
||||
the middle of an import. `export.addToPhotos {wait: false}` returns the running job instead (for a
|
||||
long-running MCP server that polls `export.photosImports`); a process that is about to end (the CLI after
|
||||
its last command, the MCP server at end of input, a snapshot script, also when they end with an error)
|
||||
first waits for such an import to finish. The wait follows the import: while an export is still writing the
|
||||
files it will add, up to an hour more of writing; once Photos is asked, the import's own time limit (see the
|
||||
table below) and half a minute; never more than three hours and one minute in all.
|
||||
- **One import at a time.** An export that will add to Photos reserves the import before it writes anything
|
||||
and holds it until it hands its files over (or is cancelled, fails or writes nothing). While an import is
|
||||
reserved or running, another (`export.addToPhotos`, or an export asking for Photos, refused before it writes
|
||||
anything) is refused with the number of the one in the way, so a retry never sends the same files twice and
|
||||
an export is never turned away after writing its files. When LightCraft stops waiting (see the table below),
|
||||
Photos may still be importing: look in Photos before trying again.
|
||||
|
||||
### Which album
|
||||
|
||||
The album is looked for by its exact name (case included) among the albums at the **top level** of the
|
||||
Photos library. Albums inside folders are never used: when the only album of that name is in a folder, a new
|
||||
top-level album is made. When none is there, a new top-level album is made. When **more than one** top-level
|
||||
album has the name, nothing is imported and LightCraft says so: rename one of them in Photos, or use another
|
||||
name. (Photos' own list of albums includes those in folders, in no particular order, so the script checks
|
||||
each one's parent folder.)
|
||||
|
||||
Photos either copies the files into its library or references them where they are, as set in Photos ▸
|
||||
Settings ▸ General ▸ *Copy items to the Photos library*. When it references them, keep the exported files.
|
||||
|
||||
## How it works
|
||||
|
||||
LightCraft runs `/usr/bin/osascript` with a constant AppleScript (`SCRIPT` in
|
||||
`crates/engine/src/apple_photos.rs`) that asks Photos, through its scripting interface, to import the files:
|
||||
`import … into album … skip check duplicates false`. The album is found or made first ([Which
|
||||
album](#which-album)). The script returns the new media items' ids.
|
||||
|
||||
This is Apple Events to Photos' own import command. It is not the OS-level synthetic input that AGENTS.md
|
||||
forbids: nothing is typed or clicked, and no other window is touched.
|
||||
|
||||
The album name and the file paths are never written into the script. They follow it as separate arguments
|
||||
(`on run argv`), each passed to osascript unchanged, so quotes, backslashes, line breaks or a leading `-` in a
|
||||
name or path can't change what the script does. A constant first argument keeps osascript from reading a
|
||||
name that starts with `-` as one of its own options.
|
||||
|
||||
The first time, macOS asks whether LightCraft may control Photos. The answer is kept in System Settings ▸
|
||||
Privacy & Security ▸ Automation. macOS asks about the app that started LightCraft's work: the LightCraft app
|
||||
when it was opened from the Finder or with `open`, but the **terminal** for `lightcraft-cli` and for a
|
||||
LightCraft started from a terminal (`cargo run`).
|
||||
|
||||
**Packaged app.** The release app is signed with the hardened runtime. For it to be allowed to ask, it carries
|
||||
the `com.apple.security.automation.apple-events` entitlement (`packaging/macos/entitlements.plist`, also used
|
||||
for `lightcraft-cli`), and its `Info.plist` has the `NSAppleEventsUsageDescription` text macOS shows in the
|
||||
prompt (`packaging/macos/Info.plist.in`). Without them macOS refuses the events (`-1743`) without asking.
|
||||
`cargo run` doesn't test this configuration; the manual test below uses the packaged app.
|
||||
|
||||
**Who can add what.** `export.addToPhotos {paths}` adds any file LightCraft can read, given its absolute path,
|
||||
not only files it exported. So any client of the control port or the MCP server can put any readable image or
|
||||
video on the Mac into the user's Photos library, and from there into iCloud Photos when that is on. macOS asks
|
||||
for the Automation permission once per app, not per import, so after the first Allow nothing asks again. The
|
||||
control port listens on 127.0.0.1 only and has no authentication ([control-protocol.md](control-protocol.md)),
|
||||
and the MCP server answers the program that started it, so this reaches programs already running as the user.
|
||||
Treat giving an agent the MCP server or the control port as giving it this too.
|
||||
|
||||
## When it doesn't work
|
||||
|
||||
| What you see | Why, and what to do |
|
||||
|---|---|
|
||||
| "LightCraft isn't allowed to control Photos…" | Permission refused (`-1743`). Turn on Photos under LightCraft (or your terminal app) in System Settings ▸ Privacy & Security ▸ Automation, then try again. |
|
||||
| "Apple Photos couldn't be found…" | Photos isn't installed or couldn't be opened (`-600`, `-10810`, `-10814`, or no `/System/Applications/Photos.app`). |
|
||||
| "The import was cancelled in Photos." | Someone pressed Cancel in a Photos dialog (`-128`). |
|
||||
| "More than one album at the top level of Photos is named …" | Several top-level albums have that name; nothing was added. Rename one in Photos, or use another name. |
|
||||
| "Photos didn't finish adding the photos within N s…" / "didn't answer in time" | LightCraft waits a minute plus five seconds a file (at most two hours), then stops osascript. Photos may still be importing: look in Photos before trying again. |
|
||||
| "Apple Photos is still adding N file(s) from an earlier request (import K)…" / "An export is about to add its files to Apple Photos (import K)…" | One import at a time: wait for import K (`export.photosImports {job: K}`), then try again. |
|
||||
| "Photos added 2 of 3 files…" | A partial import: Photos skipped files it found to be duplicates (it may ask first) or couldn't read. |
|
||||
| "Apple Photos is only available on macOS" | The setting came from a Mac (a preset or Export with Previous); the Export dialog drops it on other platforms. |
|
||||
|
||||
## Manual test
|
||||
|
||||
Run this once on a Mac whose Photos library you are happy to add a few test images to (or switch Photos to a
|
||||
new empty library first: hold ⌥ while opening Photos ▸ Create New).
|
||||
|
||||
1. Package the app (ad-hoc signed unless `MACOS_SIGN_IDENTITY` is set): `packaging/macos/package.sh --arch
|
||||
aarch64` (or `x86_64` on an Intel Mac). Check its entitlement: `codesign -d --entitlements -
|
||||
target/macos-package/LightCraft.app` lists `com.apple.security.automation.apple-events`. With a Developer ID
|
||||
build, also run the notarized DMG from `dist/release/`.
|
||||
2. Open it from the Finder, or with `open target/macos-package/LightCraft.app --args --demo` (not by running
|
||||
the binary in a terminal, which makes the terminal the app macOS asks about).
|
||||
3. Select three photos, choose File ▸ Export…, set a folder (e.g. `~/Pictures/LightCraft Exports`), tick **Add
|
||||
to Apple Photos**, type the album `LightCraft test "1"` and press OK.
|
||||
4. macOS asks whether **LightCraft** may control Photos, showing the usage text from `Info.plist`: allow it.
|
||||
Expected: the toast reads "Exported 3 of 3 photos · 3 added to Apple Photos album “LightCraft test "1"”",
|
||||
and a top-level album of that name holds the three images.
|
||||
5. Export the same photos again with the same settings. Expected: Photos asks about duplicates or imports them
|
||||
again; the toast reports the count Photos returned (a partial import shows its warning).
|
||||
6. Albums: in Photos make a folder holding an album `Nested`, then export into `Nested`. Expected: a new
|
||||
top-level album `Nested` gets the images, the one in the folder doesn't. Then make a second top-level album
|
||||
`Nested` and export again. Expected: nothing is added, and the toast says more than one album has the name.
|
||||
7. Permission refused: in System Settings ▸ Privacy & Security ▸ Automation turn off Photos under LightCraft,
|
||||
export again. Expected: the files are written, and the toast says to allow LightCraft there. Turn it back
|
||||
on.
|
||||
8. No album: keep Add to Apple Photos ticked, clear the Album field and export. Expected: the images land in the
|
||||
library, in no album.
|
||||
9. Hostile names: album `-e`, then `a\b`, and an export folder whose name contains `"` and `'`; a name with a
|
||||
line break through the CLI: `lightcraft-cli run --demo app.export dir=/tmp/lc-photos addToPhotos=true
|
||||
"photosAlbum=$(printf 'two\nlines')"`. Expected: albums named exactly so; nothing else happens.
|
||||
10. The window stays responsive: start the app with `open target/macos-package/LightCraft.app --args --demo
|
||||
--control 18999`, then send `{"method": "engine.execute", "params": {"command": "export.addToPhotos",
|
||||
"params": {"paths": ["/tmp/lc-photos/<file>.jpg"]}}}` to port 18999 (`docs/control-protocol.md`).
|
||||
Expected: an immediate `{"job": …, "running": true}`; the app keeps responding; a toast when Photos is done;
|
||||
`export.photosImports` then shows the outcome.
|
||||
11. Agents: `lightcraft-cli run --demo app.export dir=/tmp/lc-photos addToPhotos=true photosAlbum=CLI` (allow
|
||||
the terminal under Automation when asked). Expected: `applePhotos.imported` in the printed result.
|
||||
12. Files on disk: `lightcraft-cli run export.addToPhotos 'paths=["/tmp/lc-photos/<file>.jpg"]' album=CLI`.
|
||||
Expected: the command waits for Photos and prints `{"job": …, "running": false, "imported": 1, …}`.
|
||||
|
||||
Report the toast or JSON, and the macOS and Photos versions.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Albums inside folders are never used ([Which album](#which-album)).
|
||||
- Photos' duplicate check stays on, so Photos may ask about duplicates; what it skips shows as a partial import.
|
||||
- Cancelling a running export cancels the Photos step too; a Photos import already started can't be stopped
|
||||
from LightCraft.
|
||||
- One import runs (or is reserved by an export) at a time; a session remembers its last 32.
|
||||
- Quitting the desktop app while Photos is importing doesn't wait for it: Photos may still finish the import,
|
||||
but LightCraft won't show the outcome.
|
||||
+18
@@ -255,6 +255,7 @@ lightcraft-cli run --demo --script steps.jsonl --keep-going
|
||||
lightcraft-cli render in.dng -o out.tif --opt colorSpace=displayP3 --opt bitDepth=16 --opt percent=50
|
||||
lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.5 --set light.contrast=20 --size 2048
|
||||
lightcraft-cli render in.jpg -o out.png --settings look.json --preset <presetId>
|
||||
lightcraft-cli render in.dng -o out.jpg --opt addToPhotos=true --opt photosAlbum=Trip # macOS: then into Apple Photos
|
||||
lightcraft-cli commands [--json] # the command registry
|
||||
lightcraft-cli controls [--json] # develop control ids and ranges
|
||||
lightcraft-cli calibrate --max 300 ~/Pictures/2026 # camera colour profiles (docs/camera-preview-colour.md)
|
||||
@@ -268,6 +269,23 @@ lightcraft-cli calibrate --max 300 ~/Pictures/2026 # camera colour profiles (d
|
||||
- `apps/lightcraft-cli/tests/cli.rs` — spawns `lightcraft-cli mcp` with real pipes; `render`;
|
||||
`commands`.
|
||||
|
||||
## Add to Apple Photos (macOS)
|
||||
|
||||
`app.export` (MCP `export` tool, `lightcraft-cli run`, the desktop app) takes `addToPhotos: true` and
|
||||
`photosAlbum` to hand the files it wrote to Apple Photos, into that album (a top-level album of exactly that
|
||||
name, made when missing). The result's `applePhotos` is the import job: `{job, running, requested, album}`
|
||||
plus `{imported, ids, warning?}`, `{error}` or `{skipped}` once Photos is done; the files are written either
|
||||
way. Headless (this server, `lightcraft-cli`) waits for Photos by default; the desktop app (connect mode)
|
||||
answers with the running job and `export.photosImports {job}` gives the outcome. `export.addToPhotos {paths,
|
||||
album?, wait?}` does the same for files already on disk (`wait` defaults to true headless, false in the desktop
|
||||
app; with `wait: false` headless, the server waits for the import before it exits at end of input). One import
|
||||
runs at a time, and an export adding to Photos reserves it before it writes: another is refused, naming the
|
||||
job in the way. On other platforms `addToPhotos: true` is refused before anything is written, and
|
||||
`export.addToPhotos` is disabled with the reason. The first time, macOS asks the user to allow LightCraft (or
|
||||
the terminal running `lightcraft-cli`) to control Photos. After that, any client can add any file LightCraft can
|
||||
read to the user's Photos library (and so to iCloud Photos when that is on), not only exported ones: see [Who
|
||||
can add what](apple-photos.md#how-it-works).
|
||||
|
||||
## Export progress and cancellation
|
||||
|
||||
Headless direct `export`, `command_run` / `run_command` with `app.export`, and `cmd_app_export`
|
||||
|
||||
@@ -44,7 +44,7 @@ cited here still exists. Formerly docs/parity.md.
|
||||
| P. Enhance (ENH) | 0 | 0 | 2 | 0 | — | — |
|
||||
| Q. HDR (HDR) | 2 | 3 | 0 | 0 | — | — |
|
||||
| R. Video (VID) | 0 | 0 | 5 | 1 | — | 0/2 (0%) |
|
||||
| S. Export (EXP) | 15 | 2 | 1 | 0 | 7/7 (100%) | 7/7 (100%) |
|
||||
| S. Export (EXP) | 15 | 3 | 0 | 0 | 7/7 (100%) | 7/7 (100%) |
|
||||
| T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — |
|
||||
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
|
||||
| V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) |
|
||||
@@ -397,7 +397,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet |
|
||||
| LR-EXP-DNGOPT | DNG options | P2 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP or none; no embedded JPEG preview size, no lossy DNG output |
|
||||
| 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-PHOTOS | Export to the system photo library | P2 | ⬜ | | |
|
||||
| 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. **Not yet tested end to end** against a real Photos library (manual steps, with the packaged app, 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.editInExternal`, `crates/engine/src/cmd/convert.rs` | a 16-bit TIFF `-Edit` copy with the edits (Adobe RGB / ProPhoto / P3 / sRGB) next to the original, added stacked on top of it and opened in the editor set in Settings ▸ General (or the system default) |
|
||||
|
||||
## T. Share (SHARE)
|
||||
|
||||
@@ -42,6 +42,10 @@
|
||||
<true/>
|
||||
<key>NSRequiresAquaSystemAppearance</key>
|
||||
<false/>
|
||||
<!-- Shown by macOS when LightCraft first asks to control Photos (Export > Add to Apple Photos;
|
||||
needs the apple-events entitlement in entitlements.plist). -->
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>LightCraft asks Photos to import the photos you export when you choose Add to Apple Photos in the Export dialog.</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © the LightCraft authors. MIT OR Apache-2.0.</string>
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<!-- Hardened-runtime entitlements for Lightcraft. Deliberately empty: the app is pure Rust,
|
||||
<!-- Hardened-runtime entitlements for Lightcraft. Deliberately minimal: the app is pure Rust,
|
||||
loads no unsigned code, needs no JIT, and is not sandboxed (it opens files by path, from the
|
||||
command line and drag-and-drop). Add exceptions here only with a reason in a comment. -->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Export > Add to Apple Photos (crates/engine/src/apple_photos.rs) runs /usr/bin/osascript to
|
||||
send Apple Events to Photos' import command. Under the hardened runtime macOS refuses those
|
||||
events (error -1743, without asking the user) unless the app has this entitlement; the
|
||||
prompt shows NSAppleEventsUsageDescription from Info.plist. -->
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -158,7 +158,10 @@ CLI_DIR="$WORK/lightcraft-cli-$VERSION-macos-$ARCH"
|
||||
mkdir -p "$CLI_DIR"
|
||||
cp "$WORK/bin/lightcraft-cli" "$CLI_DIR/"
|
||||
copy_docs "$CLI_DIR"
|
||||
sign --options runtime "$CLI_DIR/lightcraft-cli"
|
||||
# Same entitlements as the app: `--opt addToPhotos=true` / `app.export {addToPhotos}` send Apple
|
||||
# Events to Photos through osascript too (macOS asks about the app that started the CLI, usually
|
||||
# the terminal).
|
||||
sign --options runtime --entitlements "$HERE/entitlements.plist" "$CLI_DIR/lightcraft-cli"
|
||||
codesign --verify --strict --verbose=2 "$CLI_DIR/lightcraft-cli"
|
||||
rm -f "$CLI_ZIP"
|
||||
ditto -c -k --keepParent "$CLI_DIR" "$CLI_ZIP"
|
||||
|
||||
Reference in new issue
Block a user