mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
* app.open: open directory .pcraft through the automation read root (#2490) The native on-disk format is a directory, but the automation read path serves regular files only, so agents could not open the files the app itself writes. The format crate's storage seam is now public (ByteSource + load_bytes_source); the workspace opens the .pcraft subdirectory through the held capability and loads through it. app.open tries the directory form for .pcraft paths and falls back to the bytes path for the single-file (zip) form; every other format is unchanged. * app.open .pcraft: review fixes (#2581) read_entry now bounds the read itself (the stat length is only a buffer hint) and both directory sources reject non-regular entries, so a crafted bundle with a FIFO entry can no longer block the open and stream unbounded. The desktop opener runs under the crash guard like its import sibling, the zip fallback is structural (Ok(None) instead of an error-message match), a directory-form document no longer arms a save-back target the file-only automation writer could never replace, and the open status is translated (Opened {name}, 16 locales). * read_entry: saturating max+1 so a usize::MAX budget can't overflow Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
10fb9233d5
commit
60339cf675
@@ -326,6 +326,13 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
let automation_write = automation.clone().map(|workspace| {
|
||||
Box::new(move |path: &str, bytes: &[u8]| workspace.write(path, bytes).map_err(|error| error.to_string())) as photocraft_ui_egui::AutomationWriteFn
|
||||
});
|
||||
let automation_open_pcraft = automation.clone().map(|workspace| {
|
||||
Box::new(move |path: &str| {
|
||||
let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path).to_string();
|
||||
let opened = crate::crash_guard::guard("Open", || workspace.open_pcraft(path).map_err(|e| e.to_string()))?;
|
||||
Ok(opened.map(|doc| (name, doc)))
|
||||
}) as photocraft_ui_egui::AutomationOpenPcraftFn
|
||||
});
|
||||
let automation_save = automation.clone().map(|workspace| {
|
||||
std::sync::Arc::new(move |doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings, ctl: &photocraft_ui_egui::jobs_ui::SaveCtl| {
|
||||
save_through(doc, path, settings, ctl, |p, b| workspace.write(p, b).map_err(|e| e.to_string()))
|
||||
@@ -353,6 +360,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
write: Some(Box::new(|path: &str, bytes: &[u8]| write_atomic(Path::new(path), bytes))),
|
||||
automation_read,
|
||||
automation_write,
|
||||
automation_open_pcraft,
|
||||
automation_save,
|
||||
automation_command,
|
||||
automation_authorize,
|
||||
|
||||
@@ -55,6 +55,21 @@ impl AuthorizedWorkspace {
|
||||
photocraft_format::read::read_all(&mut file, metadata.len()).map_err(|e| file_error("read", path, e))
|
||||
}
|
||||
|
||||
/// Load one `.pcraft` document in directory form — the native on-disk layout — below the
|
||||
/// read root. `read` serves regular files only (the zip form), so a regular file at the
|
||||
/// path is reported as `Ok(None)` for the caller to fall back to.
|
||||
pub fn open_pcraft(&self, path: &str) -> Result<Option<photocraft_doc::Document>, AutomationError> {
|
||||
let relative = request_path(path, self.read.as_ref())?;
|
||||
let root = self.read.as_ref().ok_or_else(|| AutomationError::BadRequest(format!("{DENIED}: read authority is absent")))?;
|
||||
let dir = match root.dir.open_dir(&relative) {
|
||||
Ok(dir) => dir,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotADirectory => return Ok(None),
|
||||
Err(e) => return Err(file_error("read", path, e)),
|
||||
};
|
||||
let source = DirHandle { dir };
|
||||
Ok(Some(photocraft_format::load_bytes_source(&source, &photocraft_format::LoadOptions::default())?))
|
||||
}
|
||||
|
||||
/// Create or replace one file below the configured write root, crash-safely: the bytes go to
|
||||
/// a temporary file beside the target, which is synced and renamed over it (the same steps
|
||||
/// as [`photocraft_format::atomic_write`], through the directory capability). On failure
|
||||
@@ -173,6 +188,24 @@ fn path_error(path: &str, reason: &str) -> AutomationError {
|
||||
AutomationError::BadRequest(format!("automation path rejected: {reason}: `{path}`"))
|
||||
}
|
||||
|
||||
/// A `.pcraft` directory behind the read-root capability: bounded reads, no ambient paths.
|
||||
struct DirHandle {
|
||||
dir: Dir,
|
||||
}
|
||||
|
||||
impl photocraft_format::ByteSource for DirHandle {
|
||||
fn get(&self, path: &str, max: usize) -> photocraft_format::Result<Vec<u8>> {
|
||||
let missing = || photocraft_format::FormatError::Corrupt(format!("missing `{path}`"));
|
||||
let mut file = self.dir.open(path).map_err(|_| missing())?;
|
||||
let metadata = file.metadata().map_err(|_| missing())?;
|
||||
if !metadata.is_file() {
|
||||
// A FIFO or device would block the open and stream without end.
|
||||
return Err(photocraft_format::FormatError::Corrupt(format!("`{path}` is not a regular file")));
|
||||
}
|
||||
photocraft_format::read_entry(&mut file, metadata.len(), max, path)
|
||||
}
|
||||
}
|
||||
|
||||
fn file_error(operation: &str, path: &str, error: std::io::Error) -> AutomationError {
|
||||
let kind = error.kind();
|
||||
AutomationError::Io(format!("automation {operation} `{path}` failed ({kind:?}): {error}"))
|
||||
@@ -431,6 +464,45 @@ mod tests {
|
||||
assert_eq!(std::fs::read(inside.join("new-output.txt")).unwrap(), b"created");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_pcraft_loads_a_directory_below_the_read_root() {
|
||||
let (inside, _, workspace) = roots("pcraft-dir");
|
||||
let doc = photocraft_doc::Document::new("Native", photocraft_doc::Size::new(16, 12), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8);
|
||||
photocraft_format::PcraftWriter::new().save_dir(&doc, &inside.join("native.pcraft"), &photocraft_format::SaveOptions::default()).unwrap();
|
||||
let loaded = workspace.open_pcraft("native.pcraft").unwrap().expect("the directory form loads");
|
||||
assert_eq!(loaded.name, doc.name);
|
||||
assert_eq!((loaded.size.width, loaded.size.height), (16, 12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_pcraft_reports_zip_files_and_missing_paths_clearly() {
|
||||
let (inside, _, workspace) = roots("pcraft-errors");
|
||||
let doc = photocraft_doc::Document::new("Zipped", photocraft_doc::Size::new(4, 4), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8);
|
||||
let zip = inside.join("zipped.pcraft");
|
||||
photocraft_format::PcraftWriter::new().save_path(&doc, &zip, &photocraft_format::SaveOptions::default()).unwrap();
|
||||
assert!(zip.is_file());
|
||||
assert!(workspace.open_pcraft("zipped.pcraft").unwrap().is_none(), "the zip form falls back to the bytes path");
|
||||
let error = workspace.open_pcraft("missing.pcraft").unwrap_err().to_string();
|
||||
assert!(error.contains("failed"), "a missing path propagates its io error: {error}");
|
||||
assert!(AuthorizedWorkspace::default().open_pcraft("x.pcraft").is_err(), "no read authority fails closed");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn open_pcraft_rejects_non_regular_entries_and_escaping_symlinks() {
|
||||
let (inside, _, workspace) = roots("pcraft-nonregular");
|
||||
let bundle = inside.join("native.pcraft");
|
||||
std::fs::create_dir_all(bundle.join("manifest.json")).unwrap();
|
||||
let error = workspace.open_pcraft("native.pcraft").unwrap_err().to_string();
|
||||
assert!(error.contains("not a regular file"), "a directory entry is refused, not streamed: {error}");
|
||||
|
||||
let (inside2, _, workspace2) = roots("pcraft-symlink");
|
||||
let bundle2 = inside2.join("native.pcraft");
|
||||
std::fs::create_dir(&bundle2).unwrap();
|
||||
std::os::unix::fs::symlink("/dev/zero", bundle2.join("manifest.json")).unwrap();
|
||||
assert!(workspace2.open_pcraft("native.pcraft").is_err(), "an escaping symlink entry is refused");
|
||||
}
|
||||
|
||||
fn temp_files(dir: &Path) -> Vec<String> {
|
||||
std::fs::read_dir(dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).filter(|n| n.ends_with(".tmp")).collect()
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ pub use autosave::{Autosaver, RecoveryEntry, discard_recovery, list_recovery, re
|
||||
pub use convert::MAX_GROUP_DEPTH;
|
||||
pub use manifest::{FORMAT_VERSION, Manifest};
|
||||
pub use read::read_file;
|
||||
pub use store::{DEFAULT_REVERIFY_BUDGET, PcraftWriter, SaveStats};
|
||||
pub use store::{ByteSource, DEFAULT_REVERIFY_BUDGET, PcraftWriter, SaveStats, read_entry};
|
||||
|
||||
/// File extension of the native format.
|
||||
pub const EXTENSION: &str = "pcraft";
|
||||
@@ -140,6 +140,12 @@ pub fn load_path(path: &Path) -> Result<Document> {
|
||||
load_path_with(path, &LoadOptions::default())
|
||||
}
|
||||
|
||||
/// Load from any byte source — a directory behind a capability handle, an archive, a test
|
||||
/// fixture — the same migration and limits as the path loads.
|
||||
pub fn load_bytes_source(src: &dyn ByteSource, opts: &LoadOptions) -> Result<Document> {
|
||||
store::load(src, opts)
|
||||
}
|
||||
|
||||
pub fn load_path_with(path: &Path, opts: &LoadOptions) -> Result<Document> {
|
||||
if path.is_dir() {
|
||||
store::load(&store::DirSource { root: path.to_path_buf() }, opts)
|
||||
@@ -160,7 +166,7 @@ pub fn read_thumbnail(bytes: &[u8]) -> Result<Option<Vec<u8>>> {
|
||||
let src = store::ZipSource::new(bytes)?;
|
||||
let m = store::read_manifest(&src, &LoadOptions::default())?;
|
||||
match m.thumbnail {
|
||||
Some(p) => Ok(Some(store::Source::get(&src, &p, 64 << 20)?)),
|
||||
Some(p) => Ok(Some(src.get(&p, 64 << 20)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
+24
-10
@@ -54,7 +54,7 @@ fn png(img: &Rgba8Image) -> Result<Vec<u8>> {
|
||||
// Sources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) trait Source {
|
||||
pub trait ByteSource {
|
||||
fn get(&self, path: &str, max: usize) -> Result<Vec<u8>>;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ impl<'a> ZipSource<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
impl Source for ZipSource<'_> {
|
||||
impl ByteSource for ZipSource<'_> {
|
||||
fn get(&self, path: &str, max: usize) -> Result<Vec<u8>> {
|
||||
self.zip.read_by_name(path, max)
|
||||
}
|
||||
@@ -78,17 +78,31 @@ pub(crate) struct DirSource {
|
||||
pub root: PathBuf,
|
||||
}
|
||||
|
||||
impl Source for DirSource {
|
||||
impl ByteSource for DirSource {
|
||||
fn get(&self, path: &str, max: usize) -> Result<Vec<u8>> {
|
||||
let p = self.root.join(path);
|
||||
let len = std::fs::metadata(&p).map_err(|_| FormatError::corrupt(format!("missing `{path}`")))?.len();
|
||||
if len > max as u64 {
|
||||
return Err(FormatError::LimitExceeded(format!("`{path}` is {len} bytes (max {max})")));
|
||||
let metadata = std::fs::metadata(&p).map_err(|_| FormatError::corrupt(format!("missing `{path}`")))?;
|
||||
if !metadata.is_file() {
|
||||
return Err(FormatError::corrupt(format!("`{path}` is not a regular file")));
|
||||
}
|
||||
Ok(std::fs::read(p)?)
|
||||
let mut file = std::fs::File::open(&p).map_err(|_| FormatError::corrupt(format!("missing `{path}`")))?;
|
||||
read_entry(&mut file, metadata.len(), max, path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one opened bundle entry under its `max` budget. The stat `len` is a buffer hint only
|
||||
/// (files can change while open, and a FIFO reports 0), so the bound is enforced on the read
|
||||
/// itself, never on the hint.
|
||||
pub fn read_entry(file: &mut impl std::io::Read, len: u64, max: usize, path: &str) -> Result<Vec<u8>> {
|
||||
let mut limited = file.take((max as u64).saturating_add(1));
|
||||
let hint = len.min((max as u64).saturating_add(1));
|
||||
let bytes = crate::read::read_all(&mut limited, hint)?;
|
||||
if bytes.len() > max {
|
||||
return Err(FormatError::LimitExceeded(format!("`{path}` exceeds {max} bytes")));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loading
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -127,7 +141,7 @@ fn check_manifest_depth(json: &[u8]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn read_manifest(src: &dyn Source, opts: &LoadOptions) -> Result<Manifest> {
|
||||
pub(crate) fn read_manifest(src: &dyn ByteSource, opts: &LoadOptions) -> Result<Manifest> {
|
||||
let raw = src.get(MANIFEST, opts.max_manifest_bytes)?;
|
||||
// serde_json stops at 128 levels (about 40 nested groups); the bounded check replaces that limit.
|
||||
check_manifest_depth(&raw)?;
|
||||
@@ -172,7 +186,7 @@ fn take_array(v: &mut serde_json::Value, pointer: &str) -> Vec<serde_json::Value
|
||||
}
|
||||
|
||||
struct LoadFetch<'a> {
|
||||
src: &'a dyn Source,
|
||||
src: &'a dyn ByteSource,
|
||||
opts: LoadOptions,
|
||||
total: u64,
|
||||
blobs: HashMap<String, Arc<Vec<u8>>>,
|
||||
@@ -230,7 +244,7 @@ impl Fetch for LoadFetch<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn load(src: &dyn Source, opts: &LoadOptions) -> Result<Document> {
|
||||
pub(crate) fn load(src: &dyn ByteSource, opts: &LoadOptions) -> Result<Document> {
|
||||
let m = read_manifest(src, opts)?;
|
||||
let mut fetch = LoadFetch { src, opts: *opts, total: 0, blobs: HashMap::new() };
|
||||
let mut loader = Loader {
|
||||
|
||||
@@ -90,6 +90,54 @@ fn save_path_zip_file() {
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// The public [`ByteSource`] entry loads a saved directory like `load_path` does.
|
||||
#[test]
|
||||
fn load_bytes_source_matches_the_path_load() {
|
||||
let doc = rich_doc(ColorMode::Rgb, SampleType::U8);
|
||||
let dir = temp_dir("bytes-source");
|
||||
let bundle = dir.join("a.pcraft");
|
||||
PcraftWriter::new().save_dir(&doc, &bundle, &SaveOptions::default()).unwrap();
|
||||
let loaded = load_bytes_source(&FsDir { root: bundle.clone() }, &LoadOptions::default()).unwrap();
|
||||
assert_eq!(loaded, doc);
|
||||
assert!(load_bytes_source(&FsDir { root: bundle.join("no-such-dir") }, &LoadOptions::default()).is_err(), "a missing bundle errors");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// `read_entry` bounds the read itself, not the stat hint: a stream that ignores the hint
|
||||
/// and yields more than `max` errors instead of filling memory.
|
||||
#[test]
|
||||
fn read_entry_bounds_the_read_not_the_hint() {
|
||||
struct Endless;
|
||||
impl std::io::Read for Endless {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
buf.fill(b'x');
|
||||
Ok(buf.len())
|
||||
}
|
||||
}
|
||||
let mut endless = Endless;
|
||||
let error = read_entry(&mut endless, 0, 100, "manifest.json").unwrap_err().to_string();
|
||||
assert!(error.contains("exceeds"), "{error}");
|
||||
let mut small = &b"hi"[..];
|
||||
assert_eq!(read_entry(&mut small, 2, 100, "manifest.json").unwrap(), b"hi");
|
||||
}
|
||||
|
||||
/// Reads the on-disk bundle shape for [`load_bytes_source_matches_the_path_load`].
|
||||
struct FsDir {
|
||||
root: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl ByteSource for FsDir {
|
||||
fn get(&self, path: &str, max: usize) -> Result<Vec<u8>> {
|
||||
let p = self.root.join(path);
|
||||
let metadata = std::fs::metadata(&p).map_err(|_| FormatError::Corrupt(format!("missing `{path}`")))?;
|
||||
if !metadata.is_file() {
|
||||
return Err(FormatError::Corrupt(format!("`{path}` is not a regular file")));
|
||||
}
|
||||
let mut file = std::fs::File::open(&p).map_err(|_| FormatError::Corrupt(format!("missing `{path}`")))?;
|
||||
read_entry(&mut file, metadata.len(), max, path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Deepest group nesting in `layers` (0 when there are no groups).
|
||||
fn group_depth(layers: &[Layer]) -> usize {
|
||||
layers.iter().map(|l| if let photocraft_doc::LayerContent::Group(g) = &l.content { 1 + group_depth(&g.children) } else { 0 }).max().unwrap_or(0)
|
||||
|
||||
@@ -1091,6 +1091,27 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
|
||||
}
|
||||
"app.open" => match s("path") {
|
||||
Some(path) => {
|
||||
// Directory .pcraft (the native on-disk form) loads through a capability-scoped
|
||||
// source; the single-file (zip) form falls back to the bytes path, as do all
|
||||
// other formats.
|
||||
if path.rsplit_once('.').is_some_and(|(_, ext)| ext.eq_ignore_ascii_case(photocraft_format::EXTENSION))
|
||||
&& let Some(open) = app.services.automation_open_pcraft.as_mut()
|
||||
{
|
||||
match open(path) {
|
||||
Ok(Some((name, doc))) => {
|
||||
app.session.add_document(doc, None);
|
||||
// Recent files, but no save-back target: the capability writer
|
||||
// replaces files, never directories.
|
||||
app.push_recent(path);
|
||||
app.sync_views();
|
||||
app.ui.status = crate::i18n::fmt(tl!("Opened {name}"), &[("name", &name)]);
|
||||
app.ui.status_error = false;
|
||||
return ok(json!({"path": path, "name": name, "warnings": []}));
|
||||
}
|
||||
Ok(None) => {} // a regular file: the bytes path reads the zip form
|
||||
Err(e) => return err(e),
|
||||
}
|
||||
}
|
||||
let opened = match app.services.automation_read.as_mut() {
|
||||
Some(read) => read(path),
|
||||
None => Err("automation read authority is not configured".into()),
|
||||
@@ -2024,6 +2045,55 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_open_opens_a_directory_pcraft_and_falls_back_for_the_zip_form() {
|
||||
use photocraft_doc::Document;
|
||||
use serde_json::json;
|
||||
let services = crate::Services {
|
||||
automation_open_pcraft: Some(Box::new(|path: &str| {
|
||||
if path == "native.pcraft" {
|
||||
let name = path.rsplit('/').next().unwrap_or(path).to_string();
|
||||
let doc = Document::new("Native", photocraft_doc::Size::new(16, 12), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8);
|
||||
Ok(Some((name, doc)))
|
||||
} else if path == "zipped.pcraft" {
|
||||
Ok(None) // a regular file: the bytes path reads the zip form
|
||||
} else {
|
||||
Err(format!("corrupt bundle: {path}"))
|
||||
}
|
||||
})),
|
||||
import: Some(Box::new(|name: &str, _b: &[u8], _depth: usize| {
|
||||
Ok((Document::new(name, photocraft_doc::Size::new(4, 4), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8), Vec::new()))
|
||||
})),
|
||||
automation_read: Some(Box::new(|path: &str| {
|
||||
if path == "zipped.pcraft" {
|
||||
Ok((path.rsplit('/').next().unwrap_or(path).to_string(), b"zip-bytes".to_vec()))
|
||||
} else {
|
||||
Err(format!("no such path: {path}"))
|
||||
}
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
let r = call(&mut app, &ctx, "app.open", json!({"path": "native.pcraft"}));
|
||||
assert_eq!(r["result"], json!({"path": "native.pcraft", "name": "native.pcraft", "warnings": []}), "{r}");
|
||||
assert_eq!(app.session.active().unwrap().doc.name, "Native");
|
||||
assert_eq!(app.ui.recent_files, vec!["native.pcraft".to_string()], "the open is recorded in recent files");
|
||||
assert_eq!(app.session.active().unwrap().path, None, "no save-back target: the automation writer replaces files, never directories");
|
||||
let r = call(&mut app, &ctx, "app.save", json!({}));
|
||||
assert!(r["error"].as_str().unwrap().contains("pass `path`"), "an in-place save is refused up front: {r}");
|
||||
|
||||
// The single-file form takes the bytes path through the same `.pcraft` extension.
|
||||
let r = call(&mut app, &ctx, "app.open", json!({"path": "zipped.pcraft"}));
|
||||
assert_eq!(r["result"]["name"], "zipped.pcraft", "{r}");
|
||||
assert_eq!(app.session.active().unwrap().doc.name, "zipped.pcraft", "the import served the zip form");
|
||||
|
||||
// A real opener error propagates instead of being masked by the fallback.
|
||||
let r = call(&mut app, &ctx, "app.open", json!({"path": "gone.pcraft"}));
|
||||
assert!(r["error"].as_str().unwrap().contains("corrupt bundle"), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_save_runs_as_a_background_job_and_the_final_reply_carries_the_path() {
|
||||
use photocraft_engine::automate_cmds::ScriptBinding;
|
||||
|
||||
@@ -2150,6 +2150,7 @@
|
||||
Developing {name} Vyvolávání {name}
|
||||
Cancelled {label} Zrušeno: {label}
|
||||
Esc to cancel Esc pro zrušení
|
||||
Opened {name} Otevřeno: {name}
|
||||
Opening {name} Otevírání {name}
|
||||
Saved {name} Uloženo: {name}
|
||||
Saving {name} Ukládání {name}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2117,6 +2117,7 @@
|
||||
Developing {name} {name} wird entwickelt
|
||||
Cancelled {label} {label} abgebrochen
|
||||
Esc to cancel Esc zum Abbrechen
|
||||
Opened {name} Geöffnet: {name}
|
||||
Opening {name} Öffne {name}
|
||||
Saved {name} Gespeichert: {name}
|
||||
Saving {name} Speichere {name}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1568,6 +1568,7 @@
|
||||
Cancelled opening {name} Ακυρώθηκε το άνοιγμα του {name}
|
||||
Cancelled {label} Ακυρώθηκε: {label}
|
||||
Esc to cancel Esc για ακύρωση
|
||||
Opened {name} Άνοιξε το {name}
|
||||
Opening {name} Άνοιγμα του {name}
|
||||
Saved {name} Αποθηκεύτηκε το {name}
|
||||
Saving {name} Αποθήκευση του {name}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 991 and column 18.
|
@@ -2133,6 +2133,7 @@
|
||||
Developing {name} Revelando {name}
|
||||
Cancelled {label} Se canceló {label}
|
||||
Esc to cancel Esc para cancelar
|
||||
Opened {name} Se abrió {name}
|
||||
Opening {name} Abriendo {name}
|
||||
Saved {name} Guardado: {name}
|
||||
Saving {name} Guardando {name}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2095,6 +2095,7 @@
|
||||
Developing {name} Développement de {name}
|
||||
Cancelled {label} {label} : annulé
|
||||
Esc to cancel Échap pour annuler
|
||||
Opened {name} Ouvert : {name}
|
||||
Opening {name} Ouverture de {name}
|
||||
Saved {name} Enregistré : {name}
|
||||
Saving {name} Enregistrement de {name}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2088,6 +2088,7 @@
|
||||
Developing {name} Mengembangkan {name}
|
||||
Cancelled {label} {label} dibatalkan
|
||||
Esc to cancel Esc untuk batal
|
||||
Opened {name} Dibuka: {name}
|
||||
Opening {name} Membuka {name}
|
||||
Saved {name} Tersimpan: {name}
|
||||
Saving {name} Menyimpan {name}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2132,6 +2132,7 @@
|
||||
Developing {name} Sviluppo di {name}
|
||||
Cancelled {label} {label} annullato
|
||||
Esc to cancel Esc per annullare
|
||||
Opened {name} Aperto: {name}
|
||||
Opening {name} Apertura {name}
|
||||
Saved {name} Salvato: {name}
|
||||
Saving {name} Salvataggio {name}
|
||||
|
||||
|
@@ -2130,6 +2130,7 @@
|
||||
Developing {name} {name} を現像中
|
||||
Cancelled {label} {label} をキャンセルしました
|
||||
Esc to cancel Esc キーでキャンセル
|
||||
Opened {name} {name} を開きました
|
||||
Opening {name} {name} を開いています
|
||||
Saved {name} {name} を保存しました
|
||||
Saving {name} {name} を保存しています
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2088,6 +2088,7 @@
|
||||
Developing {name} {name} 현상 중
|
||||
Cancelled {label} {label} 작업을 취소했습니다
|
||||
Esc to cancel Esc 키로 취소
|
||||
Opened {name} {name} 열림
|
||||
Opening {name} {name} 여는 중
|
||||
Saved {name} {name} 저장됨
|
||||
Saving {name} {name} 저장 중
|
||||
|
||||
|
@@ -2100,6 +2100,7 @@
|
||||
Developing {name} {name} wordt ontwikkeld
|
||||
Cancelled {label} {label} geannuleerd
|
||||
Esc to cancel Esc om te annuleren
|
||||
Opened {name} {name} geopend
|
||||
Opening {name} {name} openen
|
||||
Saved {name} {name} opgeslagen
|
||||
Saving {name} {name} opslaan
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -2120,6 +2120,7 @@
|
||||
Cancelled opening {name} Anulowano otwieranie {name}
|
||||
Cancelled {label} Anulowano: {label}
|
||||
Esc to cancel Esc, aby anulować
|
||||
Opened {name} Otworzono: {name}
|
||||
Opening {name} Otwieranie {name}
|
||||
Saved {name} Zapisano: {name}
|
||||
Saving {name} Zapisywanie {name}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2094,6 +2094,7 @@
|
||||
Developing {name} Revelando {name}
|
||||
Cancelled {label} {label} cancelado
|
||||
Esc to cancel Esc para cancelar
|
||||
Opened {name} Aberto: {name}
|
||||
Opening {name} Abrindo {name}
|
||||
Saved {name} Salvo: {name}
|
||||
Saving {name} Salvando {name}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2412,6 +2412,7 @@
|
||||
Developing {name} Обработка {name}
|
||||
Cancelled {label} {label}: отменено
|
||||
Esc to cancel Esc — отмена
|
||||
Opened {name} Открыто: {name}
|
||||
Opening {name} Открытие {name}
|
||||
Saved {name} Сохранено: {name}
|
||||
Saving {name} Сохранение {name}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2386,6 +2386,7 @@
|
||||
Developing {name} Обробка {name}
|
||||
Cancelled {label} Скасовано: {label}
|
||||
Esc to cancel Esc для скасування
|
||||
Opened {name} Відкрито: {name}
|
||||
Opening {name} Відкриття {name}
|
||||
Saved {name} Збережено: {name}
|
||||
Saving {name} Збереження {name}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1348 and column 18.
|
@@ -2191,6 +2191,7 @@ cameraRaw Blending 混合
|
||||
Developing {name} 正在处理 {name}
|
||||
Cancelled {label} 已取消 {label}
|
||||
Esc to cancel 按 Esc 取消
|
||||
Opened {name} 已打开 {name}
|
||||
Opening {name} 正在打开 {name}
|
||||
Saved {name} 已保存 {name}
|
||||
Saving {name} 正在保存 {name}
|
||||
|
||||
|
@@ -2132,6 +2132,7 @@
|
||||
Developing {name} 正在處理 {name}
|
||||
Cancelled {label} 已取消 {label}
|
||||
Esc to cancel 按 Esc 取消
|
||||
Opened {name} 已開啟 {name}
|
||||
Opening {name} 正在開啟 {name}
|
||||
Saved {name} 已儲存 {name}
|
||||
Saving {name} 正在儲存 {name}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -277,6 +277,10 @@ pub(crate) enum SaveKind {
|
||||
pub type AutomationReadFn = Box<dyn FnMut(&str) -> Result<(String, Vec<u8>), String>>;
|
||||
/// Write bytes through the desktop control session's authorized write root.
|
||||
pub type AutomationWriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
|
||||
/// Load a directory-form `.pcraft` through the authorized read root, returning its display
|
||||
/// name (the file name) and the loaded document. `Ok(None)` marks a regular file: the
|
||||
/// caller falls back to the bytes path for the zip form.
|
||||
pub type AutomationOpenPcraftFn = Box<dyn FnMut(&str) -> Result<Option<(String, Document)>, String>>;
|
||||
/// Reject engine commands which still perform ambient filesystem I/O.
|
||||
pub type AutomationCommandFn = Box<dyn Fn(&str, &Value) -> Result<(), String>>;
|
||||
/// Open a URL in the system browser (native) — reliable cross-platform, unlike `ctx.open_url`.
|
||||
@@ -358,6 +362,7 @@ pub struct Services {
|
||||
/// using `file_dialog` and `write` with the user's authority.
|
||||
pub automation_read: Option<AutomationReadFn>,
|
||||
pub automation_write: Option<AutomationWriteFn>,
|
||||
pub automation_open_pcraft: Option<AutomationOpenPcraftFn>,
|
||||
pub automation_command: Option<AutomationCommandFn>,
|
||||
/// Same policy as [`Self::automation_command`], as a function pointer the engine calls for
|
||||
/// each step of `actions.play`. Installed on the session only while a control request or
|
||||
|
||||
@@ -73,7 +73,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
||||
base64 PNG data; a path is relative to the automation write root. Raises the window first
|
||||
(default) because occluded macOS windows stop rendering
|
||||
- `ui.focus`: bring the main window to the front
|
||||
- `app.open {path}` / `app.save {path?, tgaBits?}`: relative file I/O through the configured automation roots (`app.open` reads under the read root, `app.save` writes under the write root; absolute paths, `..` and paths escaping the root are refused, and both fail closed when no root was granted). Both reply with `warnings` (import/export notes such as "adjustment layer flattened"; `[]` when none), also shown to the user in the status bar and as a notice (`notices` in `ui.inspect`); `app.open` also returns the `path` and document `name`, `app.save` the `path` written. `app.save` without `path` writes back only to the document's own PSD, PSB or `.pcraft` file, like File › Save. `tgaBits` (24 or 32) sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its alpha, 24 none; without it, 32 with an alpha channel or transparency, else 24. Automation saves never show TGA or TIFF options. Automation opens and saves never fire script events. Use these two rather than `file.open`, `file.save`, `file.saveAs` or `file.saveACopy`, which the control channel refuses (see [Engine commands](#engine-commands))
|
||||
- `app.open {path}` / `app.save {path?, tgaBits?}`: relative file I/O through the configured automation roots (`app.open` reads under the read root, `app.save` writes under the write root; absolute paths, `..` and paths escaping the root are refused, and both fail closed when no root was granted). Both reply with `warnings` (import/export notes such as "adjustment layer flattened"; `[]` when none), also shown to the user in the status bar and as a notice (`notices` in `ui.inspect`); `app.open` also returns the `path` and document `name` (directory `.pcraft` — the native on-disk form — loads through the read root's capability; the single-file (zip) form takes the bytes path), `app.save` the `path` written. `app.save` without `path` writes back only to the document's own PSD, PSB or `.pcraft` file, like File › Save. `tgaBits` (24 or 32) sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its alpha, 24 none; without it, 32 with an alpha channel or transparency, else 24. Automation saves never show TGA or TIFF options. Automation opens and saves never fire script events. Use these two rather than `file.open`, `file.save`, `file.saveAs` or `file.saveACopy`, which the control channel refuses (see [Engine commands](#engine-commands))
|
||||
- `app.quit`
|
||||
|
||||
## Engine commands
|
||||
|
||||
Reference in New Issue
Block a user