Files
lgtv_audio_cap/docs/development.md
T
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

218 lines
8.7 KiB
Markdown

# Building, testing and publishing
## What you need
| For | Install |
| --- | --- |
| the native service | the [openlgtv buildroot NDK](https://github.com/openlgtv/buildroot-nc4/releases) — or Docker, see below |
| packaging | `npm install` (ares-cli), or `npm install -g @webosose/ares-cli` |
| the tests | a host C compiler, Python 3, Node (optional: `flatbuffers`, `jsdom`) |
### The toolchain
The NDK is a **Linux** toolchain — there is no macOS or Windows build of it. On
Linux, unpack and relocate it once:
```sh
tar xf arm-webos-linux-gnueabi_sdk-buildroot-x86_64.tar.gz -C "$HOME"
"$HOME/arm-webos-linux-gnueabi_sdk-buildroot/relocate-sdk.sh"
```
Everywhere else, build the native part in a container:
```sh
./tools/docker-build.sh # -> build/native/audiocap-service
```
That bakes the SDK into an image, so it downloads once and later builds start
immediately. There are aarch64 and x86_64 SDK builds and the image picks
whichever matches the container, so on Apple Silicon it runs natively rather
than under emulation. Only the compile happens in the container; packaging and
deployment run on the host, where the TV is reachable.
Register the TV with ares once, using the Homebrew Channel's ssh (port 9922,
root):
```sh
ares-setup-device --add tv \
--info "{'host':'192.168.1.20','port':9922,'username':'root'}"
```
## Build and deploy
```sh
./tools/build.sh # cross-compile, stage, package -> out/*.ipk
./tools/build.sh install # ares-install on device "tv"
./tools/build.sh launch
./tools/build.sh logs
```
With the container toolchain it is two steps, since `build.sh` only knows how
to drive a local NDK:
```sh
./tools/docker-build.sh && ./tools/build.sh package
```
`DEVICE=livingroom ./tools/build.sh install` targets a different device;
`WEBOS_SDK=/opt/webos-sdk ./tools/build.sh` a differently placed NDK.
The same commands exist as npm scripts (`npm run build`, `npm run deploy`, …)
if that is more your habit. `build.sh` puts `node_modules/.bin` on PATH first,
so a local `npm install` of ares-cli is enough — no global install needed.
### What the packaging step does
`ares-package` takes two directories:
```text
build/stage/app frontend/, minus js/mock.js and its <script> tag
build/stage/service servicefiles/ plus the compiled audiocap-service
```
The service is declared native in `servicefiles/services.json`
(`"engine": "native"`), which is what makes webOS exec the binary rather than
look for a Node entry point.
## Tests
```sh
./test/run-tests.sh
```
Everything runs on the host — no TV involved:
| Suite | What it proves |
| --- | --- |
| syntax check | `service.c` and `main.c` compile against stub Luna/glib headers |
| `verify_flatbuf.py` | the hand-rolled FlatBuffers encoder matches what the official Python runtime decodes — union tags, defaults, vector contents |
| `verify_rtp.py` | the RTP sink and `host/lgtv-audiocap-receiver.py` agree: header layout, sequencing, timestamps, big-endian payload, MTU, and the SDP text |
| `engine_smoke.c` | capture → DSP → fan-out → sockets, including all 44 bytes of the WAV header and the status document |
| `ui_smoke.js` | the real `index.html` loaded in jsdom and driven like a remote |
The two optional dependencies:
```sh
python3 -m venv /tmp/fbvenv && /tmp/fbvenv/bin/pip install flatbuffers
npm install # jsdom
```
Without them those two suites print SKIP and the rest still runs.
To open the UI in a desktop browser — `js/mock.js` stands in for the Luna bus:
```sh
npm run serve # http://localhost:8000
```
## Publishing to the Homebrew Channel
Two ways to get an ipk into the Browse tab. Neither one touches `ares-install`
or ssh — the app installs itself once the TV can reach a URL.
### Your own repository (no review, no waiting)
The Homebrew Channel's *Settings → Repositories → Add repository* accepts any
URL that returns `{"packages": [...]}`. Each entry needs its own `id`/
`title`/`iconUri` for the Browse grid, plus the full manifest nested under a
`manifest` key for the details screen — [`make-manifest.py`](../tools/make-manifest.py)
builds exactly that shape. Point one at your own git host's release assets and
the app shows up in Browse with no submission process at all — this is what
`--repo-out` (on by default) is for.
1. Bump `version` in `frontend/appinfo.json`, `servicefiles/package.json` and
`package.json`.
2. Build the ipk: `./tools/docker-build.sh && ./tools/build.sh package` (or
`./tools/build.sh` on Linux with the NDK installed).
3. Create a release — note the **exact tag** Gitea/GitHub gives it, `1.0.0` or
`v1.0.0`, whichever it actually is — and attach three files: the ipk,
`frontend/assets/icon.png`, and a repo index generated with `--base-url`
set to that release's real download URL:
```sh
python3 tools/make-manifest.py \
--base-url https://git.crylia.de/Crylia/lgtv_audio_cap/releases/download/1.0.0
# -> out/manifest.json (one app entry, for the official-repo route below)
# -> out/repo.json ({"packages": [{id, title, iconUri, manifest: {...}}]})
```
A mismatched tag in `--base-url` doesn't error — it just makes the icon and
ipk links inside `repo.json` 404 silently, which looks identical to "the
details screen hangs" from the client's point of view. If the app was
already added and only the tag was wrong, re-run with the fixed tag and
re-upload `repo.json`; no need to touch the "Add repository" entry itself,
since its URL didn't change.
Attach `out/repo.json` itself too — its own download URL is what you paste
into the TV.
4. On the TV: Homebrew Channel → gear icon → *Add repository* → paste the
`repo.json` release URL → back out to Browse → find *Audio Cap* → Install.
Shipping an update later is the same four steps with a bumped version and a
new release tag; the Channel diffs against the installed version and offers
an update once the repo URL is already added.
### The official repo (public listing, reviewed)
Submit once to [webosbrew/apps-repo](https://github.com/webosbrew/apps-repo)
and anyone with a stock Homebrew Channel install can find it without adding a
custom repository. That project wants a `.yml` descriptor pointing at your own
hosted `manifest.json` (the un-wrapped file from step 3 above) — see its README
for the submission format.
---
`rootRequired: true` in the manifest tells the Homebrew Channel the service
needs elevating, from either path. The app can also do it on demand —
*System → Grant root access* runs the Channel's `elevate-service` itself, over
the Luna bus, so it works whether SSH is enabled on the TV or not.
## How the service is put together
```text
main.c registers on the bus, runs the glib loop
service.c the Luna methods and the status subscription
engine.c the capture thread: read a block, analyse it, hand it to every sink
config.c load/merge/atomic-save of config.json
dsp.c peak/RMS envelopes and the 16-band analysis
capture/ one file per backend, all dlopen-based
sinks/ one file per output
net/ RTP, FlatBuffers, the shared stream server
common/ JSON, logging, ring buffer, audio format
```
Three rules hold the design together:
**One format inside.** Everything between a backend and a sink is interleaved
signed 16-bit little-endian PCM at the configured rate. Backends convert on the
way in, sinks convert on the way out. Nothing in the middle branches on sample
type.
**Sinks must never block.** The engine calls every sink from the capture thread,
in order, and a sink that stalls stalls capture. Anything that can wait — a TCP
client that stopped reading, a HyperHDR host that is switched off — buffers
internally and drops the oldest audio instead. The Flatbuffers sink connects
non-blockingly and finishes the handshake on later blocks; sends are bounded to
200 ms.
**Audio libraries are `dlopen`ed, never linked.** A TV without `libpulse` must
still run the ALSA backend, and one with neither must still run the test tone
and start up cleanly. `ldd` on the binary shows glib, luna-service2, libc — no
audio.
### Adding a sink
1. Write `native/src/sinks/sink_yours.c` with an `open` that reads its own key
out of the settings object, plus `write`, `status` and `close`.
2. Define `const sink_driver_t sink_driver_yours` at the bottom and declare it
in `sinks/sink.h`.
3. Add it to the table in `sinks/sink.c` and to `DEFAULTS_JSON` in `config.c`.
4. Add its fields to `SINK_FIELDS` in `frontend/js/app.js`.
The UI needs nothing else: it builds the Outputs panel from `listSinks`.
### Adding a capture backend
The same shape in `native/src/capture/`, with an `available()` that answers
honestly on a TV that lacks the library, and an optional `describe()` that adds
its own fields to the diagnostics.