mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
feat(settings): Open Source Licenses section in System tab (#477)
* feat(licenses): tooling and initial data generation Adds the maintainer-only generator that produces src/data/licenses.json: - src-tauri/about.toml + about.hbs: cargo-about config + handlebars template for the Rust-side license enumeration - scripts/generate-licenses.mjs: orchestrator that runs cargo-about and license-checker-rseidelsohn (via npx, no devDep), merges the outputs into a single per-crate JSON with full license texts, and writes the result to src/data/licenses.json The script is invoked directly with `node scripts/generate-licenses.mjs` — no npm script wrapper on purpose, since adding one to package.json would trigger the nix-npm-deps-hash-sync workflow on every push. Initial generation covers 575 cargo crates + 71 npm packages (646 entries total, all with full license text bundled, ~1.4 MB JSON). * feat(licenses): Settings panel UI Adds a new Open Source Licenses section under Settings → System, sitting below Contributors. Components: - LicensesPanel.tsx: search input, curated highlight block of ~10 key dependencies (Tauri, React, rodio, symphonia, etc.), TanStack-Virtual list of all 600+ entries - LicenseTextModal.tsx: full-screen-ish modal showing the bundled license text plus name/version/license-id badges + repository link - licensesData.ts: lazy dynamic-import loader (Vite emits the JSON as a separate chunk, so the heavy ~1.4 MB payload is only loaded when the user actually opens the panel — no runtime fetch, the data is fixed into the build artifact) The panel registers itself in the Settings in-page search index under the System tab. * feat(licenses): i18n in 8 locales Adds the `licenses` namespace (title, intro, highlights, search placeholder, no-results, loading / load error, no-license-text, view-source, total line, generated-at) across en, de, fr, nl, zh, nb, ru, es. License names themselves (MIT, Apache-2.0, GPL-3.0, …) stay universal and are rendered as-is. * docs(release): document licenses regeneration step Adds a Step A.3 to the release SOP describing the maintainer-only `node scripts/generate-licenses.mjs` workflow, the cargo-about prerequisite, and explicitly notes why no npm script wrapper exists (would trigger the nix-npm-deps-hash-sync workflow).
This commit is contained in:
committed by
GitHub
parent
d48ea819c1
commit
8692e50603
@@ -41,6 +41,11 @@ Rules:
|
||||
|
||||
1. Merge ready PRs into `main`.
|
||||
2. Confirm CI is green on `main`.
|
||||
3. Refresh the bundled Open-Source-Licenses data (maintainer-only, run locally):
|
||||
- Requires `cargo-about` (one-time install: `cargo install cargo-about --features cli`).
|
||||
- Run directly: `node scripts/generate-licenses.mjs` from the repo root.
|
||||
- Inspect the diff on `src/data/licenses.json` (size + entry count delta should be plausible).
|
||||
- Commit on `main` if the file changed. No npm script wrapper exists on purpose — adding one to `package.json` would trigger the `nix-npm-deps-hash-sync` workflow on every push. Contributors consume the committed JSON as-is.
|
||||
|
||||
### Step B: Promote to RC (`next`)
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env node
|
||||
// Generates src/data/licenses.json — the data backing Settings → System → Open Source Licenses.
|
||||
//
|
||||
// Maintainer-only operation: run before each release to refresh the bundled
|
||||
// dependency list. Requires `cargo-about` installed globally
|
||||
// (`cargo install cargo-about --features cli`). The npm-side license data is
|
||||
// read via npx without persisting a devDependency, so package.json stays clean
|
||||
// and the nix-npm-deps-hash-sync workflow is not triggered.
|
||||
//
|
||||
// Output lives in src/ (not public/) so Vite bundles it as a lazy JS chunk
|
||||
// — no runtime fetch, the data is fixed into the build artifact.
|
||||
//
|
||||
// Output format (per entry):
|
||||
// {
|
||||
// name: string,
|
||||
// version: string,
|
||||
// source: 'npm' | 'cargo',
|
||||
// licenses: string[], // SPDX ids, may be multi (dual-licensed)
|
||||
// repository?: string,
|
||||
// homepage?: string,
|
||||
// description?: string,
|
||||
// publisher?: string,
|
||||
// licenseText?: string, // full license text, may be empty if not found
|
||||
// }
|
||||
//
|
||||
// Usage: node scripts/generate-licenses.mjs
|
||||
|
||||
import { execSync, spawnSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, existsSync, statSync, readdirSync, mkdirSync } from 'node:fs';
|
||||
import { resolve, dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = resolve(__dirname, '..');
|
||||
const TAURI_DIR = join(REPO_ROOT, 'src-tauri');
|
||||
const OUT_DIR = join(REPO_ROOT, 'src', 'data');
|
||||
const OUT_PATH = join(OUT_DIR, 'licenses.json');
|
||||
|
||||
function die(msg) {
|
||||
console.error(`\x1b[31m✗\x1b[0m ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function step(msg) {
|
||||
console.log(`\x1b[36m→\x1b[0m ${msg}`);
|
||||
}
|
||||
|
||||
function ok(msg) {
|
||||
console.log(`\x1b[32m✓\x1b[0m ${msg}`);
|
||||
}
|
||||
|
||||
// ── Cargo side via cargo-about ──────────────────────────────────────────────
|
||||
function checkCargoAbout() {
|
||||
try {
|
||||
execSync('cargo about --version', { stdio: 'ignore' });
|
||||
} catch {
|
||||
die(
|
||||
'cargo-about not found. Install with:\n' +
|
||||
' cargo install cargo-about --features cli',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function generateCargoLicenses() {
|
||||
step('Running cargo-about (this resolves the full dependency graph and may take ~30s)…');
|
||||
const result = spawnSync(
|
||||
'cargo',
|
||||
[
|
||||
'about',
|
||||
'generate',
|
||||
'about.hbs',
|
||||
'--config', 'about.toml',
|
||||
'--manifest-path', 'Cargo.toml',
|
||||
'--all-features',
|
||||
],
|
||||
{ cwd: TAURI_DIR, encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024 },
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
console.error(result.stderr);
|
||||
die('cargo-about failed.');
|
||||
}
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
if (!parsed?.licenses) die('cargo-about output missing `licenses` key.');
|
||||
|
||||
// Flatten: pivot from license-grouped to crate-grouped.
|
||||
const byCrate = new Map();
|
||||
for (const lic of parsed.licenses) {
|
||||
for (const u of lic.used_by ?? []) {
|
||||
const key = `${u.name}@${u.version}`;
|
||||
if (!byCrate.has(key)) {
|
||||
byCrate.set(key, {
|
||||
name: u.name,
|
||||
version: u.version,
|
||||
source: 'cargo',
|
||||
licenses: [],
|
||||
repository: u.repository || undefined,
|
||||
description: u.description || undefined,
|
||||
licenseText: '',
|
||||
});
|
||||
}
|
||||
const entry = byCrate.get(key);
|
||||
if (!entry.licenses.includes(lic.id)) entry.licenses.push(lic.id);
|
||||
// Append text — a crate may have multiple licenses (dual). Keep them all,
|
||||
// separated by a clear divider.
|
||||
if (lic.text) {
|
||||
if (entry.licenseText) {
|
||||
entry.licenseText += `\n\n--- ${lic.id} ---\n\n${lic.text}`;
|
||||
} else {
|
||||
entry.licenseText = lic.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ok(`cargo: ${byCrate.size} crates`);
|
||||
return [...byCrate.values()];
|
||||
}
|
||||
|
||||
// ── npm side via license-checker-rseidelsohn (npx) ──────────────────────────
|
||||
function readLicenseFile(path) {
|
||||
if (!path || !existsSync(path)) return '';
|
||||
try {
|
||||
return readFileSync(path, 'utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find a notice/copyright file alongside the license file.
|
||||
function findExtraLicenseTexts(packagePath) {
|
||||
if (!packagePath || !existsSync(packagePath)) return '';
|
||||
let extra = '';
|
||||
try {
|
||||
const entries = readdirSync(packagePath);
|
||||
for (const name of entries) {
|
||||
const upper = name.toUpperCase();
|
||||
if (
|
||||
upper === 'NOTICE' ||
|
||||
upper === 'NOTICE.MD' ||
|
||||
upper === 'NOTICE.TXT' ||
|
||||
upper === 'COPYRIGHT' ||
|
||||
upper === 'COPYRIGHT.MD' ||
|
||||
upper === 'COPYRIGHT.TXT'
|
||||
) {
|
||||
const full = join(packagePath, name);
|
||||
if (statSync(full).isFile()) {
|
||||
const txt = readFileSync(full, 'utf-8').trim();
|
||||
if (txt) extra += `\n\n--- ${name} ---\n\n${txt}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return extra;
|
||||
}
|
||||
|
||||
function generateNpmLicenses() {
|
||||
step('Running license-checker-rseidelsohn via npx (this enumerates node_modules)…');
|
||||
const result = spawnSync(
|
||||
'npx',
|
||||
[
|
||||
'--yes',
|
||||
'license-checker-rseidelsohn@latest',
|
||||
'--json',
|
||||
'--production',
|
||||
'--excludePrivatePackages',
|
||||
],
|
||||
{ cwd: REPO_ROOT, encoding: 'utf-8', maxBuffer: 256 * 1024 * 1024 },
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
console.error(result.stderr);
|
||||
die('license-checker-rseidelsohn failed.');
|
||||
}
|
||||
const data = JSON.parse(result.stdout);
|
||||
const out = [];
|
||||
for (const [key, info] of Object.entries(data)) {
|
||||
// key looks like `package-name@1.2.3` or `@scope/name@1.2.3`.
|
||||
const at = key.lastIndexOf('@');
|
||||
if (at <= 0) continue;
|
||||
const name = key.slice(0, at);
|
||||
const version = key.slice(at + 1);
|
||||
const licenses = Array.isArray(info.licenses)
|
||||
? info.licenses
|
||||
: typeof info.licenses === 'string'
|
||||
? [info.licenses]
|
||||
: [];
|
||||
const text =
|
||||
readLicenseFile(info.licenseFile) +
|
||||
findExtraLicenseTexts(info.path);
|
||||
out.push({
|
||||
name,
|
||||
version,
|
||||
source: 'npm',
|
||||
licenses: licenses.map(String),
|
||||
repository: info.repository || undefined,
|
||||
homepage: info.url || undefined,
|
||||
publisher: info.publisher || undefined,
|
||||
description: undefined, // license-checker doesn't surface descriptions
|
||||
licenseText: text.trim(),
|
||||
});
|
||||
}
|
||||
ok(`npm: ${out.length} packages`);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Self ────────────────────────────────────────────────────────────────────
|
||||
function readSelfPackageJson() {
|
||||
const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf-8'));
|
||||
return {
|
||||
name: pkg.name ?? 'psysonic',
|
||||
version: pkg.version ?? '0.0.0',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Main ────────────────────────────────────────────────────────────────────
|
||||
function main() {
|
||||
step(`Generating ${OUT_PATH.replace(REPO_ROOT + '/', '')}`);
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
checkCargoAbout();
|
||||
const cargoEntries = generateCargoLicenses();
|
||||
const npmEntries = generateNpmLicenses();
|
||||
|
||||
const entries = [...cargoEntries, ...npmEntries];
|
||||
// Stable sort: name, then version. Case-insensitive on name.
|
||||
entries.sort((a, b) => {
|
||||
const n = a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
if (n !== 0) return n;
|
||||
return a.version.localeCompare(b.version);
|
||||
});
|
||||
|
||||
const self = readSelfPackageJson();
|
||||
const stats = {
|
||||
npm: npmEntries.length,
|
||||
cargo: cargoEntries.length,
|
||||
total: entries.length,
|
||||
withFullText: entries.filter((e) => e.licenseText && e.licenseText.length > 0).length,
|
||||
};
|
||||
|
||||
const payload = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
project: self,
|
||||
stats,
|
||||
entries,
|
||||
};
|
||||
|
||||
writeFileSync(OUT_PATH, JSON.stringify(payload, null, 0) + '\n');
|
||||
const sizeKb = (statSync(OUT_PATH).size / 1024).toFixed(1);
|
||||
ok(`Wrote ${OUT_PATH} (${stats.total} entries, ${sizeKb} KB)`);
|
||||
ok(` ${stats.cargo} cargo + ${stats.npm} npm; ${stats.withFullText} with full license text`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,26 @@
|
||||
{{!--
|
||||
cargo-about template — emits JSON grouped by license. The Node generator
|
||||
(scripts/generate-licenses.mjs) flattens this into a per-crate list and
|
||||
merges it with the npm license data into the final public/licenses.json.
|
||||
--}}
|
||||
{
|
||||
"licenses": [
|
||||
{{#each licenses}}
|
||||
{
|
||||
"id": "{{id}}",
|
||||
"name": "{{name}}",
|
||||
"text": {{json text}},
|
||||
"used_by": [
|
||||
{{#each used_by}}
|
||||
{
|
||||
"name": "{{crate.name}}",
|
||||
"version": "{{crate.version}}",
|
||||
"repository": {{json crate.repository}},
|
||||
"description": {{json crate.description}}
|
||||
}{{#unless @last}},{{/unless}}
|
||||
{{/each}}
|
||||
]
|
||||
}{{#unless @last}},{{/unless}}
|
||||
{{/each}}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# cargo-about config — controls which licenses are allowed in the dependency
|
||||
# tree and how the licenses listing is generated. Permissive list: anything
|
||||
# the Rust ecosystem commonly publishes under, plus a few exceptions.
|
||||
#
|
||||
# Run via `npm run licenses:generate` (which calls `cargo about generate`).
|
||||
|
||||
accepted = [
|
||||
"MIT",
|
||||
"Apache-2.0",
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"ISC",
|
||||
"MPL-2.0",
|
||||
"Unicode-DFS-2016",
|
||||
"Unicode-3.0",
|
||||
"Zlib",
|
||||
"0BSD",
|
||||
"CC0-1.0",
|
||||
"Unlicense",
|
||||
"OpenSSL",
|
||||
"BSL-1.0",
|
||||
"CDLA-Permissive-2.0",
|
||||
]
|
||||
|
||||
# Skip the build host's own platform-pinning; we want a list across all targets
|
||||
# we ship binaries for so the Linux user sees Linux-only deps too, etc.
|
||||
targets = [
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"x86_64-pc-windows-msvc",
|
||||
"x86_64-apple-darwin",
|
||||
"aarch64-apple-darwin",
|
||||
]
|
||||
|
||||
# Show transitive deps (we want everything in the binary) but skip dev-deps
|
||||
# (test-only crates aren't in the shipped artifact).
|
||||
ignore-build-dependencies = false
|
||||
ignore-dev-dependencies = true
|
||||
ignore-transitive-dependencies = false
|
||||
filter-noassertion = false
|
||||
workarounds = ["ring"]
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, ExternalLink } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { open as openExternal } from '@tauri-apps/plugin-shell';
|
||||
import type { LicenseEntry } from '../utils/licensesData';
|
||||
|
||||
interface Props {
|
||||
entry: LicenseEntry | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function LicenseTextModal({ entry, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!entry) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [entry, onClose]);
|
||||
|
||||
if (!entry) return null;
|
||||
|
||||
const repoLink = entry.repository || entry.homepage;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{ alignItems: 'center', paddingTop: 0 }}
|
||||
>
|
||||
<div
|
||||
className="modal-content"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
maxWidth: '760px',
|
||||
width: '92vw',
|
||||
maxHeight: '82vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<button className="modal-close" onClick={onClose} aria-label={t('common.close')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<div style={{ marginBottom: '0.5rem', paddingRight: '2rem' }}>
|
||||
<h3 style={{ margin: 0, fontFamily: 'var(--font-display)', fontSize: '1.05rem' }}>
|
||||
{entry.name} <span style={{ color: 'var(--text-muted)', fontWeight: 400 }}>{entry.version}</span>
|
||||
</h3>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
marginTop: '0.4rem',
|
||||
fontSize: '0.78rem',
|
||||
}}
|
||||
>
|
||||
{entry.licenses.map((lid) => (
|
||||
<span
|
||||
key={lid}
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '10px',
|
||||
padding: '1px 8px',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{lid}
|
||||
</span>
|
||||
))}
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--text-muted)',
|
||||
textTransform: 'uppercase',
|
||||
fontSize: '0.7rem',
|
||||
letterSpacing: '0.04em',
|
||||
}}
|
||||
>
|
||||
{entry.source}
|
||||
</span>
|
||||
{repoLink && (
|
||||
<button
|
||||
onClick={() => openExternal(repoLink)}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
fontSize: '0.78rem',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={12} /> {t('licenses.viewSource')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{entry.description && (
|
||||
<p style={{ color: 'var(--text-muted)', marginTop: '0.6rem', marginBottom: 0, fontSize: '0.85rem' }}>
|
||||
{entry.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: '12px 14px',
|
||||
overflow: 'auto',
|
||||
flex: 1,
|
||||
fontFamily: 'var(--font-mono, ui-monospace, monospace)',
|
||||
fontSize: '0.78rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
color: 'var(--text-primary)',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{entry.licenseText || t('licenses.noLicenseText')}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Search, ExternalLink } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { open as openExternal } from '@tauri-apps/plugin-shell';
|
||||
import {
|
||||
HIGHLIGHTED_DEPENDENCIES,
|
||||
loadLicensesData,
|
||||
type LicenseEntry,
|
||||
type LicensesData,
|
||||
} from '../utils/licensesData';
|
||||
import LicenseTextModal from './LicenseTextModal';
|
||||
|
||||
const ROW_HEIGHT = 56;
|
||||
|
||||
function formatDate(iso: string, locale: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function entryKey(e: LicenseEntry): string {
|
||||
return `${e.source}:${e.name}@${e.version}`;
|
||||
}
|
||||
|
||||
function LicenseRow({
|
||||
entry,
|
||||
onSelect,
|
||||
onOpenRepo,
|
||||
}: {
|
||||
entry: LicenseEntry;
|
||||
onSelect: (e: LicenseEntry) => void;
|
||||
onOpenRepo: (url: string) => void;
|
||||
}) {
|
||||
const repo = entry.repository || entry.homepage;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(entry)}
|
||||
className="licenses-row"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: ROW_HEIGHT,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '8px 12px',
|
||||
background: 'transparent',
|
||||
border: '1px solid transparent',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
textAlign: 'left',
|
||||
color: 'var(--text-primary)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: '6px',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 500,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<span>{entry.name}</span>
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: '0.78rem', fontWeight: 400 }}>
|
||||
{entry.version}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
alignItems: 'center',
|
||||
marginTop: '2px',
|
||||
fontSize: '0.72rem',
|
||||
color: 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
<span style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>{entry.source}</span>
|
||||
<span>·</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{entry.licenses.join(', ') || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{repo && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenRepo(repo);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onOpenRepo(repo);
|
||||
}
|
||||
}}
|
||||
aria-label="Open repository"
|
||||
style={{
|
||||
color: 'var(--text-muted)',
|
||||
padding: '6px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
display: 'inline-flex',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LicensesPanel() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [data, setData] = useState<LicensesData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [selected, setSelected] = useState<LicenseEntry | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadLicensesData()
|
||||
.then((d) => {
|
||||
if (!cancelled) setData(d);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError(String(e?.message ?? e));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const highlights = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const byKey = new Map<string, LicenseEntry>();
|
||||
for (const e of data.entries) {
|
||||
byKey.set(`${e.source}:${e.name}`, e);
|
||||
}
|
||||
return HIGHLIGHTED_DEPENDENCIES.map((h) => byKey.get(`${h.source}:${h.name}`)).filter(
|
||||
(e): e is LicenseEntry => e != null,
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return data.entries;
|
||||
return data.entries.filter((e) => {
|
||||
if (e.name.toLowerCase().includes(q)) return true;
|
||||
if (e.version.toLowerCase().includes(q)) return true;
|
||||
for (const lid of e.licenses) {
|
||||
if (lid.toLowerCase().includes(q)) return true;
|
||||
}
|
||||
if (e.source.toLowerCase().includes(q)) return true;
|
||||
return false;
|
||||
});
|
||||
}, [data, query]);
|
||||
|
||||
const scrollParentRef = useRef<HTMLDivElement | null>(null);
|
||||
const virtualizer = useVirtualizer({
|
||||
count: filtered.length,
|
||||
getScrollElement: () => scrollParentRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 12,
|
||||
});
|
||||
|
||||
const openRepo = (url: string) => {
|
||||
openExternal(url).catch(() => {});
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '12px', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
||||
{t('licenses.loadError')} <span style={{ color: 'var(--danger)' }}>{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div style={{ padding: '12px', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
||||
{t('licenses.loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: '0.82rem', lineHeight: 1.55 }}>
|
||||
{t('licenses.intro')}
|
||||
</div>
|
||||
|
||||
{/* Highlight block */}
|
||||
{highlights.length > 0 && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
color: 'var(--text-muted)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
marginBottom: '6px',
|
||||
}}
|
||||
>
|
||||
{t('licenses.highlights')}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
{highlights.map((e) => (
|
||||
<LicenseRow key={entryKey(e)} entry={e} onSelect={setSelected} onOpenRepo={openRepo} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '6px 10px',
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
<Search size={14} style={{ color: 'var(--text-muted)' }} />
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder={t('licenses.searchPlaceholder')}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
padding: '2px 0',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Virtual list */}
|
||||
<div
|
||||
ref={scrollParentRef}
|
||||
style={{
|
||||
height: '420px',
|
||||
overflow: 'auto',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--bg-secondary, var(--bg))',
|
||||
}}
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ padding: '14px', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
||||
{t('licenses.noResults')}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vi) => {
|
||||
const entry = filtered[vi.index];
|
||||
return (
|
||||
<div
|
||||
key={vi.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${vi.start}px)`,
|
||||
height: vi.size,
|
||||
}}
|
||||
>
|
||||
<LicenseRow entry={entry} onSelect={setSelected} onOpenRepo={openRepo} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.72rem',
|
||||
color: 'var(--text-muted)',
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{t('licenses.totalLine', {
|
||||
total: data.stats.total,
|
||||
cargo: data.stats.cargo,
|
||||
npm: data.stats.npm,
|
||||
})}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{t('licenses.generatedAt', { date: formatDate(data.generatedAt, i18n.language) })}</span>
|
||||
</div>
|
||||
|
||||
<LicenseTextModal entry={selected} onClose={() => setSelected(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1796,4 +1796,17 @@ export const deTranslation = {
|
||||
exitPsysonic: 'Psysonic beenden',
|
||||
nothingPlaying: 'Nichts wird abgespielt',
|
||||
},
|
||||
licenses: {
|
||||
title: 'Open-Source-Lizenzen',
|
||||
intro: 'Psysonic basiert auf Open-Source-Software. Die untenstehende Liste zeigt jede Abhängigkeit, die mit der App ausgeliefert wird, inklusive Version, Lizenz und vollständigem Lizenztext.',
|
||||
highlights: 'Wichtige Abhängigkeiten',
|
||||
searchPlaceholder: 'Suche nach Name, Version oder Lizenz…',
|
||||
noResults: 'Keine Treffer.',
|
||||
loading: 'Lizenzen werden geladen…',
|
||||
loadError: 'Lizenzdaten konnten nicht geladen werden.',
|
||||
noLicenseText: 'Für diese Abhängigkeit ist kein Lizenztext gebündelt.',
|
||||
viewSource: 'Quelle anzeigen',
|
||||
totalLine: '{{total}} Abhängigkeiten — {{cargo}} Cargo, {{npm}} npm',
|
||||
generatedAt: 'Zuletzt generiert {{date}}',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1803,4 +1803,17 @@ export const enTranslation = {
|
||||
exitPsysonic: 'Exit Psysonic',
|
||||
nothingPlaying: 'Nothing playing',
|
||||
},
|
||||
licenses: {
|
||||
title: 'Open Source Licenses',
|
||||
intro: 'Psysonic is built on open-source software. The list below shows every dependency that ships in the app, with its version, license, and full license text.',
|
||||
highlights: 'Key dependencies',
|
||||
searchPlaceholder: 'Search by name, version or license…',
|
||||
noResults: 'No matches.',
|
||||
loading: 'Loading licenses…',
|
||||
loadError: 'Could not load license data.',
|
||||
noLicenseText: 'No license text bundled for this dependency.',
|
||||
viewSource: 'View source',
|
||||
totalLine: '{{total}} dependencies — {{cargo}} cargo, {{npm}} npm',
|
||||
generatedAt: 'Last generated {{date}}',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1765,4 +1765,17 @@ export const esTranslation = {
|
||||
exitPsysonic: 'Salir de Psysonic',
|
||||
nothingPlaying: 'Nada en reproducción',
|
||||
},
|
||||
licenses: {
|
||||
title: 'Licencias de Código Abierto',
|
||||
intro: 'Psysonic se basa en software de código abierto. La lista siguiente muestra cada dependencia incluida con la aplicación, con su versión, licencia y el texto completo de la licencia.',
|
||||
highlights: 'Dependencias principales',
|
||||
searchPlaceholder: 'Buscar por nombre, versión o licencia…',
|
||||
noResults: 'Sin resultados.',
|
||||
loading: 'Cargando licencias…',
|
||||
loadError: 'No se pudieron cargar los datos de licencia.',
|
||||
noLicenseText: 'No hay texto de licencia incluido para esta dependencia.',
|
||||
viewSource: 'Ver fuente',
|
||||
totalLine: '{{total}} dependencias — {{cargo}} cargo, {{npm}} npm',
|
||||
generatedAt: 'Última generación {{date}}',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1760,4 +1760,17 @@ export const frTranslation = {
|
||||
exitPsysonic: 'Quitter Psysonic',
|
||||
nothingPlaying: 'Aucune lecture',
|
||||
},
|
||||
licenses: {
|
||||
title: 'Licences Open Source',
|
||||
intro: 'Psysonic est construit sur des logiciels open source. La liste ci-dessous montre chaque dépendance livrée avec l\'application, avec sa version, sa licence et le texte de licence complet.',
|
||||
highlights: 'Dépendances principales',
|
||||
searchPlaceholder: 'Rechercher par nom, version ou licence…',
|
||||
noResults: 'Aucun résultat.',
|
||||
loading: 'Chargement des licences…',
|
||||
loadError: 'Impossible de charger les données de licence.',
|
||||
noLicenseText: 'Aucun texte de licence groupé pour cette dépendance.',
|
||||
viewSource: 'Voir la source',
|
||||
totalLine: '{{total}} dépendances — {{cargo}} cargo, {{npm}} npm',
|
||||
generatedAt: 'Dernière génération {{date}}',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1759,4 +1759,17 @@ export const nbTranslation = {
|
||||
exitPsysonic: 'Avslutt Psysonic',
|
||||
nothingPlaying: 'Ingenting spilles',
|
||||
},
|
||||
licenses: {
|
||||
title: 'Åpen kildekode-lisenser',
|
||||
intro: 'Psysonic er bygget på åpen kildekode-programvare. Listen nedenfor viser hver avhengighet som leveres med appen, med versjon, lisens og full lisenstekst.',
|
||||
highlights: 'Sentrale avhengigheter',
|
||||
searchPlaceholder: 'Søk etter navn, versjon eller lisens…',
|
||||
noResults: 'Ingen treff.',
|
||||
loading: 'Laster lisenser…',
|
||||
loadError: 'Kunne ikke laste inn lisensdata.',
|
||||
noLicenseText: 'Ingen lisenstekst inkludert for denne avhengigheten.',
|
||||
viewSource: 'Vis kilde',
|
||||
totalLine: '{{total}} avhengigheter — {{cargo}} cargo, {{npm}} npm',
|
||||
generatedAt: 'Sist generert {{date}}',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1759,4 +1759,17 @@ export const nlTranslation = {
|
||||
exitPsysonic: 'Psysonic afsluiten',
|
||||
nothingPlaying: 'Niets wordt afgespeeld',
|
||||
},
|
||||
licenses: {
|
||||
title: 'Open-source licenties',
|
||||
intro: 'Psysonic is gebouwd op open-source software. De onderstaande lijst toont elke afhankelijkheid die met de app wordt meegeleverd, met versie, licentie en volledige licentietekst.',
|
||||
highlights: 'Belangrijke afhankelijkheden',
|
||||
searchPlaceholder: 'Zoek op naam, versie of licentie…',
|
||||
noResults: 'Geen resultaten.',
|
||||
loading: 'Licenties laden…',
|
||||
loadError: 'Licentiegegevens konden niet worden geladen.',
|
||||
noLicenseText: 'Geen licentietekst meegeleverd voor deze afhankelijkheid.',
|
||||
viewSource: 'Bron bekijken',
|
||||
totalLine: '{{total}} afhankelijkheden — {{cargo}} cargo, {{npm}} npm',
|
||||
generatedAt: 'Laatst gegenereerd {{date}}',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1846,4 +1846,17 @@ export const ruTranslation = {
|
||||
exitPsysonic: 'Закрыть Psysonic',
|
||||
nothingPlaying: 'Ничего не воспроизводится',
|
||||
},
|
||||
licenses: {
|
||||
title: 'Лицензии открытого кода',
|
||||
intro: 'Psysonic построен на программном обеспечении с открытым исходным кодом. Список ниже показывает каждую зависимость, поставляемую с приложением, с версией, лицензией и полным текстом лицензии.',
|
||||
highlights: 'Ключевые зависимости',
|
||||
searchPlaceholder: 'Поиск по имени, версии или лицензии…',
|
||||
noResults: 'Нет совпадений.',
|
||||
loading: 'Загрузка лицензий…',
|
||||
loadError: 'Не удалось загрузить данные о лицензиях.',
|
||||
noLicenseText: 'Текст лицензии для этой зависимости не включён.',
|
||||
viewSource: 'Открыть исходник',
|
||||
totalLine: '{{total}} зависимостей — {{cargo}} cargo, {{npm}} npm',
|
||||
generatedAt: 'Последняя генерация {{date}}',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1752,4 +1752,17 @@ export const zhTranslation = {
|
||||
exitPsysonic: '退出 Psysonic',
|
||||
nothingPlaying: '当前没有播放',
|
||||
},
|
||||
licenses: {
|
||||
title: '开源许可证',
|
||||
intro: 'Psysonic 基于开源软件构建。下方列表显示了应用程序中包含的每个依赖项的版本、许可证以及完整的许可证文本。',
|
||||
highlights: '主要依赖项',
|
||||
searchPlaceholder: '按名称、版本或许可证搜索…',
|
||||
noResults: '没有匹配项。',
|
||||
loading: '加载许可证…',
|
||||
loadError: '无法加载许可证数据。',
|
||||
noLicenseText: '此依赖项未包含许可证文本。',
|
||||
viewSource: '查看源代码',
|
||||
totalLine: '{{total}} 个依赖项 — {{cargo}} cargo, {{npm}} npm',
|
||||
generatedAt: '最后生成时间 {{date}}',
|
||||
},
|
||||
};
|
||||
|
||||
+10
-1
@@ -6,7 +6,7 @@ import {
|
||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
|
||||
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
|
||||
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle, Maximize2, AudioLines, User, Lock,
|
||||
Users, UserPlus, Shield, Wand2, Search
|
||||
Users, UserPlus, Shield, Wand2, Search, Scale
|
||||
} from 'lucide-react';
|
||||
import i18n from '../i18n';
|
||||
import { exportBackup, importBackup } from '../utils/backup';
|
||||
@@ -22,6 +22,7 @@ import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, Las
|
||||
import LastfmIcon from '../components/LastfmIcon';
|
||||
import CustomSelect from '../components/CustomSelect';
|
||||
import SettingsSubSection from '../components/SettingsSubSection';
|
||||
import LicensesPanel from '../components/LicensesPanel';
|
||||
import { AboutPsysonicBrandHeader } from '../components/AboutPsysonicLol';
|
||||
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
|
||||
import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker';
|
||||
@@ -439,6 +440,7 @@ const SETTINGS_INDEX: SearchIndexEntry[] = [
|
||||
{ tab: 'system', titleKey: 'settings.loggingTitle', keywords: 'log logs diagnostic debug verbose' },
|
||||
{ tab: 'system', titleKey: 'settings.aboutTitle', keywords: 'about version update changelog release notes' },
|
||||
{ tab: 'system', titleKey: 'settings.aboutContributorsLabel', keywords: 'contributors credits maintainers' },
|
||||
{ tab: 'system', titleKey: 'licenses.title', keywords: 'licenses license open source attribution copyright third party dependencies oss' },
|
||||
];
|
||||
|
||||
// Substring-first, Fuzzy-Fallback (alle Query-Zeichen in Reihenfolge im
|
||||
@@ -4574,6 +4576,13 @@ export default function Settings() {
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
title={t('licenses.title')}
|
||||
icon={<Scale size={16} />}
|
||||
>
|
||||
<LicensesPanel />
|
||||
</SettingsSubSection>
|
||||
|
||||
</>
|
||||
)}
|
||||
</>}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Lazy loader + types for the bundled `src/data/licenses.json` data.
|
||||
//
|
||||
// The JSON is generated by `node scripts/generate-licenses.mjs` (maintainer-only)
|
||||
// and committed to source. Vite bundles it as a separate JS chunk via dynamic
|
||||
// import below, so the heavy ~1–2 MB payload is only loaded when the user
|
||||
// actually opens the Open Source Licenses panel — no runtime fetch, the data
|
||||
// lives entirely inside the build artifact.
|
||||
|
||||
export interface LicenseEntry {
|
||||
name: string;
|
||||
version: string;
|
||||
source: 'npm' | 'cargo';
|
||||
licenses: string[];
|
||||
repository?: string;
|
||||
homepage?: string;
|
||||
description?: string;
|
||||
publisher?: string;
|
||||
licenseText?: string;
|
||||
}
|
||||
|
||||
export interface LicensesData {
|
||||
generatedAt: string;
|
||||
project: { name: string; version: string };
|
||||
stats: { npm: number; cargo: number; total: number; withFullText: number };
|
||||
entries: LicenseEntry[];
|
||||
}
|
||||
|
||||
let cache: Promise<LicensesData> | null = null;
|
||||
|
||||
export function loadLicensesData(): Promise<LicensesData> {
|
||||
if (cache) return cache;
|
||||
cache = import('../data/licenses.json')
|
||||
.then((mod) => (mod.default ?? mod) as LicensesData)
|
||||
.catch((e) => {
|
||||
cache = null;
|
||||
throw e;
|
||||
});
|
||||
return cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Curated list of dependency names shown in the highlight block at the top of
|
||||
* the panel — the user-recognisable backbone of the app. Order matters here;
|
||||
* the panel renders them top-to-bottom as listed.
|
||||
*/
|
||||
export const HIGHLIGHTED_DEPENDENCIES: Array<{ source: 'npm' | 'cargo'; name: string }> = [
|
||||
{ source: 'cargo', name: 'tauri' },
|
||||
{ source: 'npm', name: 'react' },
|
||||
{ source: 'npm', name: 'zustand' },
|
||||
{ source: 'cargo', name: 'rodio' },
|
||||
{ source: 'cargo', name: 'symphonia' },
|
||||
{ source: 'cargo', name: 'cpal' },
|
||||
{ source: 'npm', name: 'axios' },
|
||||
{ source: 'npm', name: '@tanstack/react-virtual' },
|
||||
{ source: 'npm', name: 'react-i18next' },
|
||||
{ source: 'npm', name: 'lucide-react' },
|
||||
];
|
||||
Reference in New Issue
Block a user