Files
punktfunk/scripts/gen-third-party-notices.py
T
enricobuehlerandClaude Opus 5 5520167958 feat(clients): the OS marks tell gaming distros apart, and Windows looks current
Three things were wrong with the host-card OS icons.

The Windows mark was Font Awesome 5's, which is still the Windows 8/10 flag
with the perspective skew — dated next to the flat four-pane mark Microsoft
has shipped since Windows 11. No icon set has the current one (Simple Icons
carries no windows/microsoft slug at all), so it is drawn here: four equal
squares at the authentic 11.377 + 1.246 proportion. The Decky plugin was
pulling FaWindows straight from react-icons, so it now inlines the masters
like the web console does, or it would have kept the old flag regardless.

Bazzite, CachyOS and Nobara collapsed onto their family's mark. The host
already advertises the full chain, so this is purely missing art: all three
now ship a leaf mark, because "a Bazzite box" and "a Fedora box" are
different machines to the person reading the card. CachyOS and Nobara come
from Simple Icons; Bazzite has no icon anywhere, so its "b" is lifted out of
the project's own badge (Apache-2.0, attributed).

On Android every non-square mark was stretched. A VectorPainter maps the
viewport onto the ImageVector's default size with independent x and y
scales, so declaring a 448x512 Tux as 24x24 dp squashed it — silently, no
crash, no warning. The longest viewport edge now sets the 24 dp box and the
other follows the ratio, which is what Icon()'s ContentScale.Fit expects.
A unit test pins the invariant; every other client was already correct.

Also: scripts/gen-os-icons.sh replaces the undocumented hand-run pipeline
that turns a master into the GTK symbolic SVG, the Windows PNG and the Apple
template PDF. It reproduces the committed artifacts byte-identically.

Verified: web and Decky typecheck, Decky bundles, Android compiles and its
tests pass, PunktfunkKit builds, osinfo's tests pass. Not verified on glass,
and the GTK/Windows client crates do not build on macOS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:17:02 +02:00

