Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
4 changes: 3 additions & 1 deletion dist/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
4 changes: 3 additions & 1 deletion dist/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
19 changes: 19 additions & 0 deletions dist/preferences.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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()),
],
};

Expand Down
12 changes: 12 additions & 0 deletions dist/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
21 changes: 19 additions & 2 deletions docs/third-party-notices.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <target>` 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.
1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
176 changes: 176 additions & 0 deletions src-tauri/build.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<String, (String, BTreeSet<String>)> = 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<String> =
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::<Vec<_>>().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<PathBuf> {
let dir = manifest.parent().unwrap_or(manifest);
let mut files: BTreeSet<PathBuf> = 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)
}
1 change: 1 addition & 0 deletions src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*" }]
Expand Down
11 changes: 11 additions & 0 deletions src-tauri/permissions/autogenerated/third_party_licenses.toml
Original file line number Diff line number Diff line change
@@ -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"]
25 changes: 25 additions & 0 deletions src-tauri/src/licenses.rs
Original file line number Diff line number Diff line change
@@ -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"
);
}
}
}
4 changes: 3 additions & 1 deletion src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

mod document;
mod licenses;
mod markdown;
mod navigation;
mod preferences;
Expand Down Expand Up @@ -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,
Expand Down
Loading