apple / swift (pull_request) Successful in 1m42s
apple / screenshots (pull_request) Skipped
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m32s
ci / rust-arm64 (pull_request) Successful in 1m52s
ci / web (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m15s
ci / bun-nix (pull_request) Successful in 18s
android / android (pull_request) Successful in 3m56s
ci / rust (pull_request) Successful in 5m40s
A launcher tile (role: "launcher", design D4) shipped no art on purpose:
a launcher's own icon is square, every client cover-crops a 2:3 poster,
and the crop turns a mark into a strip. So the tiles were the launcher's
name on a flat accent face — legible, and the blandest thing in the grid.
Entries now carry an optional `icon`: the NAME of a brand mark, never
image bytes and never a URL. `[a-z][a-z0-9-]{0,31}`, shape-validated by
the host on every lane (a client interpolates the value into a resource
name or an asset lookup, so the guard belongs upstream of all of them,
and each client re-checks rather than trusting the peer).
A token rather than art because the alternative is closed by
construction, and deliberately: the art proxy serves what the bytes ARE
(sniff_image_type) and SVG is not on that list — it is script-capable
XML and the console renders library art in a browser. Widening that
sniff would trade a rendering nicety for a stored-XSS surface. Naming
the mark keeps the refusal intact, keeps the glyph vector at whatever
size a tile happens to be, lets it take the tile's ink, and adds nothing
to a reconcile payload that is already body-limited. The cost is that a
third-party plugin cannot ship a mark no client bundles; its tile falls
back to the launcher's name, exactly as before, and the fix is a PR
adding the master.
assets/launcher-icons/ holds seven monochrome masters with per-mark
provenance and licensing (Simple Icons CC0: lutris, heroic, epic, gog;
Font Awesome CC BY: steam, xbox; Playnite's own logo, MIT). steam is
generated FROM assets/os-icons/steam.svg so the SteamOS host badge and
the Steam launcher tile can never drift.
scripts/gen-launcher-icons.sh bakes the three derivatives that cannot
consume a master (GTK symbolic SVG, Windows PNG, Apple template PDF)
and — unlike gen-os-icons.sh, which prints path data for a human to
paste — GENERATES the three inline registries (web console, Android
ImageVector, pf-console-ui Skia). Three clients x seven paths of up to
3 kB is a transcription error waiting to happen, and a mangled character
is a silently wrong logo rather than a build failure. The generated Rust
goes through rustfmt, since `cargo fmt --all --check` is a CI gate and a
generated file that fails it would fail every regeneration.
All six renderers draw the mark CONTAINED, never cover-cropped: the
masters' viewports are not square (steam 496x512, playnite 1024x1024)
and filling a 2:3 frame would reproduce the strip this exists to avoid.
Every one keeps its old fallback for a token it has no art for.
Epic, GOG and Xbox marks ship dormant. Those plugins' launcher switches
are off by default and emit nothing, because the host has no verified
launcher_ui activation for them yet — shipping the art now keeps turning
one on the one-line plugin change those plugins promise, instead of also
needing a release of all six clients.
api/openapi.json and the SDK are regenerated (the spec's version field
was stale at 0.25.0 and now reads 0.26.0, which is the crate's actual
version — an unrelated line that regeneration necessarily corrects).
Verified: host cargo check, clippy -D warnings across pf-client-core /
pf-console-ui / punktfunk-client-session / punktfunk-client-linux, plain
build, pf-console-ui tests (77, including a new one asserting all seven
masters parse under Skia and one asserting the letterbox stays inside
its box), pf-client-core tests (188), cargo fmt --all --check, Apple
swift build, Android compileDebugKotlin, web tsc + vite build,
plugin-kit tsc, biome. The Windows client is NOT compile-verified — it
cannot be built from a Mac (scripts/xcheck.sh covers only the capture
stack by design) and CI does not build it either; its tile change needs
a real box before it ships.
270 lines
12 KiB
Python
Executable File
270 lines
12 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.
|
|
|
|
By default it covers the WHOLE workspace, which is what the root file must be (the host and
|
|
the desktop clients ship out of it). `--packages <name>[,<name>…]` restricts it to the transitive
|
|
dependency closure of the named workspace members instead — the Apple and Android clients link
|
|
exactly one Rust crate each (`punktfunk-core`, and the JNI bridge over it), so a workspace-wide
|
|
copy attributed them things they do not contain: FFmpeg, the NVENC SDK, GTK, windows-rs. Listing a
|
|
dependency that is not there is not a licence violation, but it is a false statement in a file
|
|
whose entire job is to be true.
|
|
|
|
Usage: python3 scripts/gen-third-party-notices.py [--out THIRD-PARTY-NOTICES.txt]
|
|
[--packages punktfunk-core,…]
|
|
"""
|
|
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"),
|
|
# Launcher brand marks for the library's launcher tiles (assets/launcher-icons/, CC BY 4.0 /
|
|
# CC0 / MIT — see assets/launcher-icons/README.md). A separate registry from the OS marks
|
|
# above, with its own sources, so it carries its own notices even where a vendor overlaps.
|
|
("Font Awesome Free brand icons (vendored, assets/launcher-icons)",
|
|
"assets/launcher-icons/LICENSES/font-awesome-brands.txt",
|
|
"https://fontawesome.com"),
|
|
("Simple Icons (vendored, assets/launcher-icons)",
|
|
"assets/launcher-icons/LICENSES/simple-icons.txt",
|
|
"https://simpleicons.org"),
|
|
("Playnite logo (vendored, assets/launcher-icons)",
|
|
"assets/launcher-icons/LICENSES/playnite.txt",
|
|
"https://github.com/JosefNemec/Playnite"),
|
|
]
|
|
|
|
|
|
def closure(meta, roots):
|
|
"""Package ids reachable from `roots` through `cargo metadata`'s resolve graph.
|
|
|
|
Deliberately the WHOLE resolve graph, not a per-target one: `cargo metadata` resolves
|
|
every `cfg()`-gated dependency of every member, so this OVER-approximates (an
|
|
`cfg(windows)`-only crate is reachable from a root even on a Linux build). Over-listing an
|
|
attribution is the safe direction; under-listing one is the failure this file exists to
|
|
prevent. What it does NOT do is pull in crates reachable only from OTHER workspace members,
|
|
which is the whole point.
|
|
"""
|
|
by_name = {}
|
|
for p in meta["packages"]:
|
|
by_name.setdefault(p["name"], p["id"])
|
|
nodes = {n["id"]: n for n in meta.get("resolve", {}).get("nodes", [])}
|
|
seen, stack = set(), []
|
|
for r in roots:
|
|
pid = by_name.get(r)
|
|
if pid is None:
|
|
raise SystemExit(f"--packages: no package named {r!r} in this workspace")
|
|
stack.append(pid)
|
|
while stack:
|
|
pid = stack.pop()
|
|
if pid in seen:
|
|
continue
|
|
seen.add(pid)
|
|
stack.extend(nodes.get(pid, {}).get("dependencies", []))
|
|
return seen
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--out", default="THIRD-PARTY-NOTICES.txt")
|
|
ap.add_argument("--manifest", default="Cargo.toml")
|
|
ap.add_argument(
|
|
"--packages",
|
|
default="",
|
|
help="comma-separated workspace member names; restrict the notices to their transitive "
|
|
"dependency closure instead of the whole workspace",
|
|
)
|
|
args = ap.parse_args()
|
|
|
|
# `--all-features` is what makes `--packages` a GUARANTEE rather than a coincidence. Without
|
|
# it, cargo resolves the workspace with default features and unifies them across members, so a
|
|
# scoped closure can pick up a crate only because some OTHER member turned the feature on —
|
|
# and, worse, can MISS one when no member does. punktfunk-core's `quic` is exactly that case:
|
|
# it is not a default feature, and quinn/opus/rustls reach the Apple file today only through
|
|
# the workspace-wide union. Resolving every feature over-approximates instead, which is the
|
|
# safe direction for an attribution file: listing a crate that is not linked is untidy,
|
|
# omitting one that is is the failure this file exists to prevent.
|
|
meta = json.loads(subprocess.check_output(
|
|
["cargo", "metadata", "--format-version", "1", "--offline", "--all-features",
|
|
"--manifest-path", args.manifest],
|
|
text=True))
|
|
ws_members = set(meta.get("workspace_members", []))
|
|
|
|
keep = None
|
|
if args.packages.strip():
|
|
keep = closure(meta, [n.strip() for n in args.packages.split(",") if n.strip()])
|
|
|
|
pkgs = []
|
|
for p in meta["packages"]:
|
|
if p["id"] in ws_members:
|
|
continue # first-party (covered by the root LICENSE-MIT / LICENSE-APACHE)
|
|
if keep is not None and p["id"] not in keep:
|
|
continue
|
|
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.")
|
|
if keep is not None:
|
|
w("")
|
|
w(f"Scope: the Rust crates linked by {args.packages} — not the whole punktfunk workspace.")
|
|
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()
|