app.open: open directory .pcraft through the automation read root (#2490) (#2581)

* 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:
Ahmed Hosny
2026-10-11 09:12:59 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 10fb9233d5
commit 60339cf675
24 changed files with 252 additions and 13 deletions
+8
View File
@@ -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,
+72
View File
@@ -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()
}
+8 -2
View File
@@ -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
View File
@@ -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 {
+48
View File
@@ -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)
+70
View File
@@ -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;
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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}
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
2132
2133
2134
2135
2136
2137
2138
+1
View File
@@ -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.
+1
View File
@@ -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} 저장 중
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
2088
2089
2090
2091
2092
2093
2094
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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}
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
2191
2192
2193
2194
2195
2196
2197
+1
View File
@@ -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.
+5
View File
@@ -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
+1 -1
View File
@@ -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