diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 172428780..a457d9808 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -1090,4 +1090,116 @@ mod tests { assert!(types > 1 && plist.matches("LSHandlerRank").count() == types, "every document type has a rank"); assert_eq!(plist.matches("Owner").count(), 2, "only VectorCraft documents and templates are owned"); } + + /// The MIME types a Linux desktop gives each extension in `OPEN_EXTS` but `UNASSOCIATED_EXTS` + /// (one row per type): shared-mime-info's, or one that `ai.storyteller.vectorcraft.mime.xml` + /// declares. + const LINUX_TYPES: &[(&str, &str)] = &[ + ("vectorcraft", "application/x-vectorcraft"), + ("drawcraft", "application/x-vectorcraft"), + ("svg", "image/svg+xml"), + ("svgz", "image/svg+xml-compressed"), + ("pdf", "application/pdf"), + ("ai", "application/illustrator"), // brand-ok: shared-mime-info's type + ("ait", "application/x-ai-template"), + ("png", "image/png"), + ("jpg", "image/jpeg"), + ("jpeg", "image/jpeg"), + ("gif", "image/gif"), + ("webp", "image/webp"), + ("tif", "image/tiff"), + ("tiff", "image/tiff"), + ("bmp", "image/bmp"), + ("vctemplate", "application/x-vectorcraft-template"), + ("dxf", "image/vnd.dxf"), + ("emf", "image/emf"), + ("wmf", "image/wmf"), + ("eps", "image/x-eps"), + ("af", "application/x-affinity"), + ("afdesign", "application/x-affinity"), + ("afpub", "application/x-affinity"), + ]; + + /// The types in `LINUX_TYPES` that shared-mime-info defines. The MIME package declares every + /// other one. + const LINUX_SHARED_TYPES: &[&str] = &[ + "image/svg+xml", + "image/svg+xml-compressed", + "application/pdf", + "application/illustrator", // brand-ok: shared-mime-info's type + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/tiff", + "image/bmp", + "image/vnd.dxf", + "image/emf", + "image/wmf", + "image/x-eps", + ]; + + /// Extensions in `LINUX_TYPES` whose type the MIME package matches by file content and not by a + /// glob, with the package's `` rule for that type (its lines joined, indentation removed). + const LINUX_BY_CONTENT: &[(&str, &str)] = &[( + "af", + // `MAGIC` at offset 0 and `DOCUMENT_KIND` at offset 8 (crates/affinity/src/container.rs). + r#""#, + )]; + + /// The desktop entry lists a MIME type for every extension in `OPEN_EXTS` but + /// `UNASSOCIATED_EXTS`, and the app's MIME package declares the types shared-mime-info doesn't + /// have. + #[test] + fn the_linux_desktop_entry_opens_every_readable_format() { + let desktop = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.desktop"); + let mime = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.mime.xml"); + let listed: Vec<&str> = + desktop.lines().find_map(|l| l.strip_prefix("MimeType=")).unwrap_or_default().split(';').filter(|t| !t.is_empty()).collect(); + // Each type the MIME package declares, with the extensions of its globs and its `` + // rule (empty when it has none) in the form `LINUX_BY_CONTENT` gives. + let declared: Vec<(&str, Vec<&str>, String)> = mime + .split("").next().unwrap_or_default(); + let globs = body.split("")) { + (Some(start), Some(end)) => body[start..end + "".len()].lines().map(str::trim).collect(), + _ => String::new(), + }; + (block.split('"').next().unwrap_or_default(), globs, magic) + }) + .collect(); + let mut associated: Vec<&str> = fileio::OPEN_EXTS.iter().copied().filter(|e| !fileio::UNASSOCIATED_EXTS.contains(e)).collect(); + let mut typed: Vec<&str> = LINUX_TYPES.iter().map(|(e, _)| *e).collect(); + associated.sort_unstable(); + typed.sort_unstable(); + typed.dedup(); + assert_eq!(typed, associated, "a MIME type for each extension the packaging associates with the app"); + for (e, t) in LINUX_TYPES { + assert!(listed.contains(t), "the desktop entry doesn't list {t} (.{e})"); + let package = declared.iter().find(|(name, ..)| name == t); + if LINUX_SHARED_TYPES.contains(t) { + assert!(package.is_none(), "{t} is shared-mime-info's, and the MIME package declares it again"); + continue; + } + let Some((_, globs, magic)) = package else { panic!("the MIME package doesn't declare {t} (.{e})") }; + if let Some((_, rule)) = LINUX_BY_CONTENT.iter().find(|(content, _)| content == e) { + assert!(!globs.contains(e), "{t} matches .{e} files by content only"); + assert_eq!(magic, rule, "{t}'s magic rule for .{e} files"); + } else { + assert!(globs.contains(e), "{t} has no *.{e} glob"); + } + } + for t in &listed { + assert!(LINUX_TYPES.iter().any(|(_, known)| known == t), "the desktop entry lists {t}, which the app doesn't open"); + } + for (t, globs, _) in &declared { + assert!(listed.contains(t), "the desktop entry doesn't list the declared {t}"); + for g in globs { + assert!(LINUX_TYPES.contains(&(*g, *t)), "{t}'s *.{g} isn't a file the app opens as {t}"); + } + } + } } diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index a1968a5cd..7aba9471e 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -660,7 +660,7 @@ pub const FORMATS: &[Format] = &[ Format { id: "psd", label: "PSD", extensions: &["psd"], mime: "image/x-psd", read: true, write: true, raster: true, options: PSD_OPTIONS }, // Affinity Photo's `.afphoto` opens by its content but isn't listed: Finder shouldn't offer a // vector app for a raster editor's documents. - reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/vnd.affinity", false), + reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/x-affinity", false), // Photoshop's large document format: read like a PSD (its merged image), never written. reader("psb", "PSB", &["psb"], "image/x-psb", true), // PostScript programs, read as EPS files are; Print to PostScript writes them, exports don't. @@ -702,10 +702,11 @@ pub const OPEN_EXTS: &[&str] = &[ "afpub", ]; -/// Extensions in [`OPEN_EXTS`] the desktop packaging doesn't associate with VectorCraft: Photoshop -/// documents belong to raster editors, so Finder and file managers don't offer a vector app for -/// them, and the packaging declares no `.epsf`, `.ps` or Targa type (File › Open and Place still -/// read them all). +/// Extensions in [`OPEN_EXTS`] the packaging declares no type for: Photoshop documents belong to +/// raster editors, so Finder and file managers don't offer a vector app for them, and there is no +/// `.epsf`, `.ps` or Targa type (File › Open and Place still read them all). On Linux the desktop +/// entry's EPS type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft +/// for it. pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "epsf", "ps", "tga", "vda", "icb", "vst"]; /// The extension that picks each writable format when exporting (the format's first; PNG-8 shares diff --git a/docs/development.md b/docs/development.md index fe660a69a..c74300ff0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -254,7 +254,7 @@ other `sv-*` locale resolve to it), Georgian (`ka`, complete; `ka-GE` and `ka_GE ## Vendor names gate -`cargo xtask brands` (part of `cargo xtask ci`) fails when user-visible text names another vendor's products or company: string literals in Rust sources (command labels and params docs, menus, panels, MCP tool definitions), `Cargo.toml` descriptions and packaging files. Comments and test code are not checked. Say "the reference app" or name the feature itself. A line that must keep an old name, such as an alias that files or preferences from earlier versions still use, carries a `brand-ok` comment. +`cargo xtask brands` (part of `cargo xtask ci`) fails when user-visible text names another vendor's products or company: string literals in Rust sources (command labels and params docs, menus, panels, MCP tool definitions), `Cargo.toml` descriptions and packaging files. Comments, test files and `#[cfg(test)]` modules are not checked. Say "the reference app" or name the feature itself. A line that must keep an old name, such as an alias that files or preferences from earlier versions still use, or a name another project defines, such as a shared MIME type, carries a `brand-ok` comment. In the Linux desktop entry, whose comments take whole lines, the comment goes on the `#` line just above it; `MARK_ABOVE` in `xtask/src/brands.rs` lists the files that accept a mark there. ## Robustness: VectorCraft never crashes diff --git a/docs/releasing.md b/docs/releasing.md index 6c3400908..f3bcac657 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -159,6 +159,13 @@ to the target triple, so host build scripts aren't affected. `.tar.gz` tree (`bin/`, `share/`). The packages install both programs, the desktop entry, the AppStream metainfo, the MIME type for VectorCraft documents, the icons and the licence files. +**Document types.** The desktop entry's `MimeType` lists every format File › Open reads but PSD, +PSB, `.ps` PostScript and Targa, so file managers list VectorCraft under Open With for them. +`ai.storyteller.vectorcraft.mime.xml` declares the types that shared-mime-info lacks: VectorCraft +documents and templates, `.ait` templates and Affinity files (`application/x-affinity`). An `.af` +file gets the Affinity type by its content, and `.afdesign` and `.afpub` files by their extension. +A test keeps both files in step with the engine's `OPEN_EXTS`. + The jobs run on `ubuntu-22.04`, the oldest GitHub-hosted image, so the binaries only need glibc 2.35 or newer: Ubuntu 22.04+, Debian 12+, Fedora 36+ and RHEL 10. Moving the job to a newer image raises that floor, so do it deliberately. After packaging, the job prints the `.deb`'s diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f33387487..9d26367ba 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -424,6 +424,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Artboard Escape routing:** Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests_distortkeys.rs`. - **Artboard deletion keys:** Delete/Backspace delete selected boards in one Undo step, retaining the original first board in the current order when all are selected, as in the public [Delete artboards guide](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html). The last board and active drags stay protected; neither key clears selected artwork. Headless shortcut regressions in `tests_distortkeys.rs` cover reordered boards, artwork, saved state and Undo/Redo. - **Linux RPM packaging:** 64-bit runtime capabilities replace Fedora-specific required package names (#1275); glibc 2.35, libm and libgcc_s are declared alongside dynamically loaded xkbcommon and Vulkan/EGL. Generated x86_64/aarch64 metadata and isolated synthetic-provider resolution checked; real-distro install/start-up QA remains pending. +- **Linux file associations:** the desktop entry lists the MIME type of every format File › Open reads but PSD, PSB, `.ps` PostScript and Targa, `.ai`, `.ait` and EPS included, and the MIME package declares the types shared-mime-info lacks (VectorCraft documents and templates, `.ait`, Affinity), so file managers list VectorCraft under Open With for them, as Finder does on macOS. `the_linux_desktop_entry_opens_every_readable_format` in `apps/vectorcraft/src/main.rs` checks both files against `OPEN_EXTS`. On Debian 13, Ubuntu 24.04 and Fedora 42, `desktop-file-validate` accepts the desktop entry, `update-mime-database` accepts the MIME package, and `gio` registers the app for every one of these types. - **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction. - **Automation:** diff --git a/packaging/linux/ai.storyteller.vectorcraft.desktop b/packaging/linux/ai.storyteller.vectorcraft.desktop index af3455300..29d3ec84f 100644 --- a/packaging/linux/ai.storyteller.vectorcraft.desktop +++ b/packaging/linux/ai.storyteller.vectorcraft.desktop @@ -11,4 +11,6 @@ StartupNotify=true StartupWMClass=ai.storyteller.vectorcraft Categories=Graphics;2DGraphics;VectorGraphics; Keywords=vector;illustration;svg;pdf;drawing;bezier; -MimeType=image/svg+xml;application/pdf; +# MimeType lists the formats File › Open reads that Info.plist.in declares for macOS. The type of .ai +# files is shared-mime-info's application/illustrator (brand-ok). +MimeType=image/svg+xml;application/pdf;application/x-vectorcraft;image/svg+xml-compressed;application/illustrator;application/x-ai-template;image/png;image/jpeg;image/gif;image/webp;image/tiff;image/bmp;application/x-vectorcraft-template;image/vnd.dxf;image/emf;image/wmf;image/x-eps;application/x-affinity; diff --git a/packaging/linux/ai.storyteller.vectorcraft.mime.xml b/packaging/linux/ai.storyteller.vectorcraft.mime.xml index be4dcc220..eb6db8ce2 100644 --- a/packaging/linux/ai.storyteller.vectorcraft.mime.xml +++ b/packaging/linux/ai.storyteller.vectorcraft.mime.xml @@ -1,3 +1,34 @@ + + + VectorCraft document + + + + + + VectorCraft template + + + + + PDF-compatible .ait template + + + + + + Affinity document + + + + + + + + diff --git a/xtask/src/brands.rs b/xtask/src/brands.rs index 9e722aa9e..1d3b8f430 100644 --- a/xtask/src/brands.rs +++ b/xtask/src/brands.rs @@ -10,8 +10,9 @@ //! * every text file under `packaging/`, and under `docs/` except Markdown pages: Markdown docs //! (like `README.md` and `ROADMAP.md`) may name the reference app and compare against it. //! -//! A line that has to keep an old name (an alias that old files or preferences still use) says -//! `brand-ok` in a comment on that line. +//! A line that has to keep an old name (an alias that old files or preferences still use) or a +//! name another project defines (a shared MIME type) says `brand-ok` in a comment on that line or, +//! in a file listed in `MARK_ABOVE`, on a `#` comment line just above it. use std::path::Path; @@ -42,6 +43,10 @@ const SELF: &str = "xtask/src/brands.rs"; /// The cursor manifest, which designers read. const CURSOR_MANIFEST: &str = "crates/tools/src/cursors.toml"; +/// Packaging files whose comments take whole lines (a desktop entry has no end-of-line comments): +/// in these, a `#` line marked `brand-ok` also covers the line just below it. +const MARK_ABOVE: &[&str] = &["packaging/linux/ai.storyteller.vectorcraft.desktop"]; + /// A denied name found in a file. #[derive(Debug, PartialEq)] pub struct Hit { @@ -77,8 +82,8 @@ pub fn scan(root: &Path, files: &[String]) -> Vec { let Ok(text) = std::fs::read_to_string(root.join(path)) else { continue }; let found = match kind { Kind::Rust => scan_rust(&text), - Kind::Manifest => scan_lines(&text, |l| l.trim_start().starts_with("description")), - Kind::Text => scan_lines(&text, |_| true), + Kind::Manifest => scan_lines(&text, |l| l.trim_start().starts_with("description"), false), + Kind::Text => scan_lines(&text, |_| true, MARK_ABOVE.contains(&path.as_str())), }; hits.extend(found.into_iter().map(|(line, term, context)| Hit { path: path.clone(), line, term, context })); } @@ -110,11 +115,16 @@ fn is_test_path(path: &str) -> bool { file == "tests.rs" || file.starts_with("tests_") || file.ends_with("_tests.rs") || parts.any(|d| d == "tests" || d == "benches") } -/// (line, term, line text) for each denied name on the lines `keep` selects. -fn scan_lines(text: &str, keep: impl Fn(&str) -> bool) -> Vec<(usize, &'static str, String)> { - text.lines() +/// (line, term, line text) for each denied name on the lines `keep` selects, but those marked +/// `brand-ok` on the line or, with `mark_above`, on a `#` comment line just above. +fn scan_lines(text: &str, keep: impl Fn(&str) -> bool, mark_above: bool) -> Vec<(usize, &'static str, String)> { + let lines: Vec<&str> = text.lines().collect(); + let marked_above = + |i: usize| mark_above && i.checked_sub(1).and_then(|j| lines.get(j)).is_some_and(|l| l.trim_start().starts_with('#') && l.contains(ALLOW)); + lines + .iter() .enumerate() - .filter(|(_, l)| keep(l) && !l.contains(ALLOW)) + .filter(|&(i, l)| keep(l) && !l.contains(ALLOW) && !marked_above(i)) .flat_map(|(i, l)| terms_in(l, DENY).into_iter().map(move |t| (i + 1, t, l.trim().to_string()))) .collect() } @@ -335,11 +345,16 @@ line Pantone"; } #[test] fn docs_packaging_and_manifests() { - let lines = |text: &str, keep: fn(&str) -> bool| -> Vec<(usize, &'static str)> { - scan_lines(text, keep).into_iter().map(|(l, t, _)| (l, t)).collect() + let scan = |text: &str, keep: fn(&str) -> bool, mark_above: bool| -> Vec<(usize, &'static str)> { + scan_lines(text, keep, mark_above).into_iter().map(|(l, t, _)| (l, t)).collect() }; + let lines = |text: &str, keep: fn(&str) -> bool| scan(text, keep, false); assert_eq!(lines("Keywords=vector;illustrator;\n", |_| true), [(1, "illustrator")]); assert_eq!(lines("Like Adobe apps\nnot here: Adobe # brand-ok\n", |_| true), [(1, "adobe")]); + let desktop = "# shared type (brand-ok)\nMimeType=application/illustrator;\n# a comment\nMimeType=application/illustrator;\n"; + assert_eq!(scan(desktop, |_| true, true), [(4, "illustrator")], "a `#` line marks the line below it only"); + assert_eq!(lines(desktop, |_| true), [(2, "illustrator"), (4, "illustrator")], "only `MARK_ABOVE` files take a mark from the line above"); + assert_eq!(scan("x = 1 # brand-ok\nIllustrator\n", |_| true, true), [(2, "illustrator")], "a code line's comment marks that line only"); let manifest = "name = \"adobe-thing\"\ndescription = \"Illustrator-style UI\"\n"; assert_eq!(lines(manifest, |l| l.trim_start().starts_with("description")), [(2, "illustrator")]); } @@ -373,12 +388,26 @@ line Pantone"; } file("docs/x.md", "Works like Photoshop.\n"); file("docs/x.txt", "Works like Photoshop.\n"); file("packaging/x.desktop", "Keywords=vector;illustrator;\n"); + let marked = "# shared type (brand-ok)\nMimeType=application/illustrator;\n"; + file(MARK_ABOVE[0], marked); + file("packaging/x.yaml", marked); file("README.md", "Adobe trademark notice\n"); - let files: Vec = ["crates/x/src/lib.rs", "docs/x.md", "docs/x.txt", "packaging/x.desktop", "README.md"].map(String::from).to_vec(); + let files: Vec = + ["crates/x/src/lib.rs", "docs/x.md", "docs/x.txt", "packaging/x.desktop", MARK_ABOVE[0], "packaging/x.yaml", "README.md"] + .map(String::from) + .to_vec(); let hits = scan(&dir, &files); let _ = std::fs::remove_dir_all(&dir); let found: Vec<(&str, usize, &str)> = hits.iter().map(|h| (h.path.as_str(), h.line, h.term)).collect(); - assert_eq!(found, [("crates/x/src/lib.rs", 1, "illustrator"), ("docs/x.txt", 1, "photoshop"), ("packaging/x.desktop", 1, "illustrator")]); + assert_eq!( + found, + [ + ("crates/x/src/lib.rs", 1, "illustrator"), + ("docs/x.txt", 1, "photoshop"), + ("packaging/x.desktop", 1, "illustrator"), + ("packaging/x.yaml", 2, "illustrator") + ] + ); } #[test]