diff --git a/docs/configuration.md b/docs/configuration.md index 9ce4cf8..542653d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -112,6 +112,19 @@ and what a given build actually acts on are not always the same thing. | `level` | `"rms"` | `rms` (steadier) or `peak` (punchier) | | `minBrightness` | `20` | brightness (0-100) during silence | | `maxBrightness` | `100` | brightness (0-100) at full level; HyperHDR does not go above 100 | +| `restrictToApp` | `""` | a webOS app id; blank means always active | + +`restrictToApp` is set from a picker of installed apps in the UI (by name, +never typed), backed by a new `listApps` Luna method that proxies to +`com.webos.applicationManager/listApps` — the frontend never calls another +service's Luna API directly, everything goes through this service, same as +everywhere else. The service also subscribes once, at startup, to +`com.webos.applicationManager/getForegroundAppInfo` to know which app is +currently in front. Until that subscription has delivered at least one +reply, a restricted sink treats the target app as *not* active — the safe +failure mode, since silently reacting to audio when the user explicitly +restricted it to one app would be the wrong one. `getDiagnostics` exposes +the live value as `foregroundApp` if you want to confirm tracking is working. ### `udp`, `tcp`, `http` @@ -149,7 +162,8 @@ luna-send -n 1 -f luna://org.webosbrew.audiocap.service/getStatus '{}' | `resetConfig` | `{}` | `{saved, settings}` | | `listBackends` | `{}` | `{backends:[{id,name,description,available}]}` | | `listSinks` | `{}` | `{sinks:[{id,name,description}]}` | -| `getDiagnostics` | `{}` | `{backends, system}` — see below | +| `listApps` | `{}` | `{apps:[{id,title}]}` — every installed app, for the "restrict to app" picker | +| `getDiagnostics` | `{}` | `{backends, system, foregroundApp}` — see below | | `getLogs` | `{"clear":true}` optional | `{logs}` | | `quit` | `{}` | ends the process; the next call starts a new one | @@ -199,7 +213,8 @@ Each sink adds its own fields. `packetsSent`/`bytesSent`/`sendErrors` for the datagram sinks, `clients`/`droppedBytes` for the stream servers, `connected`/`registered`/`framesSent`/`connectFailures`/`lastError` for the visualiser, `connected`/`updatesSent`/`minBrightness`/`maxBrightness`/`lastError` -for the brightness sink. +for the brightness sink, plus `restrictToApp`/`restrictedAppActive` when a +restriction is set. ### Diagnostics diff --git a/docs/development.md b/docs/development.md index fedb4a0..4057927 100644 --- a/docs/development.md +++ b/docs/development.md @@ -186,6 +186,9 @@ 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 +foreground_app.c tracks which app is in front, for the "restrict to app" + brightness option -- the one place this service calls out + to another Luna service instead of being called capture/ one file per backend, all dlopen-based sinks/ one file per output net/ RTP, FlatBuffers, the shared stream server diff --git a/docs/hyperhdr.md b/docs/hyperhdr.md index 78fcdec..8509485 100644 --- a/docs/hyperhdr.md +++ b/docs/hyperhdr.md @@ -195,6 +195,15 @@ TV ──RTP or local──► audiocap-service ──JSON-RPC "adjustment"─ | Follows | Average level (steadier) or Peak level (punchier) | | Minimum brightness | `0`-`100`; applied during quiet parts | | Maximum brightness | `0`-`100`; applied at full level. HyperHDR does not go above 100 | +| Restrict to app | optional — only react to audio while one specific app is in the foreground | + +*Restrict to app* is picked from a list of installed apps by name (e.g. +"Spotify"), never typed — only one app can be foreground at a time, so +there is no way to "capture the current app" from a button in this app's +own UI; you'd just be capturing yourself. Until the TV confirms which app +is actually in front, the sink treats the target as inactive rather than +guessing yes, so it can't accidentally react to the wrong thing while +starting up. Needs a working capture source the same as every other route — see the top of this document for picking one. On close, the sink resets brightness to diff --git a/frontend/appinfo.json b/frontend/appinfo.json index 7c9d7bb..d8f4abc 100644 --- a/frontend/appinfo.json +++ b/frontend/appinfo.json @@ -1,6 +1,6 @@ { "id": "org.webosbrew.audiocap", - "version": "1.0.4", + "version": "1.0.5", "vendor": "Homebrew", "type": "web", "main": "index.html", diff --git a/frontend/js/app.js b/frontend/js/app.js index abf1b4f..2bfd0c3 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -28,6 +28,7 @@ status: null, backends: [], sinkDefs: [], + installedApps: [], configPath: '', persistent: true, bootLinked: false, @@ -244,6 +245,17 @@ return out; } + // Fetched once at boot (see the listApps call near the bottom of this + // file). Sorted by title there, so this just adds the "no restriction" + // default at the front. + function installedAppOptions() { + var out = [{ value: '', label: 'Always active' }]; + state.installedApps.forEach(function (a) { + out.push({ value: a.id, label: a.title }); + }); + return out; + } + var CAPTURE_FIELDS = [ { path: 'capture.backend', label: 'Backend', type: 'choice', @@ -408,6 +420,13 @@ path: 'hyperhdrAdjust.maxBrightness', label: 'Maximum brightness', type: 'number', hint: '0-100. Applied at full level. HyperHDR does not go above 100.', }, + { + path: 'hyperhdrAdjust.restrictToApp', label: 'Restrict to app', type: 'choice', + options: installedAppOptions, + hint: 'Only react to audio while this app is in the foreground. ' + + 'Picked by name, not typed — only one app can be in front at a ' + + 'time, so there is no way to "capture the current app" from here.', + }, ], udp: [ @@ -699,6 +718,9 @@ if (s.connected !== undefined) { bits.push(s.connected ? 'connected' : 'not connected'); } + if (s.restrictToApp) { + bits.push(s.restrictedAppActive ? 'active now' : 'waiting for that app'); + } if (s.sendErrors) { bits.push(s.sendErrors + ' send errors'); } @@ -902,6 +924,16 @@ renderSinks(); }, fail); + // Powers the "restrict to app" picker on the brightness sink. Fetched + // once at boot, same as backends/sinks above — the installed-app list + // does not change during a session. + Luna.listApps(function (reply) { + state.installedApps = (reply.apps || []).slice().sort(function (a, b) { + return a.title.localeCompare(b.title); + }); + renderSinks(); + }, fail); + renderCapture(); renderSystem(); checkBootLink(); diff --git a/frontend/js/luna.js b/frontend/js/luna.js index 2257063..13431d6 100644 --- a/frontend/js/luna.js +++ b/frontend/js/luna.js @@ -101,6 +101,9 @@ listSinks: function (ok, fail) { return call(SERVICE + 'listSinks', {}, ok, fail); }, + listApps: function (ok, fail) { + return call(SERVICE + 'listApps', {}, ok, fail); + }, getDiagnostics: function (ok, fail) { return call(SERVICE + 'getDiagnostics', {}, ok, fail); }, diff --git a/frontend/js/mock.js b/frontend/js/mock.js index 82f12b6..eefd30d 100644 --- a/frontend/js/mock.js +++ b/frontend/js/mock.js @@ -17,6 +17,7 @@ }, hyperhdrAdjust: { host: '', port: 19444, minBrightness: 20, maxBrightness: 100, level: 'rms', + restrictToApp: '', }, udp: { host: '', port: 4010, multicastTtl: 4 }, tcp: { port: 4011, maxClients: 4 }, @@ -156,6 +157,17 @@ ], }); + case 'listApps': + return respond(onReply, { + returnValue: true, + apps: [ + { id: 'spotify-beehive', title: 'Spotify' }, + { id: 'netflix', title: 'Netflix' }, + { id: 'youtube.leanback.v4', title: 'YouTube' }, + { id: 'com.webos.app.livetv', title: 'Live TV' }, + ], + }); + // Same shape as capture_write_diagnostics(): backends at the top level, // everything about the machine under "system". case 'getDiagnostics': diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index 7255622..547ace2 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -30,6 +30,7 @@ add_executable(audiocap-service src/main.c src/service.c src/engine.c + src/foreground_app.c src/config.c src/dsp.c src/common/log.c diff --git a/native/src/config.c b/native/src/config.c index 7bb2c7f..26ec4b8 100644 --- a/native/src/config.c +++ b/native/src/config.c @@ -53,7 +53,8 @@ static const char* DEFAULTS_JSON = " \"port\": 19444," " \"minBrightness\": 20," " \"maxBrightness\": 100," - " \"level\": \"rms\"" + " \"level\": \"rms\"," + " \"restrictToApp\": \"\"" " }," " \"udp\": { \"host\": \"\", \"port\": 4010, \"multicastTtl\": 4 }," " \"tcp\": { \"port\": 4011, \"maxClients\": 4 }," diff --git a/native/src/foreground_app.c b/native/src/foreground_app.c new file mode 100644 index 0000000..17db7de --- /dev/null +++ b/native/src/foreground_app.c @@ -0,0 +1,77 @@ +#include "foreground_app.h" +#include "common/json.h" +#include "common/log.h" + +#include +#include +#include +#include + +static pthread_mutex_t s_lock = PTHREAD_MUTEX_INITIALIZER; +static char s_current[192] = { 0 }; +static bool s_known = false; +static LSMessageToken s_token = 0; +static bool s_subscribed = false; + +static bool on_reply(LSHandle* sh, LSMessage* msg, void* ctx) +{ + (void)sh; + (void)ctx; + const char* payload = LSMessageGetPayload(msg); + json_value_t* root = payload ? json_parse(payload) : NULL; + if (!root) + return true; + + const char* app_id = json_str(root, "appId", ""); + pthread_mutex_lock(&s_lock); + snprintf(s_current, sizeof(s_current), "%s", app_id); + s_known = true; + pthread_mutex_unlock(&s_lock); + + json_free(root); + return true; +} + +bool foreground_app_start(LSHandle* handle, char* err, size_t errlen) +{ + LSError lserror; + LSErrorInit(&lserror); + bool ok = LSCall(handle, "luna://com.webos.applicationManager/getForegroundAppInfo", + "{\"subscribe\":true}", on_reply, NULL, &s_token, &lserror); + if (!ok) { + if (err) + snprintf(err, errlen, "getForegroundAppInfo: %s", lserror.message); + LSErrorFree(&lserror); + return false; + } + s_subscribed = true; + return true; +} + +void foreground_app_stop(LSHandle* handle) +{ + if (!s_subscribed) + return; + LSError lserror; + LSErrorInit(&lserror); + if (!LSCallCancel(handle, s_token, &lserror)) { + DBG("LSCallCancel(foreground app subscription): %s", lserror.message); + LSErrorFree(&lserror); + } + s_subscribed = false; +} + +bool foreground_app_known(void) +{ + pthread_mutex_lock(&s_lock); + bool known = s_known; + pthread_mutex_unlock(&s_lock); + return known; +} + +void foreground_app_current(char* out, size_t outlen) +{ + pthread_mutex_lock(&s_lock); + snprintf(out, outlen, "%s", s_current); + pthread_mutex_unlock(&s_lock); +} diff --git a/native/src/foreground_app.h b/native/src/foreground_app.h new file mode 100644 index 0000000..7370131 --- /dev/null +++ b/native/src/foreground_app.h @@ -0,0 +1,28 @@ +// Tracks which app is currently in the foreground on the TV, so a sink can +// ask "is it Spotify right now" without making a blocking Luna call from the +// audio capture thread. +#pragma once + +#include +#include + +// Opaque: only foreground_app.c itself needs the real luna-service2 API +// surface. Everything else here (in particular sink_hyperhdr_adjust.c, which +// only ever calls the two read-only accessors below) stays free of that +// dependency. +typedef struct LSHandle LSHandle; + +// Subscribes once to com.webos.applicationManager/getForegroundAppInfo. +// Not fatal if it fails (logs and returns false): callers should treat an +// unknown foreground app as "no restriction applies" rather than silently +// freezing every app-restricted sink forever. +bool foreground_app_start(LSHandle* handle, char* err, size_t errlen); +void foreground_app_stop(LSHandle* handle); + +// True once at least one reply has come back, i.e. `current` is meaningful +// rather than just "nothing heard yet". +bool foreground_app_known(void); + +// Copies the current foreground app id into `out` (best effort; may lag the +// real state by a fraction of a second). Empty string if not known yet. +void foreground_app_current(char* out, size_t outlen); diff --git a/native/src/service.c b/native/src/service.c index b9ed66a..a24cdac 100644 --- a/native/src/service.c +++ b/native/src/service.c @@ -4,6 +4,7 @@ #include "common/log.h" #include "config.h" #include "engine.h" +#include "foreground_app.h" #include "sinks/sink.h" #include @@ -347,6 +348,67 @@ static bool method_list_sinks(LSHandle* sh, LSMessage* msg, void* ctx) return true; } +// Bridges to com.webos.applicationManager/listApps so the UI can offer a +// picker of installed apps by name, without the frontend needing its own +// permission to call another service directly -- everything it does goes +// through us, the same as every other method here. `msg` outlives this +// handler's return (the reply comes later, from `on_list_apps_reply`), so it +// is ref-counted for that stretch and always unref'd exactly once. +static bool on_list_apps_reply(LSHandle* sh, LSMessage* reply, void* ctx) +{ + LSMessage* original = ctx; + const char* payload = LSMessageGetPayload(reply); + json_value_t* root = payload ? json_parse(payload) : NULL; + const json_value_t* apps = root ? json_get(root, "apps") : NULL; + + json_writer_t w; + jw_init(&w); + jw_obj_open(&w, NULL); + jw_bool(&w, "returnValue", true); + jw_arr_open(&w, "apps"); + size_t count = json_len(apps); + for (size_t i = 0; i < count; i++) { + const json_value_t* app = json_at(apps, i); + const char* id = json_str(app, "id", NULL); + const char* title = json_str(app, "title", NULL); + // Skip anything without a real title (bare service ids, mostly) and + // this app itself -- restricting the sink "to itself" is meaningless. + if (!id || !title || !*title) + continue; + if (strcmp(id, "org.webosbrew.audiocap") == 0) + continue; + jw_obj_open(&w, NULL); + jw_str(&w, "id", id); + jw_str(&w, "title", title); + jw_obj_close(&w); + } + jw_arr_close(&w); + jw_obj_close(&w); + + reply_json(sh, original, jw_take(&w)); + if (root) + json_free(root); + LSMessageUnref(original); + return true; +} + +static bool method_list_apps(LSHandle* sh, LSMessage* msg, void* ctx) +{ + (void)ctx; + LSMessageRef(msg); + + LSError lserror; + LSErrorInit(&lserror); + bool ok = LSCallOneReply(sh, "luna://com.webos.applicationManager/listApps", + "{\"properties\":[\"id\",\"title\",\"type\"]}", on_list_apps_reply, msg, NULL, &lserror); + if (!ok) { + reply_error(sh, msg, "listApps: %s", lserror.message); + LSErrorFree(&lserror); + LSMessageUnref(msg); + } + return true; +} + static bool method_get_diagnostics(LSHandle* sh, LSMessage* msg, void* ctx) { (void)ctx; @@ -356,6 +418,13 @@ static bool method_get_diagnostics(LSHandle* sh, LSMessage* msg, void* ctx) jw_obj_open(&w, NULL); jw_bool(&w, "returnValue", true); capture_write_diagnostics(&w); + if (foreground_app_known()) { + char app[192]; + foreground_app_current(app, sizeof(app)); + jw_str(&w, "foregroundApp", app); + } else { + jw_null(&w, "foregroundApp"); + } jw_obj_close(&w); reply_json(sh, msg, jw_take(&w)); return true; @@ -407,6 +476,7 @@ static LSMethod s_methods[] = { { "resetConfig", method_reset_config, LUNA_METHOD_FLAGS_NONE }, { "listBackends", method_list_backends, LUNA_METHOD_FLAGS_NONE }, { "listSinks", method_list_sinks, LUNA_METHOD_FLAGS_NONE }, + { "listApps", method_list_apps, LUNA_METHOD_FLAGS_NONE }, { "getDiagnostics", method_get_diagnostics, LUNA_METHOD_FLAGS_NONE }, { "getLogs", method_get_logs, LUNA_METHOD_FLAGS_NONE }, { "quit", method_quit, LUNA_METHOD_FLAGS_NONE }, @@ -447,6 +517,13 @@ service_t* service_create(LSHandle* handle, GMainLoop* loop) return NULL; } + // Powers the brightness sink's "only while this app is running" option. + // Not fatal on failure: that option just has nothing to compare against, + // same as if it were left unset. + char err[192]; + if (!foreground_app_start(handle, err, sizeof(err))) + WARN("Foreground app tracking unavailable: %s", err); + return s; } @@ -454,6 +531,7 @@ void service_destroy(service_t* s) { if (!s) return; + foreground_app_stop(s->handle); engine_destroy(s->engine); config_free(s->config); free(s); diff --git a/native/src/sinks/sink_hyperhdr_adjust.c b/native/src/sinks/sink_hyperhdr_adjust.c index 584eb18..3f7f766 100644 --- a/native/src/sinks/sink_hyperhdr_adjust.c +++ b/native/src/sinks/sink_hyperhdr_adjust.c @@ -25,6 +25,7 @@ #include "sink.h" #include "../common/log.h" +#include "../foreground_app.h" #include #include @@ -52,6 +53,7 @@ typedef struct { int min_brightness; // 0-100 int max_brightness; // 0-100 bool use_rms; // rms is steadier than peak, which reacts to single transients + char restrict_to_app[192]; // empty = always active int fd; // -1 when not connected or still connecting bool connected; // fd is open and the non-blocking connect finished @@ -59,6 +61,7 @@ typedef struct { time_t last_connect_attempt; struct timespec last_send; char last_error[192]; + bool app_was_active; // for sending exactly one reset on the active->inactive edge unsigned long long updates_sent; unsigned long long connect_failures; @@ -156,6 +159,38 @@ static void adjust_write(sink_t* s, const int16_t* pcm, int frames, const dsp_le if (!adjust_pump_connect(p)) return; + // "Only while Spotify is running": until we can actually confirm that, + // the safe default is inactive, not active -- silently reacting to audio + // when the user asked to restrict it would be the wrong failure mode. + bool restricted = p->restrict_to_app[0] != '\0'; + bool app_active = true; + if (restricted) { + if (!foreground_app_known()) { + app_active = false; + } else { + char current[192]; + foreground_app_current(current, sizeof(current)); + app_active = strcmp(current, p->restrict_to_app) == 0; + } + } + + if (!app_active) { + // Send exactly one reset on the active->inactive edge, then go + // quiet, rather than leaving the LEDs stuck at whatever level the + // last audio block happened to produce. + if (p->app_was_active) { + char reset_msg[80]; + int rn = snprintf(reset_msg, sizeof(reset_msg), + "{\"command\":\"adjustment\",\"adjustment\":{\"brightness\":%d}}\n", p->max_brightness); + if (rn > 0 && (size_t)rn < sizeof(reset_msg) + && send(p->fd, reset_msg, (size_t)rn, MSG_NOSIGNAL) >= 0) + p->updates_sent++; + p->app_was_active = false; + } + return; + } + p->app_was_active = true; + if (elapsed_ms(&p->last_send) < SEND_INTERVAL_MS) return; clock_gettime(CLOCK_MONOTONIC, &p->last_send); @@ -197,6 +232,12 @@ static void adjust_status(sink_t* s, json_writer_t* w) jw_bool(w, "connected", p->connected); jw_int(w, "minBrightness", p->min_brightness); jw_int(w, "maxBrightness", p->max_brightness); + if (p->restrict_to_app[0]) { + jw_str(w, "restrictToApp", p->restrict_to_app); + jw_bool(w, "restrictedAppActive", p->app_was_active); + } else { + jw_null(w, "restrictToApp"); + } jw_int(w, "updatesSent", (long long)p->updates_sent); jw_int(w, "connectFailures", (long long)p->connect_failures); if (p->last_error[0]) @@ -229,6 +270,7 @@ static sink_t* adjust_open(const json_value_t* cfg, const audio_format_t* fmt, c int min_brightness = json_int(sc, "minBrightness", 20); int max_brightness = json_int(sc, "maxBrightness", 100); const char* level_source = json_str(sc, "level", "rms"); + const char* restrict_to_app = json_str(sc, "restrictToApp", ""); if (!host || !*host) { snprintf(err, errlen, "set the HyperHDR host address first"); @@ -276,6 +318,7 @@ static sink_t* adjust_open(const json_value_t* cfg, const audio_format_t* fmt, c p->min_brightness = min_brightness; p->max_brightness = max_brightness; p->use_rms = strcmp(level_source, "peak") != 0; + snprintf(p->restrict_to_app, sizeof(p->restrict_to_app), "%s", restrict_to_app); s->driver = &sink_driver_hyperhdr_adjust; s->priv = p; @@ -284,8 +327,9 @@ static sink_t* adjust_open(const json_value_t* cfg, const audio_format_t* fmt, c s->status = adjust_status; s->close = adjust_close; - INFO("HyperHDR adjustment sink: %s:%d, brightness %d..%d from %s", host, port, - min_brightness, max_brightness, p->use_rms ? "rms" : "peak"); + INFO("HyperHDR adjustment sink: %s:%d, brightness %d..%d from %s%s%s", host, port, + min_brightness, max_brightness, p->use_rms ? "rms" : "peak", + p->restrict_to_app[0] ? ", restricted to " : "", p->restrict_to_app); return s; } diff --git a/package.json b/package.json index 4e0cb9b..52b3618 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lgtv-audio-cap", - "version": "1.0.4", + "version": "1.0.5", "private": true, "description": "Captures audio on an LG webOS 5/6 TV and streams it out \u2014 HyperHDR first, plus raw UDP, TCP and HTTP.", "keywords": [ diff --git a/servicefiles/package.json b/servicefiles/package.json index 2975049..739eb1f 100644 --- a/servicefiles/package.json +++ b/servicefiles/package.json @@ -1,6 +1,6 @@ { "id": "org.webosbrew.audiocap.service", - "version": "1.0.4", + "version": "1.0.5", "description": "Captures TV audio and streams it to HyperHDR and other receivers", "main": "audiocap-service" } diff --git a/test/engine_smoke.c b/test/engine_smoke.c index 501a5d8..54bc346 100644 --- a/test/engine_smoke.c +++ b/test/engine_smoke.c @@ -204,12 +204,22 @@ static void test_status(engine_t* e) check(json_len(json_get(levels, "bands")) == DSP_BANDS, "all bands reported"); const json_value_t* sinks = json_get(v, "sinks"); - check(json_len(sinks) == 2, "two sinks reported"); + check(json_len(sinks) == 3, "three sinks reported"); for (size_t i = 0; i < json_len(sinks); i++) { const json_value_t* s = json_at(sinks, i); char label[64]; snprintf(label, sizeof(label), "sink '%s' started cleanly", json_str(s, "id", "?")); check(json_bool(s, "ok", false), label); + + if (strcmp(json_str(s, "id", ""), "hyperhdrAdjust") == 0) { + // A host has no Luna bus (see lunaservice_stub.c), so the + // sink can never confirm the restricted app is foreground. + // The correct failure mode is inactive, not "assume yes". + check(strcmp(json_str(s, "restrictToApp", ""), "some.other.app") == 0, + "restriction target reported back"); + check(json_bool(s, "restrictedAppActive", true) == false, + "restricted app correctly reported as not active (fail-closed)"); + } } json_free(v); } @@ -224,9 +234,11 @@ int main(void) char cfg_text[512]; snprintf(cfg_text, sizeof(cfg_text), "{\"capture\":{\"backend\":\"tone\",\"rate\":48000,\"channels\":2}," - "\"sinks\":[\"tcp\",\"http\"]," + "\"sinks\":[\"tcp\",\"http\",\"hyperhdrAdjust\"]," "\"tcp\":{\"port\":%d}," - "\"http\":{\"port\":%d}}", + "\"http\":{\"port\":%d}," + "\"hyperhdrAdjust\":{\"host\":\"127.0.0.1\",\"port\":19444," + "\"restrictToApp\":\"some.other.app\"}}", TCP_PORT, HTTP_PORT); json_value_t* cfg = json_parse(cfg_text); diff --git a/test/run-tests.sh b/test/run-tests.sh index 2ac31fc..1fdbcce 100755 --- a/test/run-tests.sh +++ b/test/run-tests.sh @@ -13,12 +13,14 @@ export PYTHONDONTWRITEBYTECODE=1 OUT=$(mktemp -d) trap 'rm -rf "$OUT"' EXIT -CFLAGS=(-std=c11 -Wall -Wextra -Wno-unused-parameter -D_GNU_SOURCE -Inative/src -O1 -g) +CFLAGS=(-std=c11 -Wall -Wextra -Wno-unused-parameter -D_GNU_SOURCE -Inative/src -Itest/stubs -O1 -g) SOURCES=( native/src/engine.c native/src/config.c native/src/dsp.c + native/src/foreground_app.c + test/stubs/luna-service2/lunaservice_stub.c native/src/common/log.c native/src/common/json.c native/src/common/ringbuf.c @@ -41,7 +43,7 @@ SOURCES=( echo "== Syntax-checking the webOS-only sources against stub headers" for f in native/src/service.c native/src/main.c; do - "$CC" "${CFLAGS[@]}" -Itest/stubs -fsyntax-only "$f" + "$CC" "${CFLAGS[@]}" -fsyntax-only "$f" echo " ok $f" done diff --git a/test/stubs/luna-service2/lunaservice.h b/test/stubs/luna-service2/lunaservice.h index 650e410..bf8d6c7 100644 --- a/test/stubs/luna-service2/lunaservice.h +++ b/test/stubs/luna-service2/lunaservice.h @@ -20,6 +20,8 @@ typedef struct { } LSError; typedef bool (*LSMethodFunction)(LSHandle* sh, LSMessage* msg, void* category_context); +typedef bool (*LSFilterFunc)(LSHandle* sh, LSMessage* reply, void* ctx); +typedef unsigned long LSMessageToken; typedef enum { LUNA_METHOD_FLAGS_NONE = 0, @@ -58,6 +60,17 @@ bool LSGmainAttach(LSHandle* handle, GMainLoop* loop, LSError* error); const char* LSMessageGetPayload(LSMessage* message); bool LSMessageIsSubscription(LSMessage* message); bool LSMessageReply(LSHandle* sh, LSMessage* message, const char* reply, LSError* error); +void LSMessageRef(LSMessage* message); +void LSMessageUnref(LSMessage* message); bool LSSubscriptionAdd(LSHandle* sh, const char* key, LSMessage* message, LSError* error); bool LSSubscriptionReply(LSHandle* sh, const char* key, const char* payload, LSError* error); + +// Client-call API: this service acting as a caller of another service, not +// just a callee. LSCall keeps calling `callback` for every reply (used for +// subscribe:true); LSCallOneReply auto-cancels after the first one. +bool LSCall(LSHandle* sh, const char* uri, const char* payload, LSFilterFunc callback, + void* ctx, LSMessageToken* ret_token, LSError* error); +bool LSCallOneReply(LSHandle* sh, const char* uri, const char* payload, LSFilterFunc callback, + void* ctx, LSMessageToken* ret_token, LSError* error); +bool LSCallCancel(LSHandle* sh, LSMessageToken token, LSError* error); diff --git a/test/stubs/luna-service2/lunaservice_stub.c b/test/stubs/luna-service2/lunaservice_stub.c new file mode 100644 index 0000000..d839db7 --- /dev/null +++ b/test/stubs/luna-service2/lunaservice_stub.c @@ -0,0 +1,58 @@ +// Linkable bodies for the handful of luna-service2 client-call functions +// foreground_app.c calls. service.c/main.c only ever get -fsyntax-only'd, so +// declarations alone are enough for them; foreground_app.c is linked into +// real host test binaries (engine_smoke, rtp_send) via SOURCES[] in +// run-tests.sh, so those symbols need bodies too, or the link fails. +// +// A host has no Luna bus, so "the call failed" is exactly the right +// simulated behaviour -- every caller here already treats that as +// "foreground app tracking unavailable" and degrades accordingly, which is +// also genuinely exercised by the test suite (see the fail-closed check in +// engine_smoke.c). +#include "luna-service2/lunaservice.h" + +#include + +void LSErrorInit(LSError* error) +{ + memset(error, 0, sizeof(*error)); +} + +void LSErrorFree(LSError* error) +{ + (void)error; +} + +bool LSCall(LSHandle* sh, const char* uri, const char* payload, LSFilterFunc callback, + void* ctx, LSMessageToken* ret_token, LSError* error) +{ + (void)sh; + (void)uri; + (void)payload; + (void)callback; + (void)ctx; + (void)ret_token; + if (error) + error->message = (char*)"no Luna bus on this host"; + return false; +} + +bool LSCallOneReply(LSHandle* sh, const char* uri, const char* payload, LSFilterFunc callback, + void* ctx, LSMessageToken* ret_token, LSError* error) +{ + return LSCall(sh, uri, payload, callback, ctx, ret_token, error); +} + +bool LSCallCancel(LSHandle* sh, LSMessageToken token, LSError* error) +{ + (void)sh; + (void)token; + (void)error; + return true; +} + +const char* LSMessageGetPayload(LSMessage* message) +{ + (void)message; + return NULL; +} diff --git a/test/ui_smoke.js b/test/ui_smoke.js index 8ff2288..e9c7f25 100644 --- a/test/ui_smoke.js +++ b/test/ui_smoke.js @@ -133,6 +133,16 @@ async function main() { check('hyperhdr card is first and marked', doc.querySelector('.sink-card .badge').textContent === 'Recommended'); check('hyperhdr host field exists', !!doc.querySelector('[data-path="hyperhdr.host"]')); + const restrictPicker = doc.querySelector('[data-path="hyperhdrAdjust.restrictToApp"]'); + check('restrict-to-app picker exists', !!restrictPicker); + eq('restrict-to-app picker starts on Always active', restrictPicker.textContent, 'Always active'); + // Always active -> the first installed app alphabetically by title + // ("Live TV", ahead of Netflix/Spotify/YouTube in the mock's list). + click(restrictPicker); + await wait(600); + eq('picking an app reaches settings by id, not a typed value', + window.App.state.settings.hyperhdrAdjust.restrictToApp, 'com.webos.app.livetv'); + eq('picker now shows the app name, not the id', restrictPicker.textContent, 'Live TV'); check('backend choice exists', !!doc.querySelector('[data-path="capture.backend"]')); check('log level choice exists', !!doc.querySelector('[data-path="logLevel"]')); check('boot toggle exists', !!doc.querySelector('[data-path="autoStart"]'));