Releases offer plain programs; the licences are built in
Release assets are now the programs themselves: SolveCraft and solvecraft-cli .exe for Windows,
plain Linux binaries plus the AppImage, a macOS .dmg (SolveCraft.app with an Applications link) and
the universal CLI binary; only the web app stays a zip. The notes template takes {tag}, says to
chmod +x on Linux and macOS, and GitHub adds the change list.
Since no LICENSE or NOTICE file rides along any more, the programs carry them:
THIRD-PARTY-LICENSES.txt (written by `cargo xtask licences` from cargo metadata and each shipped
crate's own licence files, 419 crates; `cargo xtask ci` checks it is current) is embedded with
LICENSE-MIT, LICENSE-APACHE and NOTICE in solvecraft_engine::licences, shown in Help ▸ About ▸
Licences (third-party text laid out by visible rows) and printed by `solvecraft-cli licences`.
Scenario s44_about_licences.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
3a1f05bacf
commit
bf2b64f597
14 files changed
+12283
-53
No files matched your search
+19
-20
@@ -2,17 +2,6 @@ SolveCraft is an open-source parametric 3D CAD application in pure Rust: sketch
|
||||
constrain and dimension the sketch, turn profiles into solids on a feature timeline, and change a
|
||||
parameter to watch the timeline rebuild. It is early, and we'd like your bug reports.
|
||||
|
||||
## Fixed since v0.1.0
|
||||
|
||||
- **Quitting with unsaved changes now asks first** ([#33](https://github.com/bherbruck/solvecraft/issues/33)):
|
||||
"Save changes to <name>?" with Save, Don't Save and Cancel, once per unsaved design, for the
|
||||
title bar's ×, File ▸ Quit, Alt+F4 / Cmd+Q and the window manager. The web app asks through
|
||||
the browser's "leave site?" prompt.
|
||||
- **Web: a new design no longer shows a black screen** in browsers with WebGPU
|
||||
([#34](https://github.com/bherbruck/solvecraft/issues/34)); the viewport shader compiles there now.
|
||||
- **README demo:** a short GIF at the top shows an agent building an enclosure over MCP and then
|
||||
editing its parameters. The full video is attached below.
|
||||
|
||||
## Links
|
||||
|
||||
- **Documentation:** https://bherbruck.github.io/solvecraft/
|
||||
@@ -42,24 +31,34 @@ Everything the UI does is a command with JSON parameters, so an agent can do it
|
||||
`solvecraft-cli mcp` is a Model Context Protocol server: headless, or bridged to the running app
|
||||
(`solvecraft --control 7878`, then `solvecraft-cli mcp --connect 127.0.0.1:7878`) so you can
|
||||
watch the agent model. In Claude Code: `claude mcp add solvecraft -- solvecraft-cli mcp`. The
|
||||
attached video shows an agent building an enclosure through the UI and MCP.
|
||||
demo at the top of the [README](https://github.com/bherbruck/solvecraft#readme) shows an agent
|
||||
building an enclosure through the UI and MCP.
|
||||
|
||||
## Downloads
|
||||
|
||||
| File | What |
|
||||
|---|---|
|
||||
| `solvecraft-v0.1.1-windows-x86_64.zip` | Windows 10/11: `solvecraft.exe` (the app) and `solvecraft-cli.exe` |
|
||||
| `solvecraft-v0.1.1-linux-x86_64.tar.gz` | Linux x86_64 (glibc 2.35 or newer): `solvecraft` and `solvecraft-cli` |
|
||||
| `SolveCraft-v0.1.1-x86_64.AppImage` | Linux, the app as one file: `chmod +x` and run it |
|
||||
| `solvecraft-v0.1.1-macos-universal.zip` | macOS 11 or newer, Apple silicon and Intel: `SolveCraft.app` and `solvecraft-cli` |
|
||||
| `solvecraft-v0.1.1-web.zip` | the web app, to host yourself (any static server; HTTPS or localhost for saving files) |
|
||||
| `SolveCraft-{tag}-windows-x86_64.exe` | Windows 10/11: the app, ready to run |
|
||||
| `solvecraft-cli-{tag}-windows-x86_64.exe` | Windows: the command-line tool and MCP server |
|
||||
| `SolveCraft-{tag}-macos-universal.dmg` | macOS 11 or newer, Apple silicon and Intel: open it and drag SolveCraft onto Applications |
|
||||
| `solvecraft-cli-{tag}-macos-universal` | macOS: the command-line tool and MCP server |
|
||||
| `SolveCraft-{tag}-x86_64.AppImage` | Linux x86_64 (glibc 2.35 or newer): the app as one file |
|
||||
| `solvecraft-{tag}-linux-x86_64` | Linux: the app as a plain binary |
|
||||
| `solvecraft-cli-{tag}-linux-x86_64` | Linux: the command-line tool and MCP server |
|
||||
| `solvecraft-{tag}-web.zip` | the web app, to host yourself (any static server; HTTPS or localhost for saving files) |
|
||||
| `SHA256SUMS` | checksums: `sha256sum -c SHA256SUMS` |
|
||||
|
||||
On Linux and macOS a downloaded program isn't executable yet: `chmod +x` it first
|
||||
(`chmod +x SolveCraft-{tag}-x86_64.AppImage && ./SolveCraft-{tag}-x86_64.AppImage`). The licences
|
||||
of SolveCraft and everything it is built from are inside each program: Help ▸ About ▸ Licences,
|
||||
or `solvecraft-cli licences`.
|
||||
|
||||
### The binaries are not signed
|
||||
|
||||
- **macOS:** the first time, right-click (or Control-click) `SolveCraft.app` and choose **Open**,
|
||||
then **Open** again in the dialog. If macOS says the app is damaged, run
|
||||
`xattr -dr com.apple.quarantine SolveCraft.app`. Do the same for `solvecraft-cli`.
|
||||
- **macOS:** the first time, right-click (or Control-click) `SolveCraft.app` in Applications and
|
||||
choose **Open**, then **Open** again in the dialog. If macOS says the app is damaged, run
|
||||
`xattr -dr com.apple.quarantine /Applications/SolveCraft.app`. For the CLI:
|
||||
`xattr -d com.apple.quarantine solvecraft-cli-{tag}-macos-universal`.
|
||||
- **Windows:** SmartScreen may say "Windows protected your PC". Click **More info**, then
|
||||
**Run anyway**.
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Package the Linux release build: a tar.gz with both binaries, and an AppImage of the desktop app.
|
||||
# Usage: .github/release/package-linux.sh <tag> (from the repository root, after
|
||||
# Package the Linux release build: the app and the CLI as plain binaries, and an AppImage of the
|
||||
# app. Usage: .github/release/package-linux.sh <tag> (from the repository root, after
|
||||
# `cargo build --release -p solvecraft -p solvecraft-cli`). Writes out/.
|
||||
set -euo pipefail
|
||||
tag=${1:?usage: package-linux.sh <tag>}
|
||||
bin=${CARGO_TARGET_DIR:-target}/release
|
||||
name="solvecraft-$tag-linux-x86_64"
|
||||
rm -rf stage out && mkdir -p "stage/$name" out
|
||||
cp "$bin/solvecraft" "$bin/solvecraft-cli" README.md LICENSE-MIT LICENSE-APACHE NOTICE ATTRIBUTION.md "stage/$name/"
|
||||
tar -C stage -czf "out/$name.tar.gz" "$name"
|
||||
rm -rf stage out && mkdir -p stage out
|
||||
cp "$bin/solvecraft" "out/solvecraft-$tag-linux-x86_64"
|
||||
cp "$bin/solvecraft-cli" "out/solvecraft-cli-$tag-linux-x86_64"
|
||||
|
||||
# AppImage. The icon is rendered by SolveCraft itself from the bracket example, so no image file
|
||||
# is committed.
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Package the macOS release build: a universal (arm64 + x86_64) SolveCraft.app and CLI in one zip.
|
||||
# Package the macOS release build: a universal (arm64 + x86_64) SolveCraft.app in a .dmg (with an
|
||||
# Applications link to drag it onto), and the universal CLI as a plain binary.
|
||||
# Usage: .github/release/package-macos.sh <tag> (from the repository root, after building both
|
||||
# aarch64-apple-darwin and x86_64-apple-darwin). Writes out/. The app is not signed.
|
||||
# aarch64-apple-darwin and x86_64-apple-darwin). Writes out/. Neither is signed.
|
||||
set -euo pipefail
|
||||
tag=${1:?usage: package-macos.sh <tag>}
|
||||
version=${tag#v}
|
||||
t=${CARGO_TARGET_DIR:-target}
|
||||
name="solvecraft-$tag-macos-universal"
|
||||
rm -rf stage out && mkdir -p "stage/$name" out
|
||||
app="stage/$name/SolveCraft.app"
|
||||
rm -rf stage out && mkdir -p stage/dmg out
|
||||
app="stage/dmg/SolveCraft.app"
|
||||
mkdir -p "$app/Contents/MacOS"
|
||||
lipo -create -output "$app/Contents/MacOS/solvecraft" "$t/aarch64-apple-darwin/release/solvecraft" "$t/x86_64-apple-darwin/release/solvecraft"
|
||||
lipo -create -output "stage/$name/solvecraft-cli" "$t/aarch64-apple-darwin/release/solvecraft-cli" "$t/x86_64-apple-darwin/release/solvecraft-cli"
|
||||
lipo -create -output "out/solvecraft-cli-$tag-macos-universal" "$t/aarch64-apple-darwin/release/solvecraft-cli" "$t/x86_64-apple-darwin/release/solvecraft-cli"
|
||||
cat > "$app/Contents/Info.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
@@ -29,7 +29,7 @@ cat > "$app/Contents/Info.plist" <<PLIST
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
cp README.md LICENSE-MIT LICENSE-APACHE NOTICE ATTRIBUTION.md "stage/$name/"
|
||||
lipo -info "$app/Contents/MacOS/solvecraft"
|
||||
(cd stage && ditto -c -k --keepParent "$name" "../out/$name.zip")
|
||||
ln -s /Applications stage/dmg/Applications
|
||||
hdiutil create -volname "SolveCraft $tag" -srcfolder stage/dmg -ov -format UDZO "out/SolveCraft-$tag-macos-universal.dmg"
|
||||
ls -l out
|
||||
@@ -1,7 +1,9 @@
|
||||
# Release builds: push a tag `v*` (or run it by hand with a tag name) and this builds the desktop
|
||||
# app and the CLI for Linux, Windows and macOS plus the web app, and attaches them to a GitHub
|
||||
# release with SHA256SUMS (a pre-release when the tag has a suffix, such as v0.2.0-rc1). Notes:
|
||||
# .github/release-notes.md.
|
||||
# release with SHA256SUMS (a pre-release when the tag has a suffix, such as v0.2.0-rc1). Every
|
||||
# download is a plain program (Windows .exe, Linux binary and AppImage, a macOS .dmg); the licences
|
||||
# are built into the programs (Help ▸ About ▸ Licences, `solvecraft-cli licences`). Only the web
|
||||
# app, being many files, is a zip. Notes: .github/release-notes.md, with {tag} replaced.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -52,10 +54,9 @@ jobs:
|
||||
- name: Package
|
||||
shell: pwsh
|
||||
run: |
|
||||
$name = "solvecraft-$env:TAG-windows-x86_64"
|
||||
New-Item -ItemType Directory -Force -Path "stage/$name", out | Out-Null
|
||||
Copy-Item target/release/solvecraft.exe, target/release/solvecraft-cli.exe, README.md, LICENSE-MIT, LICENSE-APACHE, NOTICE, ATTRIBUTION.md "stage/$name/"
|
||||
Compress-Archive -Path "stage/$name" -DestinationPath "out/$name.zip"
|
||||
New-Item -ItemType Directory -Force -Path out | Out-Null
|
||||
Copy-Item target/release/solvecraft.exe "out/SolveCraft-$env:TAG-windows-x86_64.exe"
|
||||
Copy-Item target/release/solvecraft-cli.exe "out/solvecraft-cli-$env:TAG-windows-x86_64.exe"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows
|
||||
@@ -106,7 +107,7 @@ jobs:
|
||||
run: |
|
||||
name="solvecraft-$TAG-web"
|
||||
mkdir -p out && cp -r dist/web "$name"
|
||||
cp LICENSE-MIT LICENSE-APACHE NOTICE "$name/"
|
||||
cp LICENSE-MIT LICENSE-APACHE NOTICE THIRD-PARTY-LICENSES.txt "$name/"
|
||||
zip -qr "out/$name.zip" "$name"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -127,11 +128,14 @@ jobs:
|
||||
- name: Checksums
|
||||
working-directory: assets
|
||||
run: sha256sum * > SHA256SUMS && cat SHA256SUMS
|
||||
- name: Notes
|
||||
run: sed "s/{tag}/$TAG/g" .github/release-notes.md > notes.md
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ env.TAG }}
|
||||
name: SolveCraft ${{ env.TAG }}
|
||||
body_path: .github/release-notes.md
|
||||
body_path: notes.md
|
||||
generate_release_notes: true
|
||||
# A tag with a suffix (v0.2.0-rc1) is a pre-release; v1.2.3 is a normal release.
|
||||
prerelease: ${{ contains(env.TAG, '-') }}
|
||||
files: assets/*
|
||||
+2
-1
@@ -59,7 +59,8 @@ The [README](README.md#architecture) has the layer table. The short version:
|
||||
2. **No GPL/LGPL/AGPL code, not even reading it.** That rules out OpenCASCADE, FreeCAD
|
||||
(including planegcs), SolveSpace, CadQuery/OCP, LibreCAD and QCAD. SolveCraft is
|
||||
MIT OR Apache-2.0, and its solver and blends are written from the maths. Permissive
|
||||
dependencies are fine. Note which one you're adding and why in the PR.
|
||||
dependencies are fine. Note which one you're adding and why in the PR, and run
|
||||
`cargo xtask licences` so THIRD-PARTY-LICENSES.txt lists it (CI checks that it is current).
|
||||
3. **Every asset is original or openly licensed and has a row in
|
||||
[ATTRIBUTION.md](ATTRIBUTION.md).** Icons are drawn in code (`crates/ui-egui/src/icons.rs`).
|
||||
`cargo xtask assets` enforces this.
|
||||
|
||||
@@ -5,7 +5,9 @@ Licensed under the MIT License or the Apache License, Version 2.0, at your optio
|
||||
|
||||
Third-party material
|
||||
|
||||
Software dependencies are listed in Cargo.lock. The geometry kernel builds on the truck crates
|
||||
Software dependencies are listed in Cargo.lock; their licences are in THIRD-PARTY-LICENSES.txt
|
||||
(`cargo xtask licences`), which the programs also carry (Help > About > Licences,
|
||||
`solvecraft-cli licences`). The geometry kernel builds on the truck crates
|
||||
(truck-modeling, truck-shapeops, truck-meshalgo, truck-stepio, truck-topology and their
|
||||
dependencies, https://github.com/ricosjp/truck), licensed under the Apache License 2.0 and used
|
||||
as libraries. truck-shapeops 0.4.0 is vendored in vendor/truck-shapeops with SolveCraft
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -10,6 +10,7 @@
|
||||
//! solvecraft-cli oracle <case-dir>... replay recipes and compare with measure.json
|
||||
//! solvecraft-cli step-corpus <case-dir>... import part.step and compare with measure.json
|
||||
//! solvecraft-cli mcp [--in design|script] [--connect HOST:PORT] MCP server on stdio (docs/mcp.md)
|
||||
//! solvecraft-cli licences SolveCraft's licence and the third-party licences
|
||||
//! ```
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
@@ -35,6 +36,7 @@ const USAGE: &str = "usage:
|
||||
solvecraft-cli oracle <case-dir>... [--json]
|
||||
solvecraft-cli step-corpus <case-dir>... [--json]
|
||||
solvecraft-cli mcp [--in design.solvecraft|script.json] [--connect 127.0.0.1:PORT]
|
||||
solvecraft-cli licences
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
@@ -55,6 +57,12 @@ fn main() -> ExitCode {
|
||||
Some("oracle") => oracle::run(&args[1..]),
|
||||
Some("step-corpus") => oracle::step_corpus(&args[1..]),
|
||||
Some("mcp") => cmd_mcp(&args[1..]),
|
||||
Some("licences" | "licenses") => {
|
||||
// Written, not print!ed: a closed pipe (`| head`) is not a crash.
|
||||
use std::io::Write as _;
|
||||
let _ = std::io::stdout().lock().write_all(solvecraft_engine::licences::all().as_bytes());
|
||||
Ok(())
|
||||
}
|
||||
// Hidden, for the kill-mid-save test: save a large design over and over.
|
||||
Some("save-stress") => cmd_save_stress(&args[1..]),
|
||||
// Hidden: time recomputes after parameter edits (docs/perf.md).
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
pub mod cmd;
|
||||
pub mod frames;
|
||||
pub mod legacy_ids;
|
||||
pub mod licences;
|
||||
pub mod params;
|
||||
pub mod recovery;
|
||||
pub mod sample;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
//! SolveCraft's licence texts and the third-party licences, built into every program so a single
|
||||
//! downloaded executable carries them (Help ▸ About ▸ Licences, `solvecraft-cli licences`).
|
||||
//! THIRD-PARTY-LICENSES.txt is written by `cargo xtask licences` and checked by `cargo xtask ci`.
|
||||
|
||||
pub const LICENSE_MIT: &str = include_str!("../../../LICENSE-MIT");
|
||||
pub const LICENSE_APACHE: &str = include_str!("../../../LICENSE-APACHE");
|
||||
pub const NOTICE: &str = include_str!("../../../NOTICE");
|
||||
pub const THIRD_PARTY: &str = include_str!("../../../THIRD-PARTY-LICENSES.txt");
|
||||
|
||||
/// Everything, as one text.
|
||||
pub fn all() -> String {
|
||||
let rule = "=".repeat(80);
|
||||
format!(
|
||||
"SolveCraft is licensed under the MIT License or the Apache License, Version 2.0, at your option.\n\n{rule}\nNOTICE\n{rule}\n\n{NOTICE}\n{rule}\nLICENSE-MIT\n{rule}\n\n{LICENSE_MIT}\n{rule}\nLICENSE-APACHE\n{rule}\n\n{LICENSE_APACHE}\n{rule}\n{THIRD_PARTY}"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn the_licences_are_built_in() {
|
||||
assert!(super::LICENSE_MIT.contains("MIT License") && super::LICENSE_APACHE.contains("Apache License"));
|
||||
assert!(super::THIRD_PARTY.contains("truck") && super::THIRD_PARTY.contains("egui"));
|
||||
assert!(super::all().contains("NOTICE"));
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,7 @@ pub const BUILD_DATE: &str = env!("SOLVECRAFT_BUILD_DATE");
|
||||
/// The user and developer guide (docs/book, published by .github/workflows/pages.yml).
|
||||
pub const DOCS_URL: &str = "https://bherbruck.github.io/solvecraft/";
|
||||
const ATTRIBUTION: &str = include_str!("../../../ATTRIBUTION.md");
|
||||
const LICENSE_MIT: &str = include_str!("../../../LICENSE-MIT");
|
||||
const LICENSE_APACHE: &str = include_str!("../../../LICENSE-APACHE");
|
||||
use solvecraft_engine::licences::{LICENSE_APACHE, LICENSE_MIT, NOTICE, THIRD_PARTY};
|
||||
/// Fonts: what this build carries.
|
||||
pub const FONTS: &str = "The interface uses egui's built-in fonts (Ubuntu Light and Hack, under the Ubuntu Font Licence and \
|
||||
the MIT/Bitstream Vera licence; Noto Emoji and the emoji icon font, under the OFL and MIT). Sketch text uses Liberation Sans \
|
||||
@@ -112,17 +111,28 @@ fn about(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
});
|
||||
ui.separator();
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
egui::CollapsingHeader::new("Attributions (ATTRIBUTION.md)").default_open(true).show(ui, |ui| {
|
||||
egui::CollapsingHeader::new("Attributions (ATTRIBUTION.md)").show(ui, |ui| {
|
||||
ui.label(RichText::new(ATTRIBUTION).monospace().size(11.0));
|
||||
});
|
||||
egui::CollapsingHeader::new("Fonts").show(ui, |ui| {
|
||||
ui.label(FONTS);
|
||||
});
|
||||
egui::CollapsingHeader::new("MIT licence").show(ui, |ui| {
|
||||
ui.label(RichText::new(LICENSE_MIT).monospace().size(11.0));
|
||||
});
|
||||
egui::CollapsingHeader::new("Apache licence 2.0").show(ui, |ui| {
|
||||
ui.label(RichText::new(LICENSE_APACHE).monospace().size(11.0));
|
||||
egui::CollapsingHeader::new("Licences").show(ui, |ui| {
|
||||
egui::CollapsingHeader::new("NOTICE").show(ui, |ui| {
|
||||
ui.label(RichText::new(NOTICE).monospace().size(11.0));
|
||||
});
|
||||
egui::CollapsingHeader::new("MIT licence").show(ui, |ui| {
|
||||
ui.label(RichText::new(LICENSE_MIT).monospace().size(11.0));
|
||||
});
|
||||
egui::CollapsingHeader::new("Apache licence 2.0").show(ui, |ui| {
|
||||
ui.label(RichText::new(LICENSE_APACHE).monospace().size(11.0));
|
||||
});
|
||||
egui::CollapsingHeader::new("Third-party licences").show(ui, |ui| {
|
||||
if ui.button("Copy all licences").clicked() {
|
||||
ctx.copy_text(solvecraft_engine::licences::all());
|
||||
}
|
||||
third_party(ui);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -131,6 +141,19 @@ fn about(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// THIRD-PARTY-LICENSES.txt (over 10 000 lines): only the lines in view are laid out.
|
||||
fn third_party(ui: &mut egui::Ui) {
|
||||
let lines: Vec<&str> = THIRD_PARTY.lines().collect();
|
||||
let font = egui::FontId::monospace(11.0);
|
||||
let row_h = ui.fonts_mut(|f| f.row_height(&font));
|
||||
egui::ScrollArea::both().id_salt("sc_third_party").max_height(360.0).show_rows(ui, row_h, lines.len(), |ui, rows| {
|
||||
for line in lines.get(rows).unwrap_or_default() {
|
||||
// One row per line (show_rows needs equal rows): long lines scroll sideways.
|
||||
ui.add(egui::Label::new(RichText::new(*line).font(font.clone())).extend());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{"note": "Help ▸ About ▸ Licences shows SolveCraft's licences and every third-party crate's, built into the program."},
|
||||
{"call": "ui.help", "params": {"item": "about"}},
|
||||
{"widget": "Licences"},
|
||||
{"expect": {"shown": "Third-party licences"}},
|
||||
{"widget": "Third-party licences"},
|
||||
{"expect": {"shown": "Copy all licences"}},
|
||||
{"expect": {"shown": "Third-party software in SolveCraft"}}
|
||||
]
|
||||
@@ -0,0 +1,180 @@
|
||||
//! `cargo xtask licences [--check]`: THIRD-PARTY-LICENSES.txt, the licences of every crate built
|
||||
//! into the shipped programs (solvecraft, solvecraft-cli, solvecraft-web), read from
|
||||
//! `cargo metadata` and the crates' own licence files. The programs embed it (Help ▸ About ▸
|
||||
//! Licences, `solvecraft-cli licences`), so a release download needs no licence files beside it.
|
||||
//! `--check` (part of `cargo xtask ci`) fails when the committed file is out of date, e.g. after a
|
||||
//! dependency changed.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::cargo;
|
||||
|
||||
pub const FILE: &str = "THIRD-PARTY-LICENSES.txt";
|
||||
const ROOTS: &[&str] = &["solvecraft", "solvecraft-cli", "solvecraft-web"];
|
||||
|
||||
pub fn run(root: &Path, check: bool) -> Result<(), String> {
|
||||
let text = generate(root)?;
|
||||
let path = root.join(FILE);
|
||||
if check {
|
||||
let old = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
if old.replace("\r\n", "\n") != text {
|
||||
return Err(format!("{FILE} is out of date (dependencies changed): run `cargo xtask licences` and commit it"));
|
||||
}
|
||||
println!("licences: {FILE} is up to date");
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::write(&path, &text).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
println!("licences: wrote {FILE} ({} KB)", text.len() / 1024);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Crate {
|
||||
name: String,
|
||||
version: String,
|
||||
license: String,
|
||||
repository: String,
|
||||
/// (file name, text) of the licence files shipped in the crate.
|
||||
files: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
fn generate(root: &Path) -> Result<String, String> {
|
||||
let out = cargo().args(["metadata", "--format-version", "1", "--locked"]).output().map_err(|e| format!("cargo metadata: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("cargo metadata failed:\n{}", String::from_utf8_lossy(&out.stderr)));
|
||||
}
|
||||
let meta: Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("cargo metadata JSON: {e}"))?;
|
||||
let packages: BTreeMap<&str, &Value> = meta["packages"].as_array().into_iter().flatten().filter_map(|p| Some((p["id"].as_str()?, p))).collect();
|
||||
let nodes: BTreeMap<&str, &Value> =
|
||||
meta["resolve"]["nodes"].as_array().into_iter().flatten().filter_map(|n| Some((n["id"].as_str()?, n))).collect();
|
||||
let members: BTreeSet<&str> = meta["workspace_members"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
|
||||
|
||||
// Everything reachable from the shipped programs through normal (not dev) dependencies, on any
|
||||
// platform.
|
||||
let mut seen: BTreeSet<&str> = BTreeSet::new();
|
||||
let mut queue: VecDeque<&str> = packages
|
||||
.iter()
|
||||
.filter(|(id, p)| members.contains(*id) && ROOTS.contains(&p["name"].as_str().unwrap_or_default()))
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
while let Some(id) = queue.pop_front() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
for d in nodes.get(id).and_then(|n| n["deps"].as_array()).into_iter().flatten() {
|
||||
let normal = d["dep_kinds"].as_array().into_iter().flatten().any(|k| k["kind"].is_null());
|
||||
if normal && let Some(pkg) = d["pkg"].as_str() {
|
||||
queue.push_back(pkg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut crates = Vec::new();
|
||||
for id in seen {
|
||||
let Some(p) = packages.get(id) else { continue };
|
||||
if members.contains(id) {
|
||||
continue;
|
||||
}
|
||||
let s = |k: &str| p[k].as_str().unwrap_or_default().to_string();
|
||||
let dir = Path::new(p["manifest_path"].as_str().unwrap_or_default()).parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
crates.push(Crate {
|
||||
name: s("name"),
|
||||
version: s("version"),
|
||||
license: s("license"),
|
||||
repository: s("repository"),
|
||||
files: licence_files(&dir, p),
|
||||
});
|
||||
}
|
||||
crates.sort_by(|a, b| (&a.name, &a.version).cmp(&(&b.name, &b.version)));
|
||||
let _ = root;
|
||||
Ok(render(&crates))
|
||||
}
|
||||
|
||||
/// The licence and notice files at the top of a crate's package (and its `license-file`).
|
||||
fn licence_files(dir: &Path, p: &Value) -> Vec<(String, String)> {
|
||||
let mut names: BTreeSet<String> = BTreeSet::new();
|
||||
if let Ok(rd) = std::fs::read_dir(dir) {
|
||||
for e in rd.flatten() {
|
||||
let n = e.file_name().to_string_lossy().to_string();
|
||||
let u = n.to_ascii_uppercase();
|
||||
if e.path().is_file() && ["LICENSE", "LICENCE", "COPYING", "NOTICE", "UNLICENSE", "COPYRIGHT"].iter().any(|k| u.starts_with(k)) {
|
||||
names.insert(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(f) = p["license_file"].as_str() {
|
||||
names.insert(f.to_string());
|
||||
}
|
||||
names
|
||||
.into_iter()
|
||||
.filter_map(|n| {
|
||||
let text = std::fs::read_to_string(dir.join(&n)).ok()?;
|
||||
let text = text.replace("\r\n", "\n").trim().to_string();
|
||||
(!text.is_empty()).then(|| (Path::new(&n).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or(n), text))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render(crates: &[Crate]) -> String {
|
||||
let rule = "=".repeat(80);
|
||||
let mut out = format!(
|
||||
"Third-party software in SolveCraft\n\nSolveCraft (the desktop app, solvecraft-cli and the web app) is built from the {} crates below.\n\
|
||||
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.\n\
|
||||
SolveCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.\n\n",
|
||||
crates.len()
|
||||
);
|
||||
for c in crates {
|
||||
out += &format!("{} {} ({})\n", c.name, c.version, if c.license.is_empty() { "see its licence file" } else { &c.license });
|
||||
}
|
||||
// One copy of each distinct licence text, with the crates it covers.
|
||||
let mut by_text: BTreeMap<&str, Vec<String>> = BTreeMap::new();
|
||||
let mut without: Vec<&Crate> = Vec::new();
|
||||
for c in crates {
|
||||
if c.files.is_empty() {
|
||||
without.push(c);
|
||||
}
|
||||
for (_, text) in &c.files {
|
||||
by_text.entry(text.as_str()).or_default().push(format!("{} {}", c.name, c.version));
|
||||
}
|
||||
}
|
||||
let mut sections: Vec<(Vec<String>, &str)> = by_text.into_iter().map(|(t, v)| (v, t)).collect();
|
||||
sections.sort();
|
||||
for (names, text) in sections {
|
||||
out += &format!("\n{rule}\n{}\n{rule}\n\n{text}\n", wrap(&names.join(", ")));
|
||||
}
|
||||
if !without.is_empty() {
|
||||
out += &format!(
|
||||
"\n{rule}\nCrates whose package carries no licence file (their licence is named in Cargo.toml; the MIT and Apache-2.0\ntexts are as in SolveCraft's own LICENSE-MIT and LICENSE-APACHE)\n{rule}\n\n"
|
||||
);
|
||||
for c in without {
|
||||
out += &format!(
|
||||
"{} {}: {}{}\n",
|
||||
c.name,
|
||||
c.version,
|
||||
c.license,
|
||||
if c.repository.is_empty() { String::new() } else { format!(" ({})", c.repository) }
|
||||
);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Wrap a long list of crate names at 100 columns.
|
||||
fn wrap(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut col = 0;
|
||||
for w in s.split(' ') {
|
||||
if col > 0 && col + w.len() + 1 > 100 {
|
||||
out.push('\n');
|
||||
col = 0;
|
||||
} else if col > 0 {
|
||||
out.push(' ');
|
||||
col += 1;
|
||||
}
|
||||
out += w;
|
||||
col += w.len();
|
||||
}
|
||||
out
|
||||
}
|
||||
+7
-1
@@ -6,6 +6,7 @@
|
||||
mod assets;
|
||||
mod book;
|
||||
mod layers;
|
||||
mod licences;
|
||||
mod parity;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -28,8 +29,11 @@ commands:
|
||||
book [--no-build]
|
||||
generate docs/book's command reference and screenshots, then mdbook build
|
||||
into target/book
|
||||
licences [--check]
|
||||
write THIRD-PARTY-LICENSES.txt (the licences of every shipped crate, embedded
|
||||
in the programs); --check fails when it is out of date
|
||||
wasm cargo check every library crate and the web app for wasm32-unknown-unknown
|
||||
ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
|
||||
ci fmt --check, clippy -D warnings, test, assets, licences --check, layers, wasm (stops at first failure)
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
@@ -42,6 +46,7 @@ fn main() -> ExitCode {
|
||||
Some("oracle") => cmd_oracle(),
|
||||
Some("step-corpus") => cmd_step_corpus(rest.first().copied()),
|
||||
Some("book") => book::run(&root(), !rest.contains(&"--no-build")),
|
||||
Some("licences") => licences::run(&root(), rest.contains(&"--check")),
|
||||
Some("ci") => cmd_ci(),
|
||||
Some("wasm") => cmd_wasm(),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
@@ -305,6 +310,7 @@ fn cmd_ci() -> Result<(), String> {
|
||||
c.args(["test", "--workspace", "--release"]);
|
||||
run(c, "cargo test")?;
|
||||
assets::run(&root())?;
|
||||
licences::run(&root(), true)?;
|
||||
cmd_layers()?;
|
||||
cmd_wasm()?;
|
||||
eprintln!("ci: all gates passed");
|
||||
|
||||
Reference in new issue
Block a user