Merge pull request #2821 from dotCooCoo/feat/linux-desktop-ai-eps-types

Linux: file managers offer VectorCraft for .ai, EPS and every other format Finder offers it for
This commit is contained in:
BFlat
2026-10-19 22:40:08 +07:00
committed by GitHub
8 changed files with 202 additions and 19 deletions
+112
View File
@@ -1090,4 +1090,116 @@ mod tests {
assert!(types > 1 && plist.matches("<key>LSHandlerRank</key>").count() == types, "every document type has a rank");
assert_eq!(plist.matches("<string>Owner</string>").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 `<magic>` 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#"<magic><match type="string" offset="0" value="\x00\xff\x4b\x41"><match type="string" offset="8" value="nsrP"/></match></magic>"#,
)];
/// 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 `<magic>`
// rule (empty when it has none) in the form `LINUX_BY_CONTENT` gives.
let declared: Vec<(&str, Vec<&str>, String)> = mime
.split("<mime-type type=\"")
.skip(1)
.map(|block| {
let body = block.split("</mime-type>").next().unwrap_or_default();
let globs = body.split("<glob pattern=\"*.").skip(1).filter_map(|g| g.split('"').next()).collect();
let magic = match (body.find("<magic"), body.find("</magic>")) {
(Some(start), Some(end)) => body[start..end + "</magic>".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}");
}
}
}
}
+6 -5
View File
@@ -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
+1 -1
View File
@@ -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
+7
View File
@@ -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
+1
View File
@@ -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:**
@@ -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;
@@ -1,3 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- This package declares the types shared-mime-info lacks for the formats in the desktop entry's
MimeType line. The desktop entry lists these types with the shared ones; a test in
apps/vectorcraft/src/main.rs checks both files. -->
<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
<mime-type type="application/x-vectorcraft">
<comment>VectorCraft document</comment>
<icon name="ai.storyteller.vectorcraft"/>
<glob pattern="*.vectorcraft"/>
<glob pattern="*.drawcraft"/>
</mime-type>
<mime-type type="application/x-vectorcraft-template">
<comment>VectorCraft template</comment>
<sub-class-of type="application/x-vectorcraft"/>
<glob pattern="*.vctemplate"/>
</mime-type>
<mime-type type="application/x-ai-template">
<comment>PDF-compatible .ait template</comment>
<sub-class-of type="application/pdf"/>
<glob pattern="*.ait"/>
</mime-type>
<!-- An .af file gets this type only when it starts with the Affinity container signature and
the document kind. -->
<mime-type type="application/x-affinity">
<comment>Affinity document</comment>
<glob pattern="*.afdesign"/>
<glob pattern="*.afpub"/>
<magic>
<match type="string" offset="0" value="\x00\xff\x4b\x41">
<match type="string" offset="8" value="nsrP"/>
</match>
</magic>
</mime-type>
</mime-info>
+41 -12
View File
@@ -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<Hit> {
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<String> = ["crates/x/src/lib.rs", "docs/x.md", "docs/x.txt", "packaging/x.desktop", "README.md"].map(String::from).to_vec();
let files: Vec<String> =
["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]