mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-02 01:08:41 +02:00
feat: xray support
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import parseSpdxExpression from "spdx-expression-parse";
|
||||
import { XRAY_SOURCE_URL } from "../src-tauri/download-xray.mjs";
|
||||
|
||||
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const PROJECT_ROOT = resolve(SCRIPT_DIR, "..");
|
||||
const OUTPUT_PATH = resolve(PROJECT_ROOT, "src/generated/licenses.json");
|
||||
const XRAY_SOURCE_OUTPUT_PATH = resolve(
|
||||
PROJECT_ROOT,
|
||||
"src/generated/xray-source.json",
|
||||
);
|
||||
const MAX_COMMAND_OUTPUT = 64 * 1024 * 1024;
|
||||
|
||||
export const RELEASE_TARGETS = [
|
||||
"aarch64-apple-darwin",
|
||||
"x86_64-apple-darwin",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"x86_64-pc-windows-msvc",
|
||||
];
|
||||
|
||||
export const MANUAL_LICENSES = [
|
||||
{
|
||||
name: "Donut Browser",
|
||||
license: "AGPL-3.0-only",
|
||||
},
|
||||
{
|
||||
name: "Xray-core",
|
||||
license: "MPL-2.0",
|
||||
},
|
||||
];
|
||||
|
||||
const LEGACY_LICENSE_EXPRESSIONS = new Map([
|
||||
["Apache-2.0 / MIT", "Apache-2.0 OR MIT"],
|
||||
["Apache-2.0/MIT", "Apache-2.0 OR MIT"],
|
||||
["BSD-3-Clause/MIT", "BSD-3-Clause OR MIT"],
|
||||
["MIT/Apache-2.0", "Apache-2.0 OR MIT"],
|
||||
["MIT OR Apache-2.0", "Apache-2.0 OR MIT"],
|
||||
["Unlicense/MIT", "MIT OR Unlicense"],
|
||||
]);
|
||||
|
||||
const HOST_ONLY_PNPM_NATIVE_PREFIXES = [
|
||||
"@img/sharp-",
|
||||
"@img/sharp-libvips-",
|
||||
"@next/swc-",
|
||||
];
|
||||
|
||||
function validateLicenseExpression(expression) {
|
||||
try {
|
||||
parseSpdxExpression(expression);
|
||||
} catch {
|
||||
throw new Error(`Invalid SPDX expression: ${expression}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeLicenseExpression(value) {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw new Error("Every shipped dependency must declare a license");
|
||||
}
|
||||
|
||||
const expression =
|
||||
LEGACY_LICENSE_EXPRESSIONS.get(value.trim()) ?? value.trim();
|
||||
validateLicenseExpression(expression);
|
||||
return expression;
|
||||
}
|
||||
|
||||
export function collectReachableRustLicenses(metadata) {
|
||||
const root = metadata.resolve?.root;
|
||||
if (!root) {
|
||||
throw new Error("Cargo metadata did not identify the root package");
|
||||
}
|
||||
|
||||
const packages = new Map(
|
||||
metadata.packages.map((dependency) => [dependency.id, dependency]),
|
||||
);
|
||||
const nodes = new Map(metadata.resolve.nodes.map((node) => [node.id, node]));
|
||||
const pending = [root];
|
||||
const visited = new Set();
|
||||
const result = [];
|
||||
|
||||
while (pending.length > 0) {
|
||||
const packageId = pending.pop();
|
||||
if (!packageId || visited.has(packageId)) continue;
|
||||
visited.add(packageId);
|
||||
|
||||
if (packageId !== root) {
|
||||
const dependency = packages.get(packageId);
|
||||
if (!dependency) {
|
||||
throw new Error(`Cargo metadata is missing package ${packageId}`);
|
||||
}
|
||||
result.push({
|
||||
name: dependency.name,
|
||||
license: dependency.license,
|
||||
});
|
||||
}
|
||||
|
||||
const node = nodes.get(packageId);
|
||||
if (!node) continue;
|
||||
for (const dependency of node.deps) {
|
||||
const isRuntimeDependency = dependency.dep_kinds.some(
|
||||
({ kind }) => kind === null,
|
||||
);
|
||||
if (isRuntimeDependency) pending.push(dependency.pkg);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function collectPnpmLicenses(report) {
|
||||
return Object.entries(report).flatMap(([groupLicense, dependencies]) =>
|
||||
dependencies
|
||||
.filter(
|
||||
(dependency) =>
|
||||
!HOST_ONLY_PNPM_NATIVE_PREFIXES.some((prefix) =>
|
||||
dependency.name.startsWith(prefix),
|
||||
),
|
||||
)
|
||||
.map((dependency) => ({
|
||||
name: dependency.name,
|
||||
license: dependency.license ?? groupLicense,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function prepareLicenseInventory(entries) {
|
||||
const unique = new Map();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (typeof entry.name !== "string" || entry.name.trim() === "") {
|
||||
throw new Error("Every shipped dependency must have a name");
|
||||
}
|
||||
const name = entry.name.trim();
|
||||
const license = normalizeLicenseExpression(entry.license);
|
||||
unique.set(`${name}\0${license}`, { name, license });
|
||||
}
|
||||
|
||||
return [...unique.values()].sort((left, right) => {
|
||||
const leftName = left.name.toLowerCase();
|
||||
const rightName = right.name.toLowerCase();
|
||||
if (leftName < rightName) return -1;
|
||||
if (leftName > rightName) return 1;
|
||||
if (left.name < right.name) return -1;
|
||||
if (left.name > right.name) return 1;
|
||||
return left.license < right.license
|
||||
? -1
|
||||
: Number(left.license > right.license);
|
||||
});
|
||||
}
|
||||
|
||||
function commandOutput(command, args) {
|
||||
const executable =
|
||||
process.platform === "win32" && command === "pnpm" ? "pnpm.cmd" : command;
|
||||
return execFileSync(executable, args, {
|
||||
cwd: PROJECT_ROOT,
|
||||
encoding: "utf8",
|
||||
maxBuffer: MAX_COMMAND_OUTPUT,
|
||||
});
|
||||
}
|
||||
|
||||
function generateInventory() {
|
||||
const entries = [...MANUAL_LICENSES];
|
||||
|
||||
const pnpmReport = JSON.parse(
|
||||
commandOutput("pnpm", [
|
||||
"--filter",
|
||||
"donutbrowser",
|
||||
"licenses",
|
||||
"list",
|
||||
"--prod",
|
||||
"--json",
|
||||
]),
|
||||
);
|
||||
entries.push(...collectPnpmLicenses(pnpmReport));
|
||||
|
||||
for (const target of RELEASE_TARGETS) {
|
||||
const metadata = JSON.parse(
|
||||
commandOutput("cargo", [
|
||||
"metadata",
|
||||
"--locked",
|
||||
"--format-version",
|
||||
"1",
|
||||
"--filter-platform",
|
||||
target,
|
||||
"--manifest-path",
|
||||
"src-tauri/Cargo.toml",
|
||||
]),
|
||||
);
|
||||
entries.push(...collectReachableRustLicenses(metadata));
|
||||
}
|
||||
|
||||
return prepareLicenseInventory(entries);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const outputs = [
|
||||
{
|
||||
path: OUTPUT_PATH,
|
||||
contents: `${JSON.stringify(generateInventory(), null, 2)}\n`,
|
||||
},
|
||||
{
|
||||
path: XRAY_SOURCE_OUTPUT_PATH,
|
||||
contents: `${JSON.stringify({ sourceUrl: XRAY_SOURCE_URL }, null, 2)}\n`,
|
||||
},
|
||||
];
|
||||
|
||||
if (process.argv.includes("--check")) {
|
||||
for (const output of outputs) {
|
||||
const current = readFileSync(output.path, "utf8");
|
||||
if (current !== output.contents) {
|
||||
throw new Error(`${output.path} is stale; run pnpm licenses:generate`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const output of outputs) {
|
||||
mkdirSync(dirname(output.path), { recursive: true });
|
||||
writeFileSync(output.path, output.contents);
|
||||
}
|
||||
}
|
||||
|
||||
const isDirectRun =
|
||||
process.argv[1] &&
|
||||
fileURLToPath(import.meta.url) === resolve(process.argv[1]);
|
||||
if (isDirectRun) main();
|
||||
@@ -0,0 +1,182 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { XRAY_SOURCE_URL } from "../src-tauri/download-xray.mjs";
|
||||
import {
|
||||
collectPnpmLicenses,
|
||||
collectReachableRustLicenses,
|
||||
normalizeLicenseExpression,
|
||||
prepareLicenseInventory,
|
||||
} from "./generate-licenses.mjs";
|
||||
|
||||
test("normalizes legacy dual-license metadata into SPDX expressions", () => {
|
||||
assert.equal(
|
||||
normalizeLicenseExpression("MIT/Apache-2.0"),
|
||||
"Apache-2.0 OR MIT",
|
||||
);
|
||||
assert.equal(
|
||||
normalizeLicenseExpression("Apache-2.0 OR MIT"),
|
||||
"Apache-2.0 OR MIT",
|
||||
);
|
||||
assert.throws(() => normalizeLicenseExpression(""), /declare a license/);
|
||||
assert.throws(
|
||||
() => normalizeLicenseExpression("not/a/license"),
|
||||
/Invalid SPDX expression/,
|
||||
);
|
||||
for (const invalid of [
|
||||
"NOASSERTION",
|
||||
"Definitely-Not-A-License",
|
||||
"MPL-999.0",
|
||||
]) {
|
||||
assert.throws(
|
||||
() => normalizeLicenseExpression(invalid),
|
||||
/Invalid SPDX expression/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("collects only normal Rust dependencies reachable from the app", () => {
|
||||
const metadata = {
|
||||
packages: [
|
||||
{ id: "app", name: "app", license: "AGPL-3.0" },
|
||||
{ id: "runtime", name: "runtime", license: "MIT" },
|
||||
{ id: "nested", name: "nested", license: "Apache-2.0" },
|
||||
{ id: "build", name: "build", license: "MIT" },
|
||||
{ id: "dev", name: "dev", license: "MIT" },
|
||||
],
|
||||
resolve: {
|
||||
root: "app",
|
||||
nodes: [
|
||||
{
|
||||
id: "app",
|
||||
deps: [
|
||||
{ pkg: "runtime", dep_kinds: [{ kind: null }] },
|
||||
{ pkg: "build", dep_kinds: [{ kind: "build" }] },
|
||||
{ pkg: "dev", dep_kinds: [{ kind: "dev" }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "runtime",
|
||||
deps: [{ pkg: "nested", dep_kinds: [{ kind: null }] }],
|
||||
},
|
||||
{ id: "nested", deps: [] },
|
||||
{ id: "build", deps: [] },
|
||||
{ id: "dev", deps: [] },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(collectReachableRustLicenses(metadata), [
|
||||
{ name: "runtime", license: "MIT" },
|
||||
{ name: "nested", license: "Apache-2.0" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("flattens pnpm groups and emits a stable name-and-license-only list", () => {
|
||||
const pnpmEntries = collectPnpmLicenses({
|
||||
MIT: [
|
||||
{
|
||||
name: "zeta",
|
||||
license: "MIT",
|
||||
versions: ["1.2.3"],
|
||||
author: "Not included",
|
||||
},
|
||||
{ name: "@next/swc-darwin-arm64", license: "MIT" },
|
||||
{ name: "@next/swc-linux-x64-gnu", license: "MIT" },
|
||||
],
|
||||
"Apache-2.0": [
|
||||
{ name: "@img/sharp-darwin-arm64" },
|
||||
{ name: "@img/sharp-linux-x64" },
|
||||
],
|
||||
"LGPL-3.0-or-later": [
|
||||
{ name: "@img/sharp-libvips-darwin-arm64" },
|
||||
{ name: "@img/sharp-libvips-linux-x64" },
|
||||
],
|
||||
"MIT OR Apache-2.0": [{ name: "alpha" }],
|
||||
});
|
||||
const inventory = prepareLicenseInventory([
|
||||
...pnpmEntries,
|
||||
{ name: "zeta", license: "MIT", copyright: "Not included" },
|
||||
]);
|
||||
|
||||
assert.deepEqual(inventory, [
|
||||
{ name: "alpha", license: "Apache-2.0 OR MIT" },
|
||||
{ name: "zeta", license: "MIT" },
|
||||
]);
|
||||
assert.deepEqual(Object.keys(inventory[0]).sort(), ["license", "name"]);
|
||||
});
|
||||
|
||||
test("pnpm inventory is stable across host-native build packages", () => {
|
||||
const reportForHost = (swc, sharp, libvips) => ({
|
||||
MIT: [
|
||||
{ name: "shared-runtime" },
|
||||
{ name: swc },
|
||||
{ name: sharp, license: "Apache-2.0" },
|
||||
{ name: libvips, license: "LGPL-3.0-or-later" },
|
||||
],
|
||||
});
|
||||
|
||||
const darwin = collectPnpmLicenses(
|
||||
reportForHost(
|
||||
"@next/swc-darwin-arm64",
|
||||
"@img/sharp-darwin-arm64",
|
||||
"@img/sharp-libvips-darwin-arm64",
|
||||
),
|
||||
);
|
||||
const linux = collectPnpmLicenses(
|
||||
reportForHost(
|
||||
"@next/swc-linux-x64-gnu",
|
||||
"@img/sharp-linux-x64",
|
||||
"@img/sharp-libvips-linux-x64",
|
||||
),
|
||||
);
|
||||
|
||||
assert.deepEqual(darwin, linux);
|
||||
assert.deepEqual(darwin, [{ name: "shared-runtime", license: "MIT" }]);
|
||||
});
|
||||
|
||||
test("generated inventory includes the bundled sidecar and Tauri opener", async () => {
|
||||
const inventory = JSON.parse(
|
||||
await readFile(
|
||||
new URL("../src/generated/licenses.json", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
inventory.some(
|
||||
(entry) =>
|
||||
entry.name === "Donut Browser" && entry.license === "AGPL-3.0-only",
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
inventory.some(
|
||||
(entry) => entry.name === "Xray-core" && entry.license === "MPL-2.0",
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
inventory.some(
|
||||
(entry) =>
|
||||
entry.name === "tauri-plugin-opener" &&
|
||||
entry.license === "Apache-2.0 OR MIT",
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
inventory.every(
|
||||
(entry) =>
|
||||
Object.keys(entry).length === 2 &&
|
||||
typeof entry.name === "string" &&
|
||||
typeof entry.license === "string",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("generated Xray source link matches the packaged release", async () => {
|
||||
const source = JSON.parse(
|
||||
await readFile(
|
||||
new URL("../src/generated/xray-source.json", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
assert.deepEqual(source, { sourceUrl: XRAY_SOURCE_URL });
|
||||
});
|
||||
Reference in New Issue
Block a user