diff --git a/Cargo.lock b/Cargo.lock index c82ba9c4d..72499f27a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -877,6 +877,7 @@ dependencies = [ "designcraft-ui-egui", "eframe", "egui", + "fmv-macos-events", "log", "muda", "objc2 0.6.4", @@ -1581,6 +1582,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 6941afe9d..02b6b5714 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -41,3 +41,8 @@ muda = { version = "0.21", default-features = false } # The input context, to drop the IME's marked text when the Type tool ends a composition. objc2 = "0.6" objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSTextInputContext"] } +# 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..71bb6a52f --- /dev/null +++ b/apps/designcraft/src/apple_events.rs @@ -0,0 +1,59 @@ +//! 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() + } + + /// The documents still queued: those that arrived after the window's last frame. + pub fn queued(&self) -> Vec { + self.inbox + .drain() + .into_iter() + .flat_map(|e| if let Event::Open(paths) = e { paths } else { Vec::new() }) + .map(|p| p.to_string_lossy().into_owned()) + .collect() + } +} + +/// Hand the documents that arrived since the last frame to the app, which opens them as File › +/// Open does once its window is ready, and return them; a quit request (Dock › Quit) closes the +/// window, as its close button does. +pub fn poll(inbox: &Inbox, app: &mut DesignApp, ctx: &egui::Context) -> Vec { + let mut documents = Vec::new(); + for e in inbox.drain() { + match e { + Event::Open(paths) => documents.extend(paths.into_iter().map(|p| p.to_string_lossy().into_owned())), + Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + } + } + app.open_documents_later(documents.iter().cloned()); + documents +} diff --git a/apps/designcraft/src/gpu.rs b/apps/designcraft/src/gpu.rs index 361ed25ba..01b3ca6d4 100644 --- a/apps/designcraft/src/gpu.rs +++ b/apps/designcraft/src/gpu.rs @@ -352,6 +352,9 @@ pub struct Startup { notice: Mutex>, /// The backend fallback of this start (`gpu.json`); `None` when `WGPU_BACKEND` chooses. fallback: Mutex>, + /// Documents the OS handed this run outside its arguments (macOS Apple events), for a start + /// again to open: the OS doesn't send them twice. + documents: Mutex>, } impl Startup { @@ -371,6 +374,12 @@ impl Startup { self.leaves.fetch_add(1, Ordering::Relaxed); } + /// Note documents the OS handed this run outside its arguments, for [`restart`] to pass on. + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + pub fn opening(&self, paths: impl IntoIterator) { + self.documents.lock().unwrap_or_else(PoisonError::into_inner).extend(paths); + } + /// The note [`watch_first_frame`] left for the status bar, once. pub fn take_notice(&self) -> Option { self.notice.lock().unwrap_or_else(PoisonError::into_inner).take() @@ -722,7 +731,8 @@ fn next_start( None } -/// Start the app again with the same arguments and the skip list `skip` ([`SKIP_ARG`]). On Unix +/// Start the app again with the same arguments, the documents the OS handed this run +/// ([`Startup::opening`]) and the skip list `skip` ([`SKIP_ARG`]). On Unix /// the new app replaces this process (same process id, so an AppImage keeps its files mounted), /// and this returns only on failure; on Windows it starts beside this one, which then exits. The /// failure was caught, so `gpu.json` stops blaming the backend first, and blames it again when @@ -731,6 +741,7 @@ fn restart(skip: &[String], startup: &Startup) -> std::io::Result<()> { log::logger().flush(); let mut c = std::process::Command::new(std::env::current_exe()?); c.args(std::env::args_os().skip(1).filter(|a| a.to_str().is_none_or(|a| skip_arg(a).is_none()))); + c.args(startup.documents.lock().unwrap_or_else(PoisonError::into_inner).iter()); c.arg(format!("{SKIP_ARG}{}", skip.join(","))); let trying = startup.caught(); #[cfg(unix)] diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 0b527f095..2bfc959e6 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -13,6 +13,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; @@ -28,6 +30,9 @@ struct App { menu: Option, /// The window's start: the adapter and backend chosen for it; see `gpu`. startup: std::sync::Arc, + /// Documents and quit requests from Finder, the Dock and Open With. + #[cfg(target_os = "macos")] + apple_events: fmv_macos_events::Inbox, /// Frames begun so far. frames: u32, } @@ -52,6 +57,8 @@ impl eframe::App for App { if let Some(m) = &mut self.menu { m.poll(&mut self.app, ctx); } + // Noted for a start again after a graphics failure, which reopens them (`gpu::restart`). + self.startup.opening(apple_events::poll(&self.apple_events, &mut self.app, ctx)); } self.app.logic(ctx); self.startup.leave(); @@ -204,10 +211,66 @@ fn open_filters(purpose: &str) -> &'static [OpenFilter] { OpenFilter { name: "DesignCraft", extensions: &["designcraft"] }, OpenFilter { name: "InDesign Markup (IDML)", extensions: &["idml"] }, OpenFilter { name: "InDesign document or template (INDD, INDT)", extensions: &["indd", "indt"] }, + // 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| { @@ -217,7 +280,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()))), clipboard_text: Some(Box::new(|| arboard::Clipboard::new().and_then(|mut c| c.get_text()).ok())), @@ -338,6 +401,12 @@ fn main() -> std::process::ExitCode { 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 events = &apple_events; let created = startup.clone(); // A panic that ends the window (egui-wgpu's own) is caught here: never a crash. let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { @@ -403,6 +472,8 @@ fn main() -> std::process::ExitCode { #[cfg(target_os = "macos")] menu: None, startup: created, + #[cfg(target_os = "macos")] + apple_events: events.connect(&cc.egui_ctx), frames: 0, })) }), @@ -411,6 +482,9 @@ fn main() -> std::process::ExitCode { .map_err(|payload| designcraft_engine::guard::panic_message(payload.as_ref())); // The window's end, or a graphics failure that starts the app again. A failure is logged: // standard error and the log file, since a Windows start has no console to show it. + // Documents that arrived after the last frame, for a start again to open too. + #[cfg(target_os = "macos")] + startup.opening(apple_events.queued()); match gpu::finish(run, &startup) { Ok(()) => std::process::ExitCode::SUCCESS, Err(e) => { @@ -447,5 +521,18 @@ mod tests { for ext in ["designcraft", "idml", "indd", "indt"] { assert!(documents.first().is_some_and(|filter| filter.extensions.contains(&ext)), "{ext} is missing from the first Open filter"); } + 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 bd6d5f66c..8003d182c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -39,13 +39,14 @@ pub fn specs() -> Vec { ok() }), cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), - "{path} — .designcraft, .idml, or an InDesign .indd/.indt (converted to IDML; its conversion warnings join `warnings`); the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped and, for IDML and InDesign files, package parts missing}", + "{path} — a DesignCraft or IDML file (recognised by its content, so the extension may be missing), or an InDesign .indd/.indt (recognised by its signature or extension, converted to IDML; its conversion warnings join `warnings`); the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped and, for IDML and InDesign files, package parts missing}", always, file_open), cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON, an IDML package or an InDesign .indd/.indt", 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}")))?; @@ -132,6 +133,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())) } @@ -242,10 +259,17 @@ fn file_open(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?; #[cfg(not(target_arch = "wasm32"))] { - if path.to_ascii_lowercase().ends_with(".idml") || super::interchange::has_indd_extension(path) { + let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + // The content decides first: a file's extension may be missing or wrong. The extension + // then routes files whose content isn't recognised to the reader that explains why. + let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase(); + let native = designcraft_format::is_designcraft(&bytes) || bytes.trim_ascii_start().starts_with(b"{"); + if designcraft_idml::is_idml(&bytes) || (!native && (super::interchange::is_indd(path, &bytes) || ext == "idml")) { return super::interchange::open_idml(s, p); } - let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + if !native && ext != designcraft_format::EXTENSION { + 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))); @@ -317,8 +341,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)"))?; { @@ -1189,3 +1214,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 35c90ef56..a5fd9a2fc 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -305,6 +305,6 @@ 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 use layout::chapter_numbering; 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 7175c4376..9353ea68e 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -73,6 +73,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 d72d02e22..701903176 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -494,6 +494,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, @@ -550,6 +553,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(), @@ -836,7 +840,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" { @@ -917,6 +922,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(); @@ -935,12 +941,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 { @@ -1468,6 +1492,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). --> + + + + + + + + + + + + + + + +