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,