Merge file-open-os: Save adds .designcraft, and the OS opens DesignCraft and IDML files

- apps/designcraft/Cargo.toml: the macOS target keeps objc2 0.6 / objc2-app-kit 0.3 (IME) and
  adds fmv-macos-events =0.1.0, which uses the same objc2 and objc2-app-kit versions.
- apps/designcraft/src/main.rs: the shared Arc<gpu::Startup>, catch_unwind and restart flow
  stay; the Apple-event handlers are installed before run_native (outside catch_unwind, held
  until the window ends), the app drains the inbox each frame, and documents that arrived are
  noted on the Startup. The open dialog keeps the INDD/INDT filter and ends with All files.
  The open-filter test checks both; the save-dialog test is added.
- apps/designcraft/src/gpu.rs: Startup::opening records the documents the OS handed this run;
  a start again after a graphics failure (both finish and the first-frame watch) passes them
  as file arguments, since Finder doesn't send them twice.
- apps/designcraft/src/apple_events.rs: poll returns the documents it handed the app, and
  queued() returns those still in the inbox when the window ends.
- crates/engine/src/cmd/file.rs: file.open decides by content first (IDML by its mimetype
  entry, then a DesignCraft package or JSON), then an InDesign file by its signature or
  .indd/.indt extension, then a .idml or .designcraft extension for the reader's own error;
  anything else is "not a DesignCraft or IDML file". INDD stays in file.openBytes. Save
  commands add .designcraft as on file-open-os; the params docs describe all of it.
- crates/engine/src/cmd/mod.rs: re-exports from both sides (is_document,
  with_document_extension and layout::chapter_numbering).
This commit is contained in:
aiongg
2026-10-10 23:21:12 +00:00
13 changed files with 401 additions and 42 deletions
Generated
+12
View File
@@ -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"
+5
View File
@@ -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"
+59
View File
@@ -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<String> {
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<String> {
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
}
+12 -1
View File
@@ -352,6 +352,9 @@ pub struct Startup {
notice: Mutex<Option<String>>,
/// The backend fallback of this start (`gpu.json`); `None` when `WGPU_BACKEND` chooses.
fallback: Mutex<Option<backend::Fallback>>,
/// 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<Vec<String>>,
}
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<Item = String>) {
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<String> {
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)]
+88 -1
View File
@@ -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<native_menu::NativeMenu>,
/// The window's start: the adapter and backend chosen for it; see `gpu`.
startup: std::sync::Arc<gpu::Startup>,
/// 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<String> {
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");
}
}
+90 -8
View File
@@ -39,13 +39,14 @@ pub fn specs() -> Vec<CommandSpec> {
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<u8> {
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<Document> {
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<Value> {
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<Value> {
fn file_save(s: &mut Session, p: &Value) -> Result<Value> {
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(&notes, 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);
}
}
+1 -1
View File
@@ -305,6 +305,6 @@ pub fn file_from(b: &[u8]) -> Result<designcraft_doc::Document> {
}
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;
+9
View File
@@ -73,6 +73,15 @@ pub fn save(doc: &Document) -> Result<Vec<u8>, 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<Document, FormatError> {
if bytes.starts_with(b"PK") {
+51 -3
View File
@@ -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<String>,
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<Value, String> {
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<Value, String> {
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<Item = String>) {
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<Value, String> {
@@ -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(&notes.to_string_lossy()).unwrap_err();
let _ = std::fs::remove_file(&notes);
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.
@@ -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;
@@ -1,3 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Installed as share/mime/packages/ai.storyteller.designcraft.xml (package.sh, the Flatpak
manifests). Both formats are zip files, so they are matched by extension: zip magic would
claim every archive. -->
<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
<mime-type type="application/x-designcraft">
<comment>DesignCraft document</comment>
<sub-class-of type="application/zip"/>
<generic-icon name="ai.storyteller.designcraft"/>
<glob pattern="*.designcraft"/>
</mime-type>
<mime-type type="application/vnd.adobe.indesign-idml-package">
<comment>IDML document</comment>
<sub-class-of type="application/zip"/>
<glob pattern="*.idml"/>
</mime-type>
</mime-info>
+40 -25
View File
@@ -45,7 +45,37 @@
<key>NSHumanReadableCopyright</key>
<string>Copyright © the DesignCraft authors. MIT OR Apache-2.0.</string>
<!-- Designcraft's own format. -->
<!-- The documents Finder, the Dock and Open With hand to the app (an Apple event; see
apps/designcraft/src/apple_events.rs). -->
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>DesignCraft Document</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>CFBundleTypeIconFile</key>
<string>DesignCraft</string>
<key>LSItemContentTypes</key>
<array><string>ai.storyteller.designcraft.document</string></array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>IDML Document</string>
<!-- Opens as a new, unsaved document; saving writes a .designcraft file. -->
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array><string>ai.storyteller.designcraft.idml</string></array>
</dict>
</array>
<!-- DesignCraft's own format: a zip package, declared as a document rather than an archive so
Finder doesn't offer to expand it. -->
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
@@ -56,55 +86,40 @@
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.image</string>
<string>public.composite-content</string>
</array>
<key>UTTypeIconFile</key>
<string>DesignCraft</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array><string>pcraft</string></array>
<array><string>designcraft</string></array>
<key>public.mime-type</key>
<array><string>application/x-designcraft</string></array>
</dict>
</dict>
</array>
<!-- Types macOS may not know; the system's own declaration wins when it has one. -->
<!-- A type macOS may not know; the system's own declaration wins when it has one. -->
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.adobe.photoshop-large-image</string>
<string>ai.storyteller.designcraft.idml</string>
<key>UTTypeDescription</key>
<string>Photoshop Large Document</string>
<string>IDML Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.image</string>
<string>public.zip-archive</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array><string>psb</string></array>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>ai.storyteller.designcraft.qoi</string>
<key>UTTypeDescription</key>
<string>QOI Image</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.image</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array><string>qoi</string></array>
<array><string>idml</string></array>
<key>public.mime-type</key>
<array><string>application/vnd.adobe.indesign-idml-package</string></array>
</dict>
</dict>
</array>
</dict>
</plist>
+19 -3
View File
@@ -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).
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package
@@ -49,6 +49,22 @@
Icon="DesigncraftIcon.ico" IconIndex="0" />
</File>
<!-- .designcraft: DesignCraft's own format; DesignCraft is its default app. -->
<ProgId Id="Designcraft.Document" Description="DesignCraft Document" Icon="DesigncraftExe" IconIndex="0">
<Extension Id="designcraft" ContentType="application/x-designcraft">
<Verb Id="open" Command="Open" TargetFile="DesigncraftExe" Argument="&quot;%1&quot;" />
</Extension>
</ProgId>
<!-- IDML: offered under "Open with", never forced as the default. -->
<ProgId Id="Designcraft.Idml" Description="IDML Document" Icon="DesigncraftExe" IconIndex="0" />
<RegistryValue Root="HKLM" Key="Software\Classes\Designcraft.Idml\shell\open\command" Value="&quot;[#DesigncraftExe]&quot; &quot;%1&quot;" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.idml\OpenWithProgids" Name="Designcraft.Idml" Value="" Type="string" />
<!-- "Open with" app list and the Run dialog (Win+R "designcraft"). -->
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\designcraft.exe" Name="FriendlyAppName" Value="DesignCraft" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\designcraft.exe\shell\open\command" Value="&quot;[#DesigncraftExe]&quot; &quot;%1&quot;" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\designcraft.exe\SupportedTypes" Name=".designcraft" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\designcraft.exe\SupportedTypes" Name=".idml" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\designcraft.exe" Value="[#DesigncraftExe]" Type="string" />
</Component>