From 11532d961e6cde422ed5b7a87e0bef2f9481db8e Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 22:40:06 +0000 Subject: [PATCH] Documents: Save adds .designcraft, and the OS opens DesignCraft and IDML files - file.save / file.saveAs / file.saveACopy add the .designcraft extension to a given path that lacks it and report the path written; saving back to the document's own file keeps its name. - file.open tells DesignCraft and IDML packages apart by content, so files without an extension open; anything else is "not a DesignCraft or IDML file". - Dropped or web-picked files without an extension open as documents when their content is one. - Desktop save dialogs filter on the suggested extension and add it to a name typed without it, asking before replacing an existing file; the open dialog gains "All files". - macOS: open-documents Apple events (Finder, Dock, Open With) through fmv-macos-events, queued until the window is ready; Info.plist declares the .designcraft type and an imported .idml type with CFBundleDocumentTypes. - Windows MSI: .designcraft ProgId with an Open verb, IDML under Open with. - Linux: shared-mime-info for both types and MimeType in the .desktop file. Fixes #158, #211, #375 --- Cargo.lock | 12 +++ apps/designcraft/Cargo.toml | 5 + apps/designcraft/src/apple_events.rs | 46 +++++++++ apps/designcraft/src/main.rs | 87 ++++++++++++++++- crates/engine/src/cmd/file.rs | 94 +++++++++++++++++-- crates/engine/src/cmd/mod.rs | 2 +- crates/format/src/lib.rs | 9 ++ crates/ui-egui/src/lib.rs | 54 ++++++++++- .../linux/ai.storyteller.designcraft.desktop | 1 + .../linux/ai.storyteller.designcraft.mime.xml | 14 +++ packaging/macos/Info.plist.in | 65 ++++++++----- packaging/windows/designcraft.wxs | 22 ++++- 12 files changed, 371 insertions(+), 40 deletions(-) create mode 100644 apps/designcraft/src/apple_events.rs diff --git a/Cargo.lock b/Cargo.lock index 9473c974a..cbcca4195 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -876,6 +876,7 @@ dependencies = [ "designcraft-ui-egui", "eframe", "egui", + "fmv-macos-events", "log", "muda", "rfd", @@ -1565,6 +1566,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fmv-macos-events" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55" +dependencies = [ + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", +] + [[package]] name = "fnv" version = "1.0.7" diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index 416b07406..16cc4d4dd 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -31,3 +31,8 @@ winit = { version = "0.30", default-features = false, features = ["x11"] } [target.'cfg(target_os = "macos")'.dependencies] muda = { version = "0.21", default-features = false } +# Finder double-clicks, Open With, Dock drops and `open -a DesignCraft brochure.idml` arrive as a +# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This crate (also used by PhotoCraft, +# PdfCraft and WordCraft) registers the handler behind a safe API (Objective-C interop needs unsafe, +# which the workspace forbids). Pinned exactly: review the diff before bumping. +fmv-macos-events = "=0.1.0" diff --git a/apps/designcraft/src/apple_events.rs b/apps/designcraft/src/apple_events.rs new file mode 100644 index 000000000..79c19692d --- /dev/null +++ b/apps/designcraft/src/apple_events.rs @@ -0,0 +1,46 @@ +//! macOS open-documents and quit Apple events. +//! +//! Finder double-clicks, Open With, drops on the Dock icon and `open -a DesignCraft brochure.idml` +//! don't pass paths on the command line: LaunchServices sends the running (or just-launched) app a +//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the +//! `NSApplicationDelegate`, and handling it ourselves needs Objective-C class declarations, i.e. +//! `unsafe`, which this workspace forbids. The `fmv-macos-events` crate wraps exactly that (an +//! `NSAppleEventManager` handler registered before Finder's launch event, leaving winit's delegate +//! alone) behind a safe main-thread API. + +use designcraft_ui_egui::DesignApp; +use fmv_macos_events::{Event, Inbox, Registration}; + +/// Keeps the Apple-event handlers registered; hold it until the event loop returns. +pub struct AppleEvents { + _registration: Registration, + inbox: Inbox, +} + +impl AppleEvents { + /// Register the handlers. Call on the main thread before the event loop starts, so the event + /// that launched the app (a Finder double-click) is caught too. + pub fn install() -> Self { + let (registration, inbox) = Registration::install(); + Self { _registration: registration, inbox } + } + + /// The queue the app drains every frame ([`poll`]); events arriving later wake `ctx`. + pub fn connect(&self, ctx: &egui::Context) -> Inbox { + let ctx = ctx.clone(); + self.inbox.set_wake(move || ctx.request_repaint()); + self.inbox.clone() + } +} + +/// Hand the documents that arrived since the last frame to the app, which opens them as File › +/// Open does once its window is ready; a quit request (Dock › Quit) closes the window, as its close +/// button does. +pub fn poll(inbox: &Inbox, app: &mut DesignApp, ctx: &egui::Context) { + for e in inbox.drain() { + match e { + Event::Open(paths) => app.open_documents_later(paths.into_iter().map(|p| p.to_string_lossy().into_owned())), + Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + } + } +} diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index c30606232..465cd250f 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -11,6 +11,8 @@ #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(target_os = "macos")] +mod apple_events; mod control_server; mod gpu; mod logging; @@ -24,6 +26,9 @@ struct App { app: DesignApp, #[cfg(target_os = "macos")] menu: Option, + /// Documents and quit requests from Finder, the Dock and Open With. + #[cfg(target_os = "macos")] + apple_events: fmv_macos_events::Inbox, /// The graphics start in progress (`gpu.json`) until a frame has been presented; see `gpu`. startup: Option, /// Frames begun so far. @@ -49,6 +54,7 @@ impl eframe::App for App { if let Some(m) = &mut self.menu { m.poll(&mut self.app, ctx); } + apple_events::poll(&self.apple_events, &mut self.app, ctx); } self.app.logic(ctx); } @@ -184,10 +190,66 @@ fn open_filters(purpose: &str) -> &'static [OpenFilter] { OpenFilter { name: "DesignCraft or IDML", extensions: &["designcraft", "idml"] }, OpenFilter { name: "DesignCraft", extensions: &["designcraft"] }, OpenFilter { name: "InDesign Markup (IDML)", extensions: &["idml"] }, + // Files saved without an extension; `file.open` tells the formats apart by content. + OpenFilter { name: "All files", extensions: &[ALL_FILES] }, ], } } +/// The "All files" pattern. The macOS panel merges every filter into one list of allowed types, +/// which takes type identifiers as well as extensions: `public.data` admits any file. +const ALL_FILES: &str = if cfg!(target_os = "macos") { "public.data" } else { "*" }; + +/// The extension of the file name a save dialog suggests (`Brochure.designcraft`), if it has one. +/// The dialog filters on it, so the macOS and Windows dialogs add it to a name typed without it. +fn suggested_extension(name: &str) -> Option<&str> { + std::path::Path::new(name).extension().and_then(|e| e.to_str()).filter(|e| !e.is_empty() && !e.contains(char::is_whitespace)) +} + +/// The file a save dialog's answer writes: `picked` with the suggested extension added when it has +/// none (Linux dialogs don't add it). A document always ends in `.designcraft`, as `file.save` +/// writes it. +fn picked_save_path(picked: std::path::PathBuf, ext: Option<&str>) -> std::path::PathBuf { + let Some(ext) = ext else { return picked }; + if ext.eq_ignore_ascii_case("designcraft") { + let p = picked.to_string_lossy(); + return designcraft_engine::cmd::with_document_extension(&p).into(); + } + if picked.extension().is_some() || picked.file_name().is_none() { + return picked; + } + let mut s = picked.into_os_string(); + s.push("."); + s.push(ext); + s.into() +} + +/// The save dialog: the suggested name's extension as its filter, and the extension added to a +/// name typed without it. Adding it names another file than the one the dialog checked, so +/// replacing an existing one asks again. +fn pick_save(name: &str) -> Option { + let ext = suggested_extension(name); + let mut dialog = rfd::FileDialog::new().set_file_name(name); + if let Some(ext) = ext { + dialog = dialog.add_filter(ext.to_ascii_uppercase(), &[ext]); + } + let picked = dialog.save_file()?; + let path = picked_save_path(picked.clone(), ext); + if path != picked && path.exists() { + let file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let answer = rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Warning) + .set_title("Replace File?") + .set_description(format!("“{file}” already exists. Do you want to replace it?")) + .set_buttons(rfd::MessageButtons::YesNo) + .show(); + if answer != rfd::MessageDialogResult::Yes { + return None; + } + } + Some(path.to_string_lossy().to_string()) +} + fn services() -> Services { Services { pick_open: Some(Box::new(|purpose: &str| { @@ -197,7 +259,7 @@ fn services() -> Services { } dialog.pick_file().map(|p| p.to_string_lossy().to_string()) })), - pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))), + pick_save: Some(Box::new(pick_save)), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), ..Default::default() @@ -289,6 +351,12 @@ fn main() -> eframe::Result { b.with_x11(); })); } + // Registered before the event loop starts, so it catches the Finder event that launched the app + // as well as later ones. Lives until the event loop returns; the app creator only borrows it. + #[cfg(target_os = "macos")] + let apple_events = apple_events::AppleEvents::install(); + #[cfg(target_os = "macos")] + let apple_events = &apple_events; eframe::run_native( "DesignCraft", options, @@ -332,6 +400,8 @@ fn main() -> eframe::Result { app, #[cfg(target_os = "macos")] menu: None, + #[cfg(target_os = "macos")] + apple_events: apple_events.connect(&cc.egui_ctx), startup: gpu, frames: 0, })) @@ -363,6 +433,19 @@ mod tests { assert!(place.len() > 1, "the place dialog keeps a filter for each kind of file"); assert!(place.iter().any(|filter| filter.extensions.contains(&"png"))); let documents = super::open_filters(""); - assert!(documents.iter().any(|filter| filter.extensions.contains(&"designcraft"))); + assert!(documents.first().is_some_and(|filter| filter.extensions == ["designcraft", "idml"])); + assert!(documents.last().is_some_and(|filter| filter.extensions == [super::ALL_FILES]), "files without an extension can be picked"); + } + + #[test] + fn save_dialog_answers_get_the_suggested_extension() { + use std::path::PathBuf; + let save = |picked: &str, name: &str| super::picked_save_path(PathBuf::from(picked), super::suggested_extension(name)); + assert_eq!(save("/d/Brochure", "Untitled.designcraft"), PathBuf::from("/d/Brochure.designcraft")); + assert_eq!(save("/d/Brochure.idml", "Untitled.designcraft"), PathBuf::from("/d/Brochure.idml.designcraft")); + assert_eq!(save("/d/Brochure.designcraft", "Untitled.designcraft"), PathBuf::from("/d/Brochure.designcraft")); + assert_eq!(save("/d/Brochure", "Untitled.pdf"), PathBuf::from("/d/Brochure.pdf")); + assert_eq!(save("/d/Brochure.txt", "Untitled.rtf"), PathBuf::from("/d/Brochure.txt"), "another export format is kept"); + assert_eq!(save("/d/Package", "Untitled 1.2 Folder"), PathBuf::from("/d/Package"), "a folder has no extension"); } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f871a43e8..1158809d7 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -18,13 +18,14 @@ pub fn specs() -> Vec { cmd!(noundo "file.newSample", "Sample Document", ["Help"], None, "{} — a multi-page magazine sample", always, file_sample), cmd!(query "file.presets", "Document Presets", [], None, "{}", always, |_, _| Ok(serde_json::to_value(PRESETS).unwrap_or_default())), cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), - "{path} — .designcraft or .idml; the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped}", + "{path} — a DesignCraft or IDML file (recognised by its content, so the extension may be missing); the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped}", always, file_open), cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON or an IDML package", always, file_open_bytes), - cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?}", has_doc, file_save), - cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, file_save), - cmd!(noundo "file.saveACopy", "Save a Copy…", ["File"], None, "{path} — writes the document without changing which file it is or its unsaved state", has_doc, |s, p| { - let path = str_param(p, "path").ok_or_else(|| bad("file.saveACopy", "missing `path`"))?.to_string(); + cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} — a `path` without the .designcraft extension gets it → {path: the file written, bytes}", has_doc, file_save), + cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} — a path without the .designcraft extension gets it → {path: the file written, bytes}", has_doc, file_save), + cmd!(noundo "file.saveACopy", "Save a Copy…", ["File"], None, "{path} — writes the document without changing which file it is or its unsaved state; a path without the .designcraft extension gets it → {path: the file written, bytes}", has_doc, |s, p| { + let path = str_param(p, "path").ok_or_else(|| bad("file.saveACopy", "missing `path`"))?; + let path = with_document_extension(path); let bytes = to_bytes(&s.doc()?.doc); #[cfg(not(target_arch = "wasm32"))] std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; @@ -111,6 +112,22 @@ pub fn to_bytes(d: &Document) -> Vec { designcraft_format::save(d).unwrap_or_default() } +/// `path` ending in `.designcraft`: the extension is added when it is missing or another one, so +/// the file opens from the file manager. A path without a file name is left for the write to fail. +pub fn with_document_extension(path: &str) -> String { + let p = std::path::Path::new(path); + let ext = p.extension().and_then(|e| e.to_str()); + if p.file_name().is_none() || ext.is_some_and(|e| e.eq_ignore_ascii_case(designcraft_format::EXTENSION)) { + return path.to_string(); + } + format!("{path}.{}", designcraft_format::EXTENSION) +} + +/// Whether `bytes` are a document `file.open` reads: a DesignCraft package or an IDML package. +pub fn is_document(bytes: &[u8]) -> bool { + designcraft_idml::is_idml(bytes) || designcraft_format::is_designcraft(bytes) +} + pub fn from_bytes(b: &[u8]) -> Result { designcraft_format::load(b).map_err(|e| EngineError::Other(e.to_string())) } @@ -163,6 +180,13 @@ fn file_open(s: &mut Session, p: &Value) -> Result { return super::interchange::open_idml(s, p); } let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + // The content decides: a file's extension may be missing or wrong. + if designcraft_idml::is_idml(&bytes) { + return super::interchange::open_idml(s, p); + } + if !bytes.starts_with(b"PK") && !bytes.trim_ascii_start().starts_with(b"{") { + return Err(bad("file.open", format!("{path}: not a DesignCraft or IDML file"))); + } let mut d = from_bytes(&bytes)?; super::interchange::resolve_packaged_links(&mut d, std::path::Path::new(path).parent()); super::datamerge::resolve_sources_on_open(&mut d, Some(std::path::Path::new(path))); @@ -233,8 +257,9 @@ fn file_open_bytes(s: &mut Session, p: &Value) -> Result { fn file_save(s: &mut Session, p: &Value) -> Result { let st = s.doc_mut()?; + // Saving to the document's own file keeps its name, even one opened without the extension. let path = str_param(p, "path") - .map(str::to_string) + .map(with_document_extension) .or_else(|| st.path.clone()) .ok_or_else(|| bad("file.save", "missing `path` (document has never been saved)"))?; { @@ -940,3 +965,60 @@ mod document_fonts_tests { let _ = std::fs::remove_dir_all(&dir); } } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod extension_tests { + use serde_json::json; + + use super::{is_document, with_document_extension}; + use crate::Session; + + #[test] + fn the_document_extension_is_added_when_missing_or_different() { + assert_eq!(with_document_extension("/a/Brochure"), "/a/Brochure.designcraft"); + assert_eq!(with_document_extension("/a/Brochure.idml"), "/a/Brochure.idml.designcraft"); + assert_eq!(with_document_extension("/a/v1.2 Brochure"), "/a/v1.2 Brochure.designcraft"); + assert_eq!(with_document_extension("/a/Brochure.DesignCraft"), "/a/Brochure.DesignCraft"); + assert_eq!(with_document_extension("/a/.."), "/a/.."); + } + + #[test] + fn save_adds_the_extension_and_open_recognises_files_by_content() { + let dir = std::env::temp_dir().join(format!("dc-file-extension-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + + let bare = dir.join("Brochure"); + let written = dir.join("Brochure.designcraft"); + let r = s.execute("file.saveAs", &json!({"path": bare.to_string_lossy()})).unwrap(); + assert_eq!(r["path"], json!(written.to_string_lossy()), "{r}"); + assert!(written.is_file() && !bare.exists()); + assert_eq!(s.doc().unwrap().path.as_deref(), written.to_str()); + let r = s.execute("file.saveACopy", &json!({"path": dir.join("Copy").to_string_lossy()})).unwrap(); + assert_eq!(r["path"], json!(dir.join("Copy.designcraft").to_string_lossy()), "{r}"); + + // A native file without the extension opens, and Save writes back to that same file. + std::fs::rename(&written, &bare).unwrap(); + let mut t = Session::new(); + t.execute("file.open", &json!({"path": bare.to_string_lossy()})).unwrap(); + let r = t.execute("file.save", &json!({})).unwrap(); + assert_eq!(r["path"], json!(bare.to_string_lossy()), "{r}"); + assert!(!written.exists()); + + // An IDML package without the extension opens as IDML, unsaved. + let idml = dir.join("Package"); + std::fs::write(&idml, designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); + assert!(is_document(&std::fs::read(&idml).unwrap()) && is_document(&std::fs::read(&bare).unwrap())); + t.execute("file.open", &json!({"path": idml.to_string_lossy()})).unwrap(); + assert_eq!(t.doc().unwrap().path, None); + + let notes = dir.join("Notes"); + std::fs::write(¬es, b"plain text").unwrap(); + assert!(!is_document(b"plain text")); + let e = t.execute("file.open", &json!({"path": notes.to_string_lossy()})).unwrap_err(); + assert!(e.to_string().contains("not a DesignCraft or IDML file"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 68061af0a..3b1d11a02 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -299,5 +299,5 @@ pub fn file_from(b: &[u8]) -> Result { } pub(crate) use datamerge::sync_placeholders; pub(crate) use file::load_document_fonts; -pub use file::{base64_decode, base64_encode, from_bytes, to_bytes}; +pub use file::{base64_decode, base64_encode, from_bytes, is_document, to_bytes, with_document_extension}; pub(crate) use place_text::autoflow as place_text_autoflow; diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index f80ad0a65..9ba2eace7 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -72,6 +72,15 @@ pub fn save(doc: &Document) -> Result, FormatError> { Ok(buf.into_inner()) } +/// The file name extension of a DesignCraft document. +pub const EXTENSION: &str = "designcraft"; + +/// Whether `bytes` are a DesignCraft document package: a zip holding `document.json`. Files +/// without an extension are recognised by this (the legacy JSON form is too generic to sniff). +pub fn is_designcraft(bytes: &[u8]) -> bool { + bytes.starts_with(b"PK") && zip::ZipArchive::new(Cursor::new(bytes)).is_ok_and(|z| z.index_for_name("document.json").is_some()) +} + /// Read `.designcraft` bytes (zip, or the legacy single JSON file). pub fn load(bytes: &[u8]) -> Result { if bytes.starts_with(b"PK") { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fb81e9c34..672537ec8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -476,6 +476,9 @@ pub struct DesignApp { fonts_lang: String, pub restyle: bool, fonts_ready: bool, + /// Files the operating system asked to open (macOS Finder, Dock, Open With), opened once the + /// window is ready; see [`DesignApp::open_documents_later`]. + os_documents: Vec, pub integrated_titlebar: bool, /// The host installed a native menu bar (macOS): don't draw menus in the window. pub native_menu: bool, @@ -516,6 +519,7 @@ impl DesignApp { fonts_lang: String::new(), restyle: false, fonts_ready: false, + os_documents: Vec::new(), integrated_titlebar: false, native_menu: false, native_shortcuts: Default::default(), @@ -682,7 +686,8 @@ impl DesignApp { fn import_file(&mut self, file: ImportedFile) -> Result { let lower = file.name.to_ascii_lowercase(); - let layout = opens_as_document(&file.name); + let layout = opens_as_document(&file.name) + || (std::path::Path::new(&file.name).extension().is_none() && designcraft_engine::cmd::is_document(&file.bytes)); let place = file.request.purpose == "place" || (matches!(file.request.purpose.as_str(), "drop" | "open") && !layout && !lower.ends_with(".ase")); if place || lower.ends_with(".ase") || file.request.purpose == "swatches" { @@ -760,6 +765,7 @@ impl DesignApp { self.drain_control(ctx); if self.fonts_ready { self.drain_inbox(); + self.open_os_documents(); } if !self.synthetic.is_empty() { ctx.request_repaint(); @@ -778,12 +784,30 @@ impl DesignApp { } } - /// A file dropped on the window: documents open, anything else is placed. + /// A file dropped on the window: documents open, anything else is placed. A file without an + /// extension opens when its content is a DesignCraft or IDML document. pub fn open_dropped(&mut self, path: &str) -> Result { - let cmd = if opens_as_document(path) { "file.open" } else { "file.place" }; + let document = opens_as_document(path) + || (std::path::Path::new(path).extension().is_none() && std::fs::read(path).is_ok_and(|b| designcraft_engine::cmd::is_document(&b))); + let cmd = if document { "file.open" } else { "file.place" }; self.open_file(cmd, json!({"path": path})) } + /// Queue files the operating system asked the app to open. They can arrive before the window + /// exists (the event that launched the app); the first ready frame opens them, each as a new + /// document through `file.open`, as File › Open does. + pub fn open_documents_later(&mut self, paths: impl IntoIterator) { + self.os_documents.extend(paths); + } + + fn open_os_documents(&mut self) { + for path in std::mem::take(&mut self.os_documents) { + if let Err(e) = self.open_file("file.open", json!({"path": path})) { + log::warn!("{path}: {e}"); + } + } + } + /// Open (`file.open`, `file.openBytes`) or place (`file.place`) a file the user chose. A file /// that can't be opened or placed gets an alert with the reason, as well as the status line. pub fn open_file(&mut self, cmd: &str, params: Value) -> Result { @@ -1226,6 +1250,30 @@ mod tests { } } + /// Files without an extension: dropped, a document opens and anything else is placed; asked + /// for by the operating system, they wait for the window and then open as documents. + #[test] + fn extensionless_documents_open_by_content() { + let path = std::env::temp_dir().join(format!("designcraft-ui-{}-Brochure", std::process::id())); + let mut app = DesignApp::new(Session::new(), Services::default()); + app.run("file.new", json!({})).unwrap(); + let bytes = designcraft_engine::cmd::base64_decode(app.run("file.serialize", json!({})).unwrap()["base64"].as_str().unwrap()); + std::fs::write(&path, bytes).unwrap(); + let p = path.to_string_lossy().to_string(); + app.open_dropped(&p).unwrap(); + assert_eq!(app.session.documents().len(), 2, "a document opens, it isn't placed"); + app.open_documents_later([p.clone()]); + assert_eq!(app.session.documents().len(), 2, "queued until the window is ready"); + app.open_os_documents(); + assert_eq!(app.session.documents().len(), 3); + assert_eq!(app.session.active().and_then(|d| d.path.clone()), Some(p)); + let _ = std::fs::remove_file(&path); + let notes = unreadable_file("notes"); + let e = app.open_dropped(¬es.to_string_lossy()).unwrap_err(); + let _ = std::fs::remove_file(¬es); + assert_eq!(alert(&app), ("Can't Place the File".to_string(), e)); + } + #[test] fn dropping_an_unreadable_file_alerts() { // A document opens, so it says it can't be opened. diff --git a/packaging/linux/ai.storyteller.designcraft.desktop b/packaging/linux/ai.storyteller.designcraft.desktop index cdf1461cf..82668385f 100644 --- a/packaging/linux/ai.storyteller.designcraft.desktop +++ b/packaging/linux/ai.storyteller.designcraft.desktop @@ -10,4 +10,5 @@ Terminal=false StartupNotify=true StartupWMClass=ai.storyteller.designcraft Categories=Graphics;Publishing; +MimeType=application/x-designcraft;application/vnd.adobe.indesign-idml-package; Keywords=layout;page;desktop publishing;dtp;magazine;book;indesign;idml;typesetting; diff --git a/packaging/linux/ai.storyteller.designcraft.mime.xml b/packaging/linux/ai.storyteller.designcraft.mime.xml index be4dcc220..d01ff317b 100644 --- a/packaging/linux/ai.storyteller.designcraft.mime.xml +++ b/packaging/linux/ai.storyteller.designcraft.mime.xml @@ -1,3 +1,17 @@ + + + DesignCraft document + + + + + + IDML document + + + diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index 434894284..ba4503678 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -45,7 +45,37 @@ NSHumanReadableCopyright Copyright © the DesignCraft authors. MIT OR Apache-2.0. - + + CFBundleDocumentTypes + + + CFBundleTypeName + DesignCraft Document + CFBundleTypeRole + Editor + LSHandlerRank + Owner + CFBundleTypeIconFile + DesignCraft + LSItemContentTypes + ai.storyteller.designcraft.document + + + CFBundleTypeName + IDML Document + + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + LSItemContentTypes + ai.storyteller.designcraft.idml + + + + UTExportedTypeDeclarations @@ -56,55 +86,40 @@ UTTypeConformsTo public.data - public.image + public.composite-content UTTypeIconFile DesignCraft UTTypeTagSpecification public.filename-extension - pcraft + designcraft public.mime-type application/x-designcraft - + UTImportedTypeDeclarations UTTypeIdentifier - com.adobe.photoshop-large-image + ai.storyteller.designcraft.idml UTTypeDescription - Photoshop Large Document + IDML Document UTTypeConformsTo public.data - public.image + public.zip-archive UTTypeTagSpecification public.filename-extension - psb - - - - UTTypeIdentifier - ai.storyteller.designcraft.qoi - UTTypeDescription - QOI Image - UTTypeConformsTo - - public.data - public.image - - UTTypeTagSpecification - - public.filename-extension - qoi + idml + public.mime-type + application/vnd.adobe.indesign-idml-package - diff --git a/packaging/windows/designcraft.wxs b/packaging/windows/designcraft.wxs index 8e2de81a0..bf8852abc 100644 --- a/packaging/windows/designcraft.wxs +++ b/packaging/windows/designcraft.wxs @@ -4,9 +4,9 @@ wix build designcraft.wxs -arch x64 -d Version=1.2.3 -d BinDir=... -d IconPath=... -o out.msi - Per-machine install into Program Files, a Start Menu shortcut, `.pcraft` owned by DesignCraft, - and DesignCraft offered under "Open with" for PSD/PSB and the image formats it reads (without - taking over the user's default app for them). + Per-machine install into Program Files, a Start Menu shortcut, `.designcraft` owned by + DesignCraft, and DesignCraft offered under "Open with" for IDML (without taking over the user's + default app for it). --> + + + + + + + + + + + + + + + +