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]