Collect application resources for #433 (#833)

This commit is contained in:
Nathan Dane authored and GitHub committed 2026-10-14 00:49:32 -04:00
1 parent d5ff5ea1a5
commit 9f76cca8b4
107 files changed
+2940 -2660

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
//! Regenerate all exports of the shared app mark together. Packaging copies these files.
use std::path::Path;
#[path = "../../assets/icons/app/shared/source/app_icon.rs"]
mod app_icon;
#[path = "../../assets/icons/app/shared/source/windows_ico.rs"]
mod windows_ico;
pub fn run(root: &Path) -> Result<(), String> {
let stage = root.join("target/resources/app-icons");
std::fs::create_dir_all(&stage).map_err(|e| e.to_string())?;
let mut exports = Vec::new();
for size in [16, 24, 32, 48, 64, 128, 256, 512] {
let png = stage.join(format!("{size}.png"));
draw(&png, size)?;
exports.push((png, format!("linux/exports/hicolor/{size}x{size}/apps/ai.storyteller.solidcraft.png")));
}
let installer = stage.join("solidcraft.ico");
draw(&installer, 256)?;
exports.push((installer, "windows/exports/solidcraft.ico".into()));
let executable = stage.join("solidcraft-executable.ico");
std::fs::write(&executable, windows_ico::encode().map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
exports.push((executable, "windows/exports/solidcraft-executable.ico".into()));
// Finish generation before replacing any canonical exports.
for (source, relative) in exports {
let target = root.join("assets/icons/app").join(relative);
if let Some(dir) = target.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
std::fs::copy(source, target).map_err(|e| e.to_string())?;
}
Ok(())
}
fn draw(path: &Path, size: usize) -> Result<(), String> {
let mut c = crate::cargo();
c.args(["run", "-q", "--profile", "ci", "-p", "solidcraft-cli", "--", "icon", "--size", &size.to_string(), "--out"]).arg(path);
crate::run(c, "solidcraft-cli icon")
}
+92 -25
View File
@@ -1,40 +1,107 @@
//! `cargo xtask assets`: every non-code asset in the repository must have a row in
//! ATTRIBUTION.md (author, source, licence). Icons are drawn in code and need no file.
//! Asset provenance gate. ATTRIBUTION.md is the single inventory of collected files.
use std::collections::BTreeSet;
use std::path::Path;
const ASSET_EXT: &[&str] =
&["png", "jpg", "jpeg", "svg", "ico", "icns", "ttf", "otf", "woff", "woff2", "gif", "bmp", "webp", "wav", "mp3", "stl", "step", "stp", "obj"];
const SKIP_DIRS: &[&str] = &["target", ".git", "plan", "dist"];
const ASSET_EXT: &[&str] = &[
"png",
"jpg",
"jpeg",
"svg",
"ico",
"icns",
"car",
"vectorcraft",
"ai",
"psd",
"afdesign",
"fig",
"sketch",
"ttf",
"otf",
"woff",
"woff2",
"gif",
"bmp",
"webp",
"wav",
"mp3",
"mp4",
"stl",
"step",
"stp",
"obj",
];
fn walk(dir: &Path, root: &Path, out: &mut Vec<String>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for e in rd.flatten() {
fn walk(dir: &Path, root: &Path, out: &mut Vec<String>) -> Result<(), String> {
for e in std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))? {
let e = e.map_err(|e| e.to_string())?;
let name = e.file_name().to_string_lossy().into_owned();
if name.starts_with('.') || name.starts_with("target") || matches!(name.as_str(), "plan" | "dist" | "node_modules") {
continue;
}
let p = e.path();
let name = e.file_name().to_string_lossy().to_string();
if p.is_dir() {
if !SKIP_DIRS.contains(&name.as_str()) && !name.starts_with('.') {
walk(&p, root, out);
let kind = e.file_type().map_err(|e| e.to_string())?;
if kind.is_symlink() {
if p.starts_with(root.join("assets")) {
return Err(format!("resource symlink: {}", p.display()));
}
} else if let Some(ext) = p.extension().map(|x| x.to_string_lossy().to_ascii_lowercase())
&& ASSET_EXT.contains(&ext.as_str())
&& let Ok(rel) = p.strip_prefix(root)
{
out.push(rel.to_string_lossy().replace('\\', "/"));
} else if kind.is_dir() {
walk(&p, root, out)?;
} else if kind.is_file() {
out.push(p.strip_prefix(root).map_err(|e| e.to_string())?.to_string_lossy().replace('\\', "/"));
}
}
Ok(())
}
fn needs_attribution(path: &str) -> bool {
// Include code masters, native work, catalogs, metadata and licence texts in the collection.
if path.starts_with("assets/") {
return !path.ends_with(".md");
}
Path::new(path).extension().is_some_and(|ext| ASSET_EXT.contains(&ext.to_string_lossy().to_ascii_lowercase().as_str()))
}
fn missing<'a>(files: &'a [String], attribution: &str) -> Vec<&'a str> {
let rows: BTreeSet<_> =
attribution.lines().filter(|line| line.starts_with('|')).filter_map(|line| line.split('|').nth(1)).map(str::trim).collect();
files.iter().map(String::as_str).filter(|path| needs_attribution(path) && !rows.contains(path)).collect()
}
pub fn run(root: &Path) -> Result<(), String> {
let attribution = std::fs::read_to_string(root.join("ATTRIBUTION.md")).map_err(|e| format!("ATTRIBUTION.md: {e}"))?;
let mut files = Vec::new();
walk(root, root, &mut files);
walk(root, root, &mut files)?;
files.sort();
let missing: Vec<&String> = files.iter().filter(|f| !attribution.contains(f.as_str())).collect();
if missing.is_empty() {
println!("assets: {} asset file(s), all attributed", files.len());
Ok(())
} else {
Err(format!("assets missing from ATTRIBUTION.md:\n{}", missing.iter().map(|m| format!(" {m}")).collect::<Vec<_>>().join("\n")))
let missing = missing(&files, &attribution);
if !missing.is_empty() {
return Err(format!("assets missing attribution rows:\n{}", missing.iter().map(|path| format!(" {path}")).collect::<Vec<_>>().join("\n")));
}
println!("assets: {} files, all attributed", files.iter().filter(|path| needs_attribution(path)).count());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn collected_code_native_work_and_metadata_require_rows() {
for path in
["assets/icons/source/icon.rs", "assets/work/project.ai", "assets/fonts/OFL.txt", "assets/locales/en.json", "assets/macos/Assets.car"]
{
assert!(needs_attribution(path), "{path}");
}
assert!(needs_attribution("docs/screenshot.png"));
assert!(needs_attribution("design/project.vectorcraft"));
assert!(!needs_attribution("assets/README.md"));
assert!(!needs_attribution("crates/ui/src/widget.rs"));
}
#[test]
fn incidental_mentions_do_not_replace_attribution_rows() {
let files = ["assets/source.svg".to_string(), "assets/README.md".to_string()];
assert_eq!(missing(&files, "Mention assets/source.svg"), ["assets/source.svg"]);
assert!(missing(&files, "| assets/source.svg | author | original | MIT |\n").is_empty());
}
}
+1
View File
@@ -19,6 +19,7 @@ const SNAPSHOT_SIZE: (&str, &str) = ("960", "600");
pub fn run(root: &Path, build: bool) -> Result<(), String> {
let book = root.join("docs/book");
std::fs::copy(root.join("assets/branding/web/source/og.png"), book.join("src/og.png")).map_err(|e| format!("book preview card: {e}"))?;
let mut c = cargo();
c.args(["build", "-q", "--release", "-p", "solidcraft-cli"]);
crate::run(c, "cargo build -p solidcraft-cli")?;
+2 -2
View File
@@ -1,6 +1,6 @@
//! `cargo xtask i18n`: the message catalogs' coverage report, and a way to repair it.
//!
//! The catalogs under `crates/ui-egui/src/locales` are plain JSON maps from a stable message id
//! The catalogs under `assets/locales` are plain JSON maps from a stable message id
//! to one translation. The English catalog is also the lookup table for `Locale::label()`, which
//! translates source strings coming from the engine (command labels, toolbar tabs and panels) by
//! matching their English text. A label the English catalog does not carry therefore stays English
@@ -20,7 +20,7 @@ use std::path::Path;
use serde_json::{Map, Value};
/// Where the catalogs live, and the file the English one must lead.
const DIR: &str = "crates/ui-egui/src/locales";
const DIR: &str = "assets/locales";
/// Catalogs delivered complete: a missing message here fails `--check`. English is the source
/// catalog and is checked implicitly; keep this in step with `COMPLETE` in `ui-egui::i18n`.
+4 -4
View File
@@ -162,15 +162,15 @@ fn render(root: &Path, crates: &[Crate]) -> String {
let font_rule = "=".repeat(80);
out += &format!(
"\n{font_rule}\nBundled fonts (SIL Open Font License, Version 1.1)\n{font_rule}\n\n\
- LiberationSans-Regular.ttf (crates/sketch/fonts/LiberationSans-Regular.ttf)\n \
- LiberationSans-Regular.ttf (assets/fonts/liberation-sans/LiberationSans-Regular.ttf)\n \
Copyright 2012 Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google)\n \
Source: https://github.com/liberationfonts\n\n\
- BIZUDPGothic-Regular.ttf (apps/solidcraft-web/fonts/BIZUDPGothic-Regular.ttf)\n \
- BIZUDPGothic-Regular.ttf (assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf)\n \
Copyright 2022 The BIZ UDGothic Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho)\n \
Source: https://github.com/googlefonts/morisawa-biz-ud-gothic\n\n"
);
let ofl_path = root.join("apps/solidcraft-web/fonts/OFL.txt");
let fallback_path = root.join("crates/sketch/fonts/OFL.txt");
let ofl_path = root.join("assets/fonts/biz-udp-gothic/OFL.txt");
let fallback_path = root.join("assets/fonts/liberation-sans/OFL.txt");
let ofl_text = std::fs::read_to_string(&ofl_path).or_else(|_| std::fs::read_to_string(&fallback_path)).unwrap_or_default();
let ofl_clean = ofl_text.replace("\r\n", "\n").trim().to_string();
if !ofl_clean.is_empty() {
+4 -1
View File
@@ -3,6 +3,7 @@
//! Pure Rust (std, serde_json and sha2). External tools (`cargo`) are invoked through
//! `std::process::Command`.
mod app_icons;
mod assets;
mod book;
mod features;
@@ -22,9 +23,10 @@ usage: cargo xtask <command>
commands:
layers enforce the crate dependency layering (plan/architecture.md §3)
assets check that every non-code asset file is attributed in ATTRIBUTION.md
app-icons regenerate shared app-icon exports for Linux, FreeBSD and Windows
features list SolidCraft's commands by area in docs/features.md (from the registry)
i18n [--check | --add-missing]
report how complete the message catalogs in crates/ui-egui/src/locales are:
report how complete the message catalogs in assets/locales are:
which messages a locale has not translated and which command labels the English
catalog cannot translate (so they stay English everywhere). --check fails when
there is a gap, for CI; --add-missing writes the uncovered command labels into
@@ -64,6 +66,7 @@ fn main() -> ExitCode {
let result = match args.first().map(String::as_str) {
Some("layers") => cmd_layers(),
Some("assets") => assets::run(&root()),
Some("app-icons") => app_icons::run(&root()),
Some("features") => features::run(&root()),
Some("i18n") => i18n::run(&root(), &rest),
Some("oracle") => cmd_oracle(rest.iter().position(|a| *a == "--cli").and_then(|i| rest.get(i + 1)).copied()),