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
7 changes: 7 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,13 @@ jobs:
- name: Stage ONNX Runtime
run: npm run fetch:onnxruntime

# Fourth step spelled out for the same reason: `build:mac`, `build:win` and
# `build:linux` chain it before electron-builder. It writes the licence texts of the
# bundled npm packages and of the crates in the addon; scripts/before-pack.cjs
# refuses to package without them.
- name: Generate third-party licences
run: npm run licenses:generate

- name: Package .app bundle
run: npx electron-builder --mac --${{ matrix.arch }} --dir --publish never
env:
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ result-*
# Auto-caption model + ORT wasm — regenerated at build by scripts/fetch-caption-model.mjs
/caption-assets/

# Third-party licence texts, regenerated before every package by
# scripts/generate-third-party-licenses.mjs (`npm run licenses:generate`).
/build-licenses/

# Native STT artifacts — whisper-stt-server binaries + ggml backend sidecars.
/electron/native/bin/
/electron/native/models/
Expand Down
7 changes: 5 additions & 2 deletions THIRD-PARTY-NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ bundle the third-party components below. This file ships inside the
application resources and satisfies the attribution and source-offer obligations
that come with them.

npm dependencies are not listed here: they are resolved from `package.json` and
distributed by their own registries, not redistributed inside our binaries.
npm dependencies are not listed here. Their code ships bundled inside the
application by Vite, and their licence texts are in `THIRD-PARTY-LICENSES.txt`,
generated at build time and shipped beside this file.

---

Expand Down Expand Up @@ -182,6 +183,8 @@ distributed by their own registries, not redistributed inside our binaries.
- **Licenses**: as each crate publishes them on crates.io. Where a crate offers a
choice, OpenScreen uses it under MIT or Apache-2.0. Each crate's licence text is
in its source package at the version listed.
- **Full texts**: the licence and NOTICE files of every crate compiled into the
addon are in `THIRD-PARTY-LICENSES.txt`, shipped beside this file.
- **MIT OR Apache-2.0** (155): android_system_properties 0.1.5, anyhow 1.0.103,
arrayvec 0.7.8, ash 0.38.0+1.3.281, autocfg 1.5.1, bit-set 0.8.0, bit-vec 0.8.0,
bitflags 1.3.2, bitflags 2.13.1, bumpalo 3.20.3, cc 1.2.67, cexpr 0.6.0, cfg-if
Expand Down
8 changes: 8 additions & 0 deletions electron-builder.json5
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@
"from": "THIRD-PARTY-NOTICES.md",
"to": "THIRD-PARTY-NOTICES.md"
},
// The full licence texts of the npm packages Vite bundles and of the crates compiled into
// the compositor addon. Generated, not committed: every build:* script and build.yml's
// macOS job run `npm run licenses:generate` first. electron-builder only warns about a
// missing source here, so scripts/before-pack.cjs is what refuses to package without it.
{
"from": "build-licenses/THIRD-PARTY-LICENSES.txt",
"to": "THIRD-PARTY-LICENSES.txt"
},
{
"from": "LICENSE",
"to": "LICENSE"
Expand Down
11 changes: 6 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,25 +33,26 @@
],
"scripts": {
"dev": "vite",
"build": "tsc && vite build && electron-builder",
"build": "tsc && vite build && npm run licenses:generate && electron-builder",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"i18n:check": "node scripts/i18n-check.mjs",
"docs:check": "node scripts/check-docs.mjs",
"assets:appx": "node scripts/generate-appx-assets.mjs",
"licenses:generate": "node scripts/generate-third-party-licenses.mjs",
"preview": "vite preview",
"build:native:mac": "node scripts/build-macos-screencapturekit-helper.mjs",
"build:mac": "npm run build:native:mac && npm run fetch:ffmpeg:mac && npm run fetch:onnxruntime && npm run build:native:compositor:mac && tsc && vite build && electron-builder --mac",
"build:mac": "npm run build:native:mac && npm run fetch:ffmpeg:mac && npm run fetch:onnxruntime && npm run build:native:compositor:mac && tsc && vite build && npm run licenses:generate && electron-builder --mac",
"build:native:win": "node scripts/build-windows-wgc-helper.mjs",
"stage:vcomp": "node scripts/stage-vcomp-runtime.mjs",
"build:native:compositor": "node scripts/build-windows-compositor-addon.mjs",
"build:native:compositor:mac": "node scripts/build-macos-compositor-addon.mjs",
"build:native:compositor:linux": "node scripts/build-linux-compositor-addon.mjs",
"build:native:linux": "node scripts/build-linux-pipewire-helper.mjs",
"build:win": "npm run build:native:win && npm run fetch:ffmpeg && npm run fetch:onnxruntime && npm run stage:vcomp && npm run build:native:compositor && tsc && vite build && electron-builder --win --config.npmRebuild=false",
"build:win:store": "npm run build:native:win && npm run fetch:ffmpeg && npm run fetch:onnxruntime && npm run stage:vcomp && npm run build:native:compositor && tsc && vite build && electron-builder --win appx --config.npmRebuild=false",
"build:linux": "npm run fetch:ffmpeg:sdk && npm run build:native:linux && npm run fetch:onnxruntime && npm run build:native:compositor:linux && tsc && vite build && electron-builder --linux AppImage deb pacman rpm --config.npmRebuild=false",
"build:win": "npm run build:native:win && npm run fetch:ffmpeg && npm run fetch:onnxruntime && npm run stage:vcomp && npm run build:native:compositor && tsc && vite build && npm run licenses:generate && electron-builder --win --config.npmRebuild=false",
"build:win:store": "npm run build:native:win && npm run fetch:ffmpeg && npm run fetch:onnxruntime && npm run stage:vcomp && npm run build:native:compositor && tsc && vite build && npm run licenses:generate && electron-builder --win appx --config.npmRebuild=false",
"build:linux": "npm run fetch:ffmpeg:sdk && npm run build:native:linux && npm run fetch:onnxruntime && npm run build:native:compositor:linux && tsc && vite build && npm run licenses:generate && electron-builder --linux AppImage deb pacman rpm --config.npmRebuild=false",
"build:whisper-binaries": "bash scripts/build-whisper-stt.sh",
"test:whisper-stt": "node scripts/test-whisper-stt.mjs",
"test": "vitest --run",
Expand Down
21 changes: 21 additions & 0 deletions scripts/before-pack.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1112,7 +1112,27 @@ function checkCompositorAddonFreshness(
);
}

// electron-builder.json5 ships this as resources/THIRD-PARTY-LICENSES.txt, but for a missing
// extraResources source electron-builder only logs "file source doesn't exist" and packages
// without it. A packaging path that skipped `npm run licenses:generate` would ship an installer
// with no licence texts for the code it bundles, silently. The generator writes nothing when cargo
// cannot list the addon's crates, and never fails over a missing licence file, so only absence is
// refused here.
const THIRD_PARTY_LICENSES = path.join(ROOT, "build-licenses", "THIRD-PARTY-LICENSES.txt");

function checkThirdPartyLicenses() {
if (!fs.existsSync(THIRD_PARTY_LICENSES)) {
throw new Error(
"Refusing to package without the third-party licence texts.\n\n" +
` expected: ${THIRD_PARTY_LICENSES}\n\n` +
"Generate them with:\n\n npm run licenses:generate\n\n" +
"or use the build:<platform> script, which does that for you.",
);
}
}

exports.default = async function beforePack(context) {
checkThirdPartyLicenses();
const platform = context?.electronPlatformName ?? process.platform;
if (platform === "win32") {
// The copy that ships is the arch-tagged one under electron/native/bin/
Expand Down Expand Up @@ -1162,6 +1182,7 @@ exports.default = async function beforePack(context) {
// Runnable on its own for debugging: `node scripts/before-pack.cjs`
if (require.main === module) {
try {
checkThirdPartyLicenses();
if (process.platform === "darwin") {
checkMacNativePayload({ arch: undefined });
const tag = `darwin-${process.arch}`;
Expand Down
230 changes: 230 additions & 0 deletions scripts/generate-third-party-licenses.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
// Writes build-licenses/THIRD-PARTY-LICENSES.txt, the full licence texts of the third-party code
// compiled into the installers. electron-builder.json5 ships it in resources/, beside
// THIRD-PARTY-NOTICES.md, which covers the other bundled components.
//
// Two sources, both read at build time so a dependency bump needs no manual step:
// - npm: every production entry of package-lock.json. node_modules is not packaged, but the
// code is: Vite bundles it into dist/ and dist-electron/.
// - Rust: every crate compiled into compositor_view.node, from `cargo metadata`.
//
// A package without a licence file is recorded with its declared licence, with a warning; that
// never fails a build. A missing or failing cargo does, and nothing is written: every installer
// ships the addon, so a file without its crates would be incomplete, and before-pack.cjs, which
// refuses to package when the file is absent, would accept it.
//
// Run: node scripts/generate-third-party-licenses.mjs [output path]

import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const OUTPUT = path.join(ROOT, "build-licenses", "THIRD-PARTY-LICENSES.txt");
const ADDON_CRATE = "compositor-view-napi";
const LICENSE_FILE = /^(licen[cs]e|copying|notice)/i;
const NO_FILE = "(no licence file in the package)";

/**
* The licence and NOTICE files at the top of a package directory, in file-name order, then the
* `extra` files that exist. A text read twice is printed once: renderSection merges it.
*/
function readLicenseTexts(dir, extra = []) {
const names = fs
.readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isFile() && LICENSE_FILE.test(entry.name))
.map((entry) => entry.name)
.sort();
return [
...names.map((name) => path.join(dir, name)),
...extra.filter((file) => fs.existsSync(file)),
]
.map((file) => fs.readFileSync(file, "utf8"))
.filter((text) => text.trim());
}

/**
* SPDX expression from a package.json or a cargo package; tolerates the legacy object forms.
* A crate may declare only a `license_file`, a custom licence, which is then named instead.
*/
function declaredLicense({ license, licenses, license_file }) {
const values = [license ?? licenses].flat();
const ids = values.map((value) => (typeof value === "string" ? value : value?.type));
return (
ids.filter(Boolean).join(" OR ") ||
(license_file ? `see ${license_file}` : "no declared licence")
);
}

function readJson(file) {
try {
return JSON.parse(fs.readFileSync(file, "utf8"));
} catch {
return {};
}
}

/**
* Production npm packages: package-lock.json entries under node_modules/ that are not `dev`
* (`optional` and `devOptional` ones included). An entry not installed on this machine, such as
* another platform's optional binary, is skipped: none of its code was bundled here.
*/
export function collectNpmPackages(root = ROOT) {
const lock = JSON.parse(fs.readFileSync(path.join(root, "package-lock.json"), "utf8"));
const packages = new Map();
const skipped = [];
for (const [key, entry] of Object.entries(lock.packages ?? {})) {
if (!key.startsWith("node_modules/") || entry.dev) continue;
const dir = path.join(root, key);
if (!fs.existsSync(dir)) {
skipped.push(key);
continue;
}
const name = key.split("node_modules/").pop();
const info = { name, ...entry, ...readJson(path.join(dir, "package.json")) };
const label = `${info.name} ${info.version} (${declaredLicense(info)})`;
if (!packages.has(label)) packages.set(label, readLicenseTexts(dir));
}
return { entries: [...packages].map(([label, texts]) => ({ label, texts })), skipped };
}

/**
* Crates compiled into the addon: the closure of NORMAL dependencies of compositor-view-napi,
* for every target platform. Build-dependencies never reach the binary, and neither do
* procedural macros, which run inside rustc, nor what only they pull in, so the walk stops at
* them. Workspace members are OpenScreen's own code, under its LICENSE.
*/
export function collectCrates(metadata) {
const packages = new Map(metadata.packages.map((pkg) => [pkg.id, pkg]));
const deps = new Map(metadata.resolve.nodes.map((node) => [node.id, node.deps]));
const members = new Set(metadata.workspace_members);
const isProcMacro = (pkg) => pkg.targets.some((target) => target.kind.includes("proc-macro"));
const root = metadata.packages.find((pkg) => pkg.name === ADDON_CRATE && members.has(pkg.id));
if (!root) throw new Error(`${ADDON_CRATE} is not a member of the cargo workspace`);

const seen = new Set();
const stack = [root.id];
while (stack.length > 0) {
const id = stack.pop();
if (seen.has(id) || isProcMacro(packages.get(id))) continue;
seen.add(id);
for (const dep of deps.get(id) ?? []) {
if (dep.dep_kinds.some((kind) => kind.kind === null)) stack.push(dep.pkg);
}
}
return [...seen]
.filter((id) => !members.has(id))
.map((id) => packages.get(id))
.map((pkg) => {
const dir = path.dirname(pkg.manifest_path);
// Cargo's `license-file` is relative to the manifest and may sit below the top level.
const declared = pkg.license_file ? [path.resolve(dir, pkg.license_file)] : [];
return {
label: `${pkg.name} ${pkg.version} (${declaredLicense(pkg)})`,
texts: readLicenseTexts(dir, declared),
};
});
}

// Code-unit order, not localeCompare: the same input must give the same bytes on every runner.
const byLabel = (a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0);
const RULE = "=".repeat(80);
const LINE = "-".repeat(80);

/** Each distinct text once, after the sorted labels of every package that ships it. */
function renderSection(title, entries) {
const groups = new Map();
for (const { label, texts } of [...entries].sort(byLabel)) {
for (const text of texts.length > 0 ? texts : [NO_FILE]) {
const key = text.replace(/\s+/g, " ").trim();
const group = groups.get(key) ?? { text: text.replace(/\r\n?/g, "\n").trim(), labels: [] };
groups.set(key, group);
if (group.labels.at(-1) !== label) group.labels.push(label);
}
}
const blocks = [...groups.values()].map(({ labels, text }) =>
[LINE, ...labels, "", text, ""].join("\n"),
);
return [RULE, title, RULE, "", ...blocks].join("\n");
}

function summary(noun, entries) {
const missing = entries.filter((entry) => entry.texts.length === 0).length;
return `${noun}: ${entries.length} (${missing} without a licence file).`;
}

export function renderLicenses({ npm, crates }) {
const header = [
"OpenScreen: third-party licences",
"",
"The full licence texts of the third-party code compiled into OpenScreen: the npm",
"production dependencies, which Vite bundles into the application, and the Rust crates",
"compiled into the native compositor addon (compositor_view.node). Each distinct text",
'appears once, preceded by the packages that ship it as "name version (declared licence)".',
"",
"The other bundled components (FFmpeg, whisper.cpp, ONNX Runtime, fonts, model weights",
"and more) and their source offers are described in THIRD-PARTY-NOTICES.md, beside this",
"file. Generated at build time by scripts/generate-third-party-licenses.mjs.",
"",
summary("npm packages", npm),
summary("Rust crates", crates),
"",
];
const sections = [
renderSection("npm packages", npm),
renderSection("Rust crates compiled into the compositor addon", crates),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
];
return `${[...header, ...sections].join("\n").trimEnd()}\n`;
}

function cargoMetadata() {
const exe = process.platform === "win32" ? "cargo.exe" : "cargo";
const rustup = path.join(os.homedir(), ".cargo", "bin", exe);
const crates = path.join(ROOT, "crates");
const result = spawnSync(
fs.existsSync(rustup) ? rustup : "cargo",
["metadata", "--format-version", "1", "--manifest-path", path.join(crates, "Cargo.toml")],
// cwd = crates/ so cargo reads crates/.cargo/config.toml, like the addon builds do.
// The JSON is ~1 MB already, which is spawnSync's default buffer.
{ cwd: crates, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 },
);
if (result.error) throw new Error(`cargo metadata could not run: ${result.error.message}`);
if (result.status !== 0) {
const last = result.stderr.trim().split("\n").pop();
throw new Error(`cargo metadata failed, exit code ${result.status}: ${last}`);
}
return JSON.parse(result.stdout);
}

// An annotation on GitHub Actions, so a degraded file shows on the run summary rather than
// scrolling past in a build log that still ends green.
const warn = (message) =>
console.warn(process.env.GITHUB_ACTIONS ? `::warning::${message}` : `warning: ${message}`);

function main() {
const output = process.argv[2] ? path.resolve(process.argv[2]) : OUTPUT;
const { entries: npm, skipped } = collectNpmPackages();
// Throws without cargo: no fallback, see the top of this file.
const crates = collectCrates(cargoMetadata());
const missing = [...npm, ...crates].filter((entry) => entry.texts.length === 0);
if (missing.length > 0) {
warn(
`third-party licences: ${missing.length} packages have no licence file; ` +
"recorded with their declared licence.",
);
}

const text = renderLicenses({ npm, crates });
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, text);
console.log(
`Wrote ${path.relative(ROOT, output)} (${Math.round(Buffer.byteLength(text) / 1024)} KB): ` +
`${npm.length} npm packages, ${crates.length} Rust crates` +
(skipped.length > 0 ? `; ${skipped.length} lock entries not installed here skipped.` : "."),
);
}

if (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href) {
main();
}
Loading
Loading