#!/usr/bin/env python3 """Writes the Homebrew Channel manifest for a built ipk, and a custom-repo index that wraps it. The manifest (out/manifest.json) is one app entry: id, ipkUrl, ipkHash, and so on. It is what you submit to webosbrew/apps-repo to get into the official store. The repo index (out/repo.json) is `{"packages": [...]}`, which is the format the Homebrew Channel's own "Add repository" dialog expects (Settings -> Repositories -> Add repository). Host it anywhere static, paste its URL in, and the app shows up in Browse — no submission, no review, no shell access to the TV at all. Each package entry embeds the full manifest under a "manifest" key. That is not decoration: the app's details screen (DetailsPanel.refresh(), read straight from its source) only ever uses entry.manifest directly, or fetches entry.manifestUrl if entry.manifest is absent. Ship a repo.json without either one and the details view calls resolveURL(undefined, ...), throws, and spins on "Loading" forever with no error shown. Embedding beats a manifestUrl because there is only one file to keep in sync. python3 tools/make-manifest.py \\ --base-url https://git.example/you/lgtv-audio-cap/releases/download/v1.0.0 --base-url must be the exact release download URL, tag and all — the icon and ipk links are absolute, so a tag typo (v1.0.0 vs 1.0.0) 404s silently rather than falling back to anything. Defaults to the newest ipk in out/ and writes out/manifest.json + out/repo.json. """ import argparse import glob import hashlib import json import os import sys HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.join(HERE, os.pardir) SOURCE_URL = "https://git.crylia.de/Crylia/lgtv_audio_cap" def newest_ipk(directory): matches = sorted(glob.glob(os.path.join(directory, "*.ipk")), key=os.path.getmtime, reverse=True) return matches[0] if matches else None def sha256(path): digest = hashlib.sha256() with open(path, "rb") as fh: for block in iter(lambda: fh.read(1 << 20), b""): digest.update(block) return digest.hexdigest() def main(): parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) parser.add_argument("--ipk", default=None, help="path to the ipk") parser.add_argument("--base-url", default=None, help="URL the ipk and icon will be served from") parser.add_argument("--source-url", default=SOURCE_URL, help="project page shown in the Homebrew Channel") parser.add_argument("--out", default=os.path.join(ROOT, "out", "manifest.json")) parser.add_argument("--repo-out", default=os.path.join(ROOT, "out", "repo.json"), help="path for the custom-repo index (set to '' to skip)") args = parser.parse_args() appinfo = json.load(open(os.path.join(ROOT, "frontend", "appinfo.json"))) ipk = args.ipk or newest_ipk(os.path.join(ROOT, "out")) if not ipk or not os.path.exists(ipk): print("no ipk found; run ./tools/build.sh first", file=sys.stderr) return 1 name = os.path.basename(ipk) base = (args.base_url or "").rstrip("/") manifest = { "id": appinfo["id"], "version": appinfo["version"], "type": appinfo.get("type", "web"), "title": appinfo.get("title", appinfo["id"]), "appDescription": appinfo.get("appDescription", ""), "iconUri": base + "/icon.png" if base else "icon.png", "sourceUrl": args.source_url, # The service reads the TV's audio devices, which are root-only. The # Homebrew Channel uses this to elevate the service at install time. "rootRequired": True, "ipkUrl": base + "/" + name if base else name, "ipkHash": {"sha256": sha256(ipk)}, "ipkSize": os.path.getsize(ipk), } os.makedirs(os.path.dirname(args.out), exist_ok=True) with open(args.out, "w") as fh: json.dump(manifest, fh, indent=2) fh.write("\n") print(json.dumps(manifest, indent=2)) print("\nwrote %s" % os.path.relpath(args.out, ROOT), file=sys.stderr) if args.repo_out: # The grid view (BrowserPanel) reads id/title/iconUri straight off the # package entry. The details view (DetailsPanel) only ever looks at # entry.manifest directly, or fetches entry.manifestUrl if that is # missing — never the entry's own top-level fields. Skipping # manifestUrl (a second file, a second URL to keep in sync) by # embedding the manifest here means DetailsPanel takes its # already-ready fast path and never issues that fetch at all. package_entry = { "id": manifest["id"], "title": manifest["title"], "iconUri": manifest["iconUri"], "shortDescription": manifest["appDescription"], "manifest": manifest, } os.makedirs(os.path.dirname(args.repo_out) or ".", exist_ok=True) with open(args.repo_out, "w") as fh: json.dump({"packages": [package_entry]}, fh, indent=2) fh.write("\n") print("wrote %s" % os.path.relpath(args.repo_out, ROOT), file=sys.stderr) if not base: print("note: no --base-url given, so ipkUrl and iconUri are relative", file=sys.stderr) return 0 if __name__ == "__main__": sys.exit(main())