mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -06:00
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:
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user