196 lines
7.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""Generate THIRD-PARTY-NOTICES.txt for the Rust workspace.
Offline, dependency-free attribution generator. It reads `cargo metadata`, then for every
third-party crate (everything that is NOT a first-party workspace member) it pulls the crate's
*actual* LICENSE/COPYING/NOTICE text out of the local cargo registry cache (or the in-tree
vendored source for path deps), deduplicates identical license texts, and emits a single
notices file: a per-crate manifest followed by the verbatim license texts.
This satisfies the binary-distribution attribution duty for the permissive (MIT/BSD/ISC/Zlib/
Apache/Unicode/etc.) crates linked into shipped punktfunk artifacts. `cargo about` (see
about.toml) produces an equivalent, network-augmented result in CI; this is the dependency-free
fallback that also runs locally and is committed as a baseline.
Usage: python3 scripts/gen-third-party-notices.py [--out THIRD-PARTY-NOTICES.txt]
"""
import argparse
import hashlib
import json
import os
import subprocess
import sys
LICENSE_GLOBS = ("license", "licence", "copying", "notice", "unlicense", "copyright")
def find_license_files(pkg_dir):
out = []
try:
names = sorted(os.listdir(pkg_dir))
except OSError:
return out
for n in names:
low = n.lower()
if any(low == g or low.startswith(g + ".") or low.startswith(g + "-") or g in low for g in LICENSE_GLOBS):
p = os.path.join(pkg_dir, n)
if os.path.isfile(p):
try:
with open(p, "r", encoding="utf-8", errors="replace") as f:
txt = f.read().strip()
if txt:
out.append((n, txt))
except OSError:
pass
return out
# Crates that ship license-mit/license-apache-2.0 files but omit the `license` field in
# their Cargo.toml (publish = false upstream), so `cargo metadata` reports no SPDX for them.
LICENSE_OVERRIDES = {
"windows-reactor-setup": "MIT OR Apache-2.0",
}
# Third-party source trees VENDORED inside first-party workspace crates — the
# workspace-member skip in main() hides them from `cargo metadata`, so they are listed
# here explicitly: (label, license file relative to the repo root, source URL).
VENDORED_TREES = [
("pyrowave (vendored, crates/pyrowave-sys)",
"crates/pyrowave-sys/vendor/pyrowave/LICENSE",
"https://github.com/Themaister/pyrowave"),
("Granite subset (vendored, crates/pyrowave-sys)",
"crates/pyrowave-sys/vendor/pyrowave/Granite/LICENSE",
"https://github.com/Themaister/Granite"),
("volk (vendored, crates/pyrowave-sys)",
"crates/pyrowave-sys/vendor/pyrowave/Granite/third_party/volk/LICENSE.md",
"https://github.com/zeux/volk"),
("Vulkan-Headers (vendored, crates/pyrowave-sys)",
"crates/pyrowave-sys/vendor/pyrowave/Granite/third_party/khronos/vulkan-headers/LICENSE.md",
"https://github.com/KhronosGroup/Vulkan-Headers"),
# OS brand marks for the host-card OS icon (assets/os-icons/, CC BY 4.0 / CC0 /
# Apache-2.0 — see assets/os-icons/README.md; clients embed per-platform derivatives).
("Font Awesome Free brand icons (vendored, assets/os-icons)",
"assets/os-icons/LICENSES/font-awesome-brands.txt",
"https://fontawesome.com"),
("Simple Icons (vendored, assets/os-icons)",
"assets/os-icons/LICENSES/simple-icons.txt",
"https://simpleicons.org"),
("Bazzite logo (vendored, assets/os-icons)",
"assets/os-icons/LICENSES/bazzite.txt",
"https://github.com/ublue-os/bazzite"),
]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--out", default="THIRD-PARTY-NOTICES.txt")
ap.add_argument("--manifest", default="Cargo.toml")
args = ap.parse_args()
meta = json.loads(subprocess.check_output(
["cargo", "metadata", "--format-version", "1", "--offline", "--manifest-path", args.manifest],
text=True))
ws_members = set(meta.get("workspace_members", []))
pkgs = []
for p in meta["packages"]:
if p["id"] in ws_members:
continue # first-party (covered by the root LICENSE-MIT / LICENSE-APACHE)
pkgs.append(p)
pkgs.sort(key=lambda p: (p["name"].lower(), p["version"]))
# Group license texts: text-hash -> {text, name, crates[]}
texts = {}
no_text = []
for p in pkgs:
pkg_dir = os.path.dirname(p["manifest_path"])
files = find_license_files(pkg_dir)
label = f'{p["name"]} {p["version"]}'
if not files:
no_text.append(p)
continue
for fname, txt in files:
h = hashlib.sha256(txt.encode("utf-8", "replace")).hexdigest()
ent = texts.setdefault(h, {"text": txt, "filename": fname, "crates": set()})
ent["crates"].add(label)
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
vendored = []
for label, lic_path, url in VENDORED_TREES:
full = os.path.join(repo_root, lic_path)
try:
with open(full, encoding="utf-8", errors="replace") as f:
txt = f.read().strip()
except OSError:
print(f"WARNING: vendored license missing: {lic_path}", file=sys.stderr)
continue
vendored.append((label, url))
h = hashlib.sha256(txt.encode("utf-8", "replace")).hexdigest()
ent = texts.setdefault(
h, {"text": txt, "filename": os.path.basename(lic_path), "crates": set()}
)
ent["crates"].add(label)
lines = []
w = lines.append
w("THIRD-PARTY SOFTWARE NOTICES")
w("=" * 76)
w("")
w("punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.")
w("The binaries it ships statically/dynamically link the third-party Rust crates listed")
w("below. Each is distributed under its own permissive license; the full license texts")
w("follow the manifest. This file is generated by scripts/gen-third-party-notices.py")
w("(or `cargo about`, see about.toml) — do not edit by hand.")
w("")
w(f"Total third-party crates: {len(pkgs)}")
w("")
if vendored:
w("-" * 76)
w("VENDORED THIRD-PARTY SOURCE (inside first-party crates)")
w("-" * 76)
for label, url in vendored:
w(f" {label}{url}")
w("")
w("-" * 76)
w("MANIFEST (crate version — SPDX license — source)")
w("-" * 76)
for p in pkgs:
lic = p.get("license") or LICENSE_OVERRIDES.get(p["name"]) or (("file: " + p["license_file"]) if p.get("license_file") else "UNKNOWN")
repo = p.get("repository") or ""
w(f' {p["name"]} {p["version"]}{lic}' + (f' — {repo}' if repo else ""))
w("")
if no_text:
w("-" * 76)
w("Crates whose package did not embed a license file (SPDX + source only)")
w("-" * 76)
for p in no_text:
lic = p.get("license") or LICENSE_OVERRIDES.get(p["name"]) or "UNKNOWN"
repo = p.get("repository") or ""
w(f' {p["name"]} {p["version"]}{lic}' + (f' — {repo}' if repo else ""))
w("")
w("=" * 76)
w("FULL LICENSE TEXTS (deduplicated)")
w("=" * 76)
# Stable order: by first crate name covered.
for h, ent in sorted(texts.items(), key=lambda kv: sorted(kv[1]["crates"])[0].lower()):
crates = ", ".join(sorted(ent["crates"]))
w("")
w("-" * 76)
w(f"The following license ({ent['filename']}) applies to: {crates}")
w("-" * 76)
w(ent["text"])
w("")
text = "\n".join(lines) + "\n"
with open(args.out, "w", encoding="utf-8") as f:
f.write(text)
print(f"wrote {args.out}: {len(pkgs)} crates, {len(texts)} distinct license texts, "
f"{len(no_text)} without embedded text", file=sys.stderr)
if __name__ == "__main__":
main()