Files
Rene KievitsandClaude Opus 5 f622c3a0bf Fix repo.json: embed the manifest, don't just list its fields
Read the Homebrew Channel's own DetailsPanel.js: when an app is opened
from Browse, refresh() only ever takes entry.manifest directly, or
fetches entry.manifestUrl if that's absent — never the entry's own
top-level fields. Our repo.json had neither, so
resolveURL(undefined, repositoryURL) threw before the fetch could even
start, and the details screen spun on "Loading" forever with no visible
error. This is what happened when it was tried against a real repo.

Fixed by nesting the full manifest under a "manifest" key per package
entry, alongside the id/title/iconUri the Browse grid reads directly.
Verified structurally (grid fields present, manifest embedded and
non-empty, ipkUrl absolute) since there's no local Homebrew Channel
build to run this against.

Also caught in the same investigation: a stale --base-url in the docs
used the tag v1.0.0 while the actual release was tagged 1.0.0, which
404s the icon and ipk silently — same symptom, different cause. Noted
in both the tool's docstring and the docs that a tag mismatch looks
identical to the manifest bug from the client's side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:30:24 +02:00

134 lines
5.3 KiB
Python
Executable File

#!/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())