From f710b7df322da302f8de6d00c00c77989f683601 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Mon, 12 Oct 2026 13:00:46 +0200 Subject: [PATCH] fix: ship the licences of the Rust libraries inside MemoryStick build.rs collects the licence texts of every crate linked into the executable for the target being built, from cargo metadata run locked and offline, and embeds them. Preferences > About shows them under Third-party licences. --- README.md | 5 +- dist/i18n/en.json | 4 +- dist/i18n/fr.json | 4 +- dist/preferences.js | 19 ++ dist/styles.css | 12 ++ docs/third-party-notices.md | 21 ++- src-tauri/Cargo.toml | 1 + src-tauri/build.rs | 176 ++++++++++++++++++ src-tauri/capabilities/default.json | 1 + .../autogenerated/third_party_licenses.toml | 11 ++ src-tauri/src/licenses.rs | 25 +++ src-tauri/src/main.rs | 4 +- 12 files changed, 277 insertions(+), 6 deletions(-) create mode 100644 src-tauri/permissions/autogenerated/third_party_licenses.toml create mode 100644 src-tauri/src/licenses.rs diff --git a/README.md b/README.md index c4dc27b..9ebd7ea 100644 --- a/README.md +++ b/README.md @@ -206,5 +206,8 @@ version. The full text is in [LICENSE](LICENSE). The libraries in `dist/vendor/` keep their own licences: BSD-3-Clause for highlight.js, MIT for KaTeX and Mermaid, OFL-1.1 for the KaTeX fonts, GPL-3.0-or-later for the Colony themes, and those of the packages Mermaid's -build contains. The fonts in `dist/fonts/` are under OFL-1.1. See +build contains. The fonts in `dist/fonts/` are under OFL-1.1. The Rust +libraries compiled into the executable keep theirs too, mostly MIT, +Apache-2.0, BSD and ISC, and Preferences > About > Third-party licences +shows their texts. See [docs/third-party-notices.md](docs/third-party-notices.md). diff --git a/dist/i18n/en.json b/dist/i18n/en.json index 3180480..6edd4cc 100644 --- a/dist/i18n/en.json +++ b/dist/i18n/en.json @@ -77,5 +77,7 @@ "preferences_about_desc": "A Markdown viewer from Project Colony.", "preferences_version": "Version {detail}", "preferences_license": "Free software under the GNU General Public License, version 3 or later.", - "preferences_source": "Source code" + "preferences_source": "Source code", + "preferences_third_party": "Third-party licences", + "preferences_third_party_desc": "The Rust libraries compiled into MemoryStick, each with the licence text it ships." } diff --git a/dist/i18n/fr.json b/dist/i18n/fr.json index 83dd124..017823a 100644 --- a/dist/i18n/fr.json +++ b/dist/i18n/fr.json @@ -77,5 +77,7 @@ "preferences_about_desc": "Une visionneuse Markdown du projet Colony.", "preferences_version": "Version {detail}", "preferences_license": "Logiciel libre sous licence publique générale GNU, version 3 ou ultérieure.", - "preferences_source": "Code source" + "preferences_source": "Code source", + "preferences_third_party": "Licences tierces", + "preferences_third_party_desc": "Les bibliothèques Rust compilées dans MemoryStick, chacune avec le texte de licence qu'elle fournit." } diff --git a/dist/preferences.js b/dist/preferences.js index 515b658..8f61e8c 100644 --- a/dist/preferences.js +++ b/dist/preferences.js @@ -155,6 +155,23 @@ function preview() { h('span.hljs-comment', {}, '// ' + t('preferences_section_preview')), '\n println!(', h('span.hljs-string', {}, '"MemoryStick"'), ');\n}'))); } +// ===== About ===== + +// The licence texts of the Rust libraries, asked of the app the first time +// their section opens: a promise while that is pending, then the text. +let licenses = null; + +function licenseText() { + if (licenses === null) { + licenses = invoke('third_party_licenses') + .then((text) => { licenses = text; renderPreferences(); }) + .catch((err) => { licenses = null; showToast(t('error_interface', err), true); }); + } + // Focusable, so the keyboard can scroll it. + return typeof licenses === 'string' + && h('pre.licenses', { tabindex: '0', 'aria-label': t('preferences_third_party') }, licenses); +} + // ===== Categories ===== const PAGES = { @@ -205,6 +222,8 @@ const PAGES = { onclick: () => invoke('plugin:opener|open_url', { url: 'https://github.com/Project-Colony/MemoryStick' }) .catch((err) => showToast(t('error_cannot_open_link', err), true)), }, t('preferences_source')), + section('third-party', t('preferences_third_party'), t('preferences_third_party_desc'), + expanded.has('third-party') && licenseText()), ], }; diff --git a/dist/styles.css b/dist/styles.css index bb635de..2e72c94 100644 --- a/dist/styles.css +++ b/dist/styles.css @@ -522,6 +522,18 @@ body.drag-over #drop-hint .hint-title { color: var(--colony-accent-blue); } cursor: pointer; } .link-button:hover { background: var(--colony-btn-hover); } +.licenses { + max-height: 360px; + margin: 0; + padding: 12px; + overflow: auto; + border: 1px solid var(--colony-border-subtle); + border-radius: 8px; + font-family: var(--mono); + font-size: calc(12px * var(--font-scale)); + white-space: pre-wrap; + overflow-wrap: anywhere; +} /* ===== Toast ===== */ #toast { diff --git a/docs/third-party-notices.md b/docs/third-party-notices.md index 91d90f9..6bf3d6c 100644 --- a/docs/third-party-notices.md +++ b/docs/third-party-notices.md @@ -34,5 +34,22 @@ MemoryStick's GPL-3.0-or-later. elkjs 0.12 and later are available under EPL-2.0 OR GPL-3.0-or-later, so the hold can be lifted once a Mermaid release depends on elkjs 0.12 or later. -The Rust crates compiled into the executable are listed in -[`src-tauri/Cargo.lock`](../src-tauri/Cargo.lock). +## Rust libraries + +The executable also links the Rust crates its code is built on: Tauri, +comrak, serde and the libraries they depend on, about 300 of them, mostly +under MIT, Apache-2.0, BSD or ISC. Their licence texts travel inside every +build, and Preferences > About > Third-party licences shows them. + +`src-tauri/build.rs` collects them at build time. It asks +`cargo metadata --locked --offline --filter-platform ` for the +dependency graph, follows the normal dependencies of `memorystick` (build +and dev dependencies run on the build machine and are not linked in), and +reads every `LICENSE*`, `LICENCE*`, `UNLICENSE*`, `COPYING*`, `COPYRIGHT*` +and `NOTICE*` file, plus any declared `license-file`, at the top of each +crate Cargo downloaded to build it. Identical texts are kept once, under +the list of crates that ship them. A crate that ships no licence file is +listed with the SPDX expression from its manifest, whose standard text is +at https://spdx.org/licenses/. The result is embedded in the executable, +so each platform's build carries the notices of exactly the crates it +links, and the build needs no network beyond what Cargo already fetched. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ac09d9c..07642ae 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -10,6 +10,7 @@ rust-version = "1.90" [build-dependencies] tauri-build = "2.7.1" +serde_json = "1.0.151" [dependencies] tauri = "2.12.2" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index c579f50..d19cdc8 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,4 +1,14 @@ +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::Value; + fn main() { + write_rust_licenses(); + // Declaring the app's commands turns on Tauri's permission check for // them, so they answer only the pages that capabilities/ allows: the // app's own page, never a page from anywhere else. @@ -7,7 +17,173 @@ fn main() { "open_document", "load_file", "save_preferences", + "third_party_licenses", ]), )) .expect("tauri-build failed"); } + +/// File names, lowercased, that hold a library's licence or notices. +const LICENCE_FILES: [&str; 6] = [ + "license", + "licence", + "unlicense", + "copying", + "copyright", + "notice", +]; + +/// Writes $OUT_DIR/rust-licenses.txt for src/licenses.rs: the licence +/// texts of every library linked into the executable for this target, which +/// MIT, BSD, Apache-2.0 and the like require a binary to carry. The texts +/// come from the packages Cargo already downloaded to build them, so this +/// never reaches the network. +fn write_rust_licenses() { + println!("cargo:rerun-if-changed=Cargo.lock"); + let var = |name: &str| env::var(name).unwrap_or_else(|_| panic!("{name} is not set")); + let target = var("TARGET"); + + let output = Command::new(var("CARGO")) + .args(["metadata", "--format-version", "1", "--locked", "--offline"]) + .args(["--filter-platform", &target, "--manifest-path"]) + .arg(Path::new(&var("CARGO_MANIFEST_DIR")).join("Cargo.toml")) + .output() + .expect("cannot run cargo metadata"); + assert!( + output.status.success(), + "cargo metadata failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let metadata: Value = + serde_json::from_slice(&output.stdout).expect("cargo metadata printed no JSON"); + + let text = rust_licenses(&metadata, &var("CARGO_PKG_VERSION"), &target); + fs::write(Path::new(&var("OUT_DIR")).join("rust-licenses.txt"), text) + .expect("cannot write rust-licenses.txt"); +} + +/// The text: one block per distinct licence text, headed by the libraries +/// that ship it, then the libraries that ship no licence file at all. +fn rust_licenses(metadata: &Value, version: &str, target: &str) -> String { + let packages: HashMap<&str, &Value> = slice(&metadata["packages"]) + .iter() + .map(|p| (p["id"].as_str().unwrap_or_default(), p)) + .collect(); + let nodes: HashMap<&str, &Value> = slice(&metadata["resolve"]["nodes"]) + .iter() + .map(|n| (n["id"].as_str().unwrap_or_default(), n)) + .collect(); + + // Normal dependencies only, followed from MemoryStick: build and dev + // dependencies run on the build machine and are not linked in. + let mut linked = BTreeSet::new(); + let mut todo = vec![metadata["resolve"]["root"] + .as_str() + .expect("cargo metadata names no root package")]; + while let Some(id) = todo.pop() { + for dep in nodes.get(id).map_or(&[][..], |n| slice(&n["deps"])) { + let normal = slice(&dep["dep_kinds"]).iter().any(|k| k["kind"].is_null()); + let pkg = dep["pkg"].as_str().unwrap_or_default(); + if normal && linked.insert(pkg) { + todo.push(pkg); + } + } + } + + // Keyed by the words alone: copies of one licence that differ only in + // line breaks, indentation or trailing spaces count as one text, shown + // as the first library to ship it does. + let mut by_text: BTreeMap)> = BTreeMap::new(); + let mut without_file = BTreeSet::new(); + for id in linked { + let package = packages[id]; + let str_of = |key: &str| package[key].as_str().unwrap_or_default(); + let label = match str_of("license") { + "" => format!("{}@{}", str_of("name"), str_of("version")), + expr => format!("{}@{} ({expr})", str_of("name"), str_of("version")), + }; + let texts: BTreeSet = + licence_files(Path::new(str_of("manifest_path")), str_of("license_file")) + .iter() + .filter_map(|file| fs::read(file).ok()) + .map(|bytes| normalise(&String::from_utf8_lossy(&bytes))) + .filter(|text| !text.is_empty()) + .collect(); + if texts.is_empty() { + without_file.insert(label); + } else { + for text in texts { + let words = text.split_whitespace().collect::>().join(" "); + let (_, libs) = by_text + .entry(words) + .or_insert_with(|| (text, BTreeSet::new())); + libs.insert(label.clone()); + } + } + } + + let mut blocks: Vec<_> = by_text + .into_values() + .map(|(text, libs)| (libs, text)) + .collect(); + blocks.sort(); + let rule = |c: &str| c.repeat(72); + let mut out = format!( + "The Rust libraries compiled into MemoryStick {version} for {target}.\n\ + Each list of libraries is followed by the licence text they ship.\n" + ); + for (libs, text) in blocks { + out += &format!("\n{}\n", rule("=")); + for lib in libs { + out += &format!("{lib}\n"); + } + out += &format!("{}\n\n{text}\n", rule("-")); + } + if !without_file.is_empty() { + out += &format!( + "\n{}\nThese libraries ship no licence file. Each is under the licence its\n\ + manifest names, whose standard text is at https://spdx.org/licenses/.\n{}\n", + rule("="), + rule("-") + ); + for lib in without_file { + out += &format!("{lib}\n"); + } + } + out +} + +/// The licence files at the top of a package, plus its declared +/// license-file. +fn licence_files(manifest: &Path, declared: &str) -> BTreeSet { + let dir = manifest.parent().unwrap_or(manifest); + let mut files: BTreeSet = fs::read_dir(dir) + .into_iter() + .flatten() + .flatten() + .map(|entry| entry.path()) + .filter(|path| { + let name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_lowercase(); + path.is_file() && LICENCE_FILES.iter().any(|prefix| name.starts_with(prefix)) + }) + .collect(); + if !declared.is_empty() { + files.insert(dir.join(declared)); + } + files +} + +/// The text without carriage returns, trailing spaces or blank lines at +/// either end. +fn normalise(text: &str) -> String { + let lines: Vec<&str> = text.lines().map(str::trim_end).collect(); + lines.join("\n").trim_matches('\n').to_owned() +} + +fn slice(value: &Value) -> &[Value] { + value.as_array().map_or(&[], Vec::as_slice) +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 3c16669..bcefddb 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -8,6 +8,7 @@ "allow-open-document", "allow-load-file", "allow-save-preferences", + "allow-third-party-licenses", { "identifier": "opener:allow-open-url", "allow": [{ "url": "https://*" }, { "url": "http://*" }, { "url": "mailto:*" }] diff --git a/src-tauri/permissions/autogenerated/third_party_licenses.toml b/src-tauri/permissions/autogenerated/third_party_licenses.toml new file mode 100644 index 0000000..b64a505 --- /dev/null +++ b/src-tauri/permissions/autogenerated/third_party_licenses.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-third-party-licenses" +description = "Enables the third_party_licenses command without any pre-configured scope." +commands.allow = ["third_party_licenses"] + +[[permission]] +identifier = "deny-third-party-licenses" +description = "Denies the third_party_licenses command without any pre-configured scope." +commands.deny = ["third_party_licenses"] diff --git a/src-tauri/src/licenses.rs b/src-tauri/src/licenses.rs new file mode 100644 index 0000000..da561cb --- /dev/null +++ b/src-tauri/src/licenses.rs @@ -0,0 +1,25 @@ +//! The licences of the Rust libraries compiled into MemoryStick, which +//! their MIT, BSD, Apache-2.0 and similar terms require the executable to +//! carry. build.rs gathers them for the target being built. + +const TEXT: &str = include_str!(concat!(env!("OUT_DIR"), "/rust-licenses.txt")); + +/// The text Preferences > About shows under Third-party licences. +#[tauri::command] +pub fn third_party_licenses() -> &'static str { + TEXT +} + +#[cfg(test)] +mod tests { + #[test] + fn names_the_libraries_memorystick_is_built_on() { + for name in ["tauri", "comrak", "serde"] { + let label = format!("{name}@"); + assert!( + super::TEXT.lines().any(|line| line.starts_with(&label)), + "{name} is missing from the third-party licences" + ); + } + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 8fadde3..433461e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,6 +2,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod document; +mod licenses; mod markdown; mod navigation; mod preferences; @@ -55,7 +56,8 @@ fn main() { .invoke_handler(tauri::generate_handler![ document::open_document, document::load_file, - preferences::save_preferences + preferences::save_preferences, + licenses::third_party_licenses ]) .on_window_event(document::on_window_event) // The window is built here rather than from tauri.conf.json alone,