diff --git a/README.md b/README.md index 7ac4fc55..980dafb0 100644 --- a/README.md +++ b/README.md @@ -406,6 +406,16 @@ The author(s) of StemDeck provide this software "as is", without warranty of any --- +## License + +StemDeck is [Apache-2.0](LICENSE). + +Every download ships a `THIRD_PARTY_NOTICES.txt` and a `licenses/` folder. `licenses/INDEX.txt` lists each packaged Python dependency with its version and license, and each one's full license text sits beside it. That inventory is generated from the packaged interpreter at build time, so it describes what actually shipped rather than what was expected to. + +FFmpeg is a GPL build. StemDeck downloads it and runs it as a separate executable, so it does not change StemDeck's own license, and `THIRD_PARTY_NOTICES.txt` carries the written offer of source for the exact build your platform receives. + +--- + ## Community | Platform | Link | diff --git a/packaging/linux/THIRD_PARTY_NOTICES.txt b/packaging/linux/THIRD_PARTY_NOTICES.txt index f7f3435f..792d5395 100644 --- a/packaging/linux/THIRD_PARTY_NOTICES.txt +++ b/packaging/linux/THIRD_PARTY_NOTICES.txt @@ -4,9 +4,10 @@ THIRD-PARTY NOTICES StemDeck includes third-party open-source software. Each component is copyrighted by its respective authors and distributed under its own license. -This starter notice is not a substitute for the full license inventory that -must be generated from the final packaged Python runtime before a public -release. +The full inventory is generated from the packaged Python runtime at build +time and ships in this download under licenses/. INDEX.txt there lists +every packaged dependency with its version and license, and each entry's +own license text sits beside it. Bundled Components ------------------ @@ -76,3 +77,24 @@ Disclaimer This notice file is not legal advice. Before public release, verify the exact licenses of every bundled package and generated binary artifact. + +FFmpeg (GPL build) +------------------ +StemDeck downloads FFmpeg at first run and calls it as a separate +executable. It is not linked into StemDeck and does not change +StemDeck's own license. + +Build: ffmpeg-release-amd64-static, from johnvansickle.com/ffmpeg +License: GNU General Public License v3 or later + https://www.gnu.org/licenses/gpl-3.0.html + +Written offer of source. The complete corresponding source, along with +the build's own readme naming its configure flags, is published beside +the binary: + + https://johnvansickle.com/ffmpeg/ + https://git.ffmpeg.org/ffmpeg.git + +If either link stops resolving, open an issue at +https://github.com/stemdeckapp/stemdeck/issues and the source will be +provided by another means. diff --git a/packaging/macos/THIRD_PARTY_NOTICES.txt b/packaging/macos/THIRD_PARTY_NOTICES.txt index 171b32cf..dfcd7f69 100644 --- a/packaging/macos/THIRD_PARTY_NOTICES.txt +++ b/packaging/macos/THIRD_PARTY_NOTICES.txt @@ -14,7 +14,29 @@ Downloaded during first-run setup: - FFmpeg and ffprobe - Demucs model weights -The runtime pack includes its own dependency inventory under -runtime/licenses/pip-list.json. Before public release, generate and include -full license texts for every packaged dependency and verify the exact FFmpeg -build license/provenance. +The runtime pack carries the full inventory under licenses/. INDEX.txt there +lists every packaged Python dependency with its version and license, and each +entry's own license text sits beside it. It is generated from the packaged +interpreter at build time rather than maintained by hand, so it describes what +actually shipped. + +FFmpeg +------ +StemDeck downloads FFmpeg and ffprobe at first run and calls them as +separate executables. They are not linked into StemDeck and do not +change StemDeck's own license. + +Build: ffmpeg 8.1.1 and ffprobe 8.1.1, from evermeet.cx +License: GNU General Public License v3 or later + https://www.gnu.org/licenses/gpl-3.0.html + +Written offer of source. The complete corresponding source is: + + FFmpeg itself, release 8.1.1 + https://git.ffmpeg.org/ffmpeg.git + The build's configuration, reported by `ffmpeg -version` + https://evermeet.cx/ffmpeg/ + +If either link stops resolving, open an issue at +https://github.com/stemdeckapp/stemdeck/issues and the source will be +provided by another means. diff --git a/packaging/windows/THIRD_PARTY_NOTICES.txt b/packaging/windows/THIRD_PARTY_NOTICES.txt index 5e5294af..7c7ab829 100644 --- a/packaging/windows/THIRD_PARTY_NOTICES.txt +++ b/packaging/windows/THIRD_PARTY_NOTICES.txt @@ -4,9 +4,10 @@ THIRD-PARTY NOTICES StemDeck includes third-party open-source software. Each component is copyrighted by its respective authors and distributed under its own license. -This starter notice is not a substitute for the full license inventory that -must be generated from the final packaged Python runtime before a public -release. +The full inventory is generated from the packaged Python runtime at build +time and ships in this download under licenses/. INDEX.txt there lists +every packaged dependency with its version and license, and each entry's +own license text sits beside it. Bundled Components ------------------ @@ -72,3 +73,25 @@ Disclaimer This notice file is not legal advice. Before public release, verify the exact licenses of every bundled package and generated binary artifact. + +FFmpeg (GPL build) +------------------ +StemDeck downloads FFmpeg at first run and calls it as a separate +executable. It is not linked into StemDeck and does not change +StemDeck's own license. + +Build: ffmpeg-n8.1-latest-win64-gpl, from BtbN/FFmpeg-Builds +License: GNU General Public License v3 or later + https://www.gnu.org/licenses/gpl-3.0.html + +Written offer of source. The complete corresponding source for this +build is: + + FFmpeg itself, tag n8.1 + https://github.com/FFmpeg/FFmpeg/tree/n8.1 + The build scripts and the exact configure flags used + https://github.com/BtbN/FFmpeg-Builds + +If either link stops resolving, open an issue at +https://github.com/stemdeckapp/stemdeck/issues and the source will be +provided by another means. diff --git a/scripts/collect_licenses.py b/scripts/collect_licenses.py new file mode 100644 index 00000000..31ecb4dc --- /dev/null +++ b/scripts/collect_licenses.py @@ -0,0 +1,123 @@ +"""Write the license text of every packaged Python dependency into the bundle. + +Listing a dependency's name and the word "MIT" is not what MIT asks for. The +permissive licenses in this stack -- MIT, BSD, Apache-2.0 -- all require the +copyright notice and the license text itself to travel with a binary +distribution, and the hand-written THIRD_PARTY_NOTICES.txt says as much about +itself: "not a substitute for the full license inventory that must be generated +from the final packaged Python runtime". + +Generated rather than maintained, because the list is whatever the packaged venv +actually contains. Three hand-edited notices files across three platforms drift +from the lockfile the first time a dependency is added, and nobody notices, +because nothing reads them. + +Stdlib only, on purpose: this runs against the bundled interpreter partway +through packaging, before anything has been installed that is not a runtime +dependency. + +Usage: python collect_licenses.py +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +# .dist-info/licenses/ is the modern location (PEP 639). Older wheels drop the +# file at the top of .dist-info instead, under any of these names. +LEGACY_NAMES = re.compile( + r"^(LICEN[CS]E|COPYING|NOTICE|AUTHORS|COPYRIGHT)([._-].*)?$", re.IGNORECASE +) + + +def metadata_field(dist_info: Path, field: str) -> str: + meta = dist_info / "METADATA" + if not meta.is_file(): + return "" + prefix = f"{field}:".lower() + for line in meta.read_text(encoding="utf-8", errors="replace").splitlines(): + if not line.strip(): + break # headers end at the first blank line; the body is the README + if line.lower().startswith(prefix): + return line.split(":", 1)[1].strip() + return "" + + +def license_files(dist_info: Path) -> list[Path]: + found = list((dist_info / "licenses").rglob("*")) if (dist_info / "licenses").is_dir() else [] + found += [p for p in dist_info.iterdir() if p.is_file() and LEGACY_NAMES.match(p.name)] + return sorted(p for p in found if p.is_file()) + + +def main() -> int: + site_packages = Path(sys.argv[1]) + out = Path(sys.argv[2]) + if not site_packages.is_dir(): + print(f"not a directory: {site_packages}", file=sys.stderr) + return 1 + out.mkdir(parents=True, exist_ok=True) + + index: list[dict[str, object]] = [] + missing: list[str] = [] + + for dist_info in sorted(site_packages.glob("*.dist-info")): + name = metadata_field(dist_info, "Name") or dist_info.name.split("-")[0] + version = metadata_field(dist_info, "Version") + declared = metadata_field(dist_info, "License-Expression") or metadata_field( + dist_info, "License" + ) + # A classifier is the only statement of license for a good number of + # older wheels, and it is more readable than a pasted license body. + if not declared or len(declared) > 80: + classifiers = [ + line.split("::")[-1].strip() + for line in (dist_info / "METADATA") + .read_text(encoding="utf-8", errors="replace") + .splitlines() + if line.startswith("Classifier: License ::") + ] + declared = ", ".join(classifiers) or declared[:80] or "see license text" + + texts = license_files(dist_info) + stem = f"{name}-{version}" if version else name + written = [] + for src in texts: + suffix = "" if len(texts) == 1 else f".{src.name}" + dest = out / f"{stem}{suffix}.txt" + dest.write_text(src.read_text(encoding="utf-8", errors="replace"), encoding="utf-8") + written.append(dest.name) + if not written: + missing.append(stem) + index.append({"name": name, "version": version, "license": declared, "files": written}) + + (out / "index.json").write_text( + json.dumps(index, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + + lines = [ + "PACKAGED PYTHON DEPENDENCIES", + "============================", + "", + "Generated from the bundled interpreter at package time. Each entry's", + "full license text, where the wheel ships one, is in this directory.", + "", + ] + width = max((len(str(e["name"])) for e in index), default=4) + for e in index: + note = "" if e["files"] else " (no license file in wheel)" + lines.append(f"{str(e['name']).ljust(width)} {e['version']} {e['license']}{note}") + (out / "INDEX.txt").write_text("\n".join(lines) + "\n", encoding="utf-8") + + print(f"licenses: {len(index)} packages, {len(index) - len(missing)} with license text") + if missing: + # Not fatal. Some wheels genuinely ship no license file, and failing the + # release build over one would be worse than recording the fact. + print(f"licenses: no text bundled for {len(missing)}: {', '.join(sorted(missing)[:8])}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/linux/make-portable.sh b/scripts/linux/make-portable.sh index 1e4d254d..742e07ec 100755 --- a/scripts/linux/make-portable.sh +++ b/scripts/linux/make-portable.sh @@ -230,6 +230,15 @@ done # catch a strip that removed something load-bearing (#407, #421). "$BUNDLED_PYTHON" -c "import fastapi, uvicorn, yt_dlp, demucs, torch, torchaudio, librosa, pyloudnorm, soundfile, audio_separator, onnxruntime; print('Post-strip import check OK')" +# Full license texts for everything in the venv, generated from the venv rather +# than maintained by hand. MIT, BSD and Apache-2.0 all require the copyright +# notice and the license itself to travel with a binary; a name and the word +# "MIT" in THIRD_PARTY_NOTICES.txt is not that. Runs here, after the strip, so +# the inventory describes what actually ships. +"$BUNDLED_PYTHON" "${REPO_ROOT}/scripts/collect_licenses.py" \ + "${PYTHON_DIR}/lib/python${PYTHON_VERSION}/site-packages" \ + "${STAGE}/licenses" + # Runtime fingerprint, shipped inside python/ in every package (#421). # # The in-app updater's SAFETY GATE, not a download trigger: it replaces diff --git a/scripts/macos/make-runtime-pack.sh b/scripts/macos/make-runtime-pack.sh index 16c05674..72562708 100755 --- a/scripts/macos/make-runtime-pack.sh +++ b/scripts/macos/make-runtime-pack.sh @@ -201,6 +201,12 @@ chmod +x "${QJS_DIR}/qjs" echo "==> Capturing dependency inventory" mkdir -p "$RUNTIME_DIR/licenses" uv pip list --system --python "$PYTHON_DIR/bin/python" --format=json > "$RUNTIME_DIR/licenses/pip-list.json" +# pip-list.json is names and versions, which is an inventory but not a +# notice. MIT, BSD and Apache-2.0 all require the copyright line and the +# license text itself to travel with a binary, so collect those too. +"$PYTHON_DIR/bin/python" "${REPO_ROOT}/scripts/collect_licenses.py" \ + "$PYTHON_DIR/lib/python${PYTHON_VERSION}/site-packages" \ + "$RUNTIME_DIR/licenses" cat > "$RUNTIME_DIR/runtime-manifest.json" < StemDeck - ghcr.io/stemdeckapp/stemdeck:0.16.0 + ghcr.io/stemdeckapp/stemdeck:0.16.1 https://github.com/stemdeckapp/stemdeck/pkgs/container/stemdeck bridge sh