mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-02 14:47:05 -06:00
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:
Generated
+12
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¬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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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(¬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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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=""%1"" />
|
||||
</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=""[#DesigncraftExe]" "%1"" 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=""[#DesigncraftExe]" "%1"" 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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user