Restrict the brightness sink to one app, picked by name not typed

Confirmed the brightness command applies globally, not per-LED
(serverinfo showed exactly one adjustment object, "id": "default",
covering the whole string), so no LED-count configuration is needed
for this at all -- that question resolved itself once the mechanism
was actually inspected instead of assumed.

For "only react while Spotify is running": only one app can be in the
foreground on webOS at a time, so a "capture the current app" button
in this app's own UI can never work -- pressing it means this app is
foreground, not Spotify. The only workable UI is picking a target from
every *installed* app by name, regardless of what's currently running.

That needed a new native capability this service never had: calling
OUT to another Luna service, not just being called. Two additions:

  foreground_app.c   subscribes once, at startup, to
                      com.webos.applicationManager/getForegroundAppInfo
                      and keeps a thread-safe cache the audio thread can
                      read without a blocking Luna call
  service.c           new listApps method, bridging to
                      com.webos.applicationManager/listApps so the
                      frontend never has to call another service
                      directly -- same rule as everywhere else here

Until the subscription has delivered at least one reply, a restricted
sink treats the target app as inactive, not active -- reacting to
audio when the user explicitly restricted it to one app would be the
wrong failure mode. Verified end to end on the host: engine_smoke.c
opens the sink with a restriction set, confirms it reports itself
correctly inactive against the stub Luna bus (which always "fails" to
call out, exactly like a real host with no bus).

Needed real, linkable stub bodies for LSCall/LSCallOneReply/
LSCallCancel/LSMessageGetPayload/LSErrorInit/LSErrorFree
(test/stubs/luna-service2/lunaservice_stub.c) since foreground_app.c
is the first source file here that's actually linked into a host test
binary rather than only syntax-checked -- service.c/main.c's existing
stub declarations were never called, only compiled against. Confirmed
those really are the correct symbol names by cross-compiling clean
against the real webOS SDK's actual libluna-service2, not just the
stub.

Bumped to 1.0.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Rene Kievits
2026-08-26 15:46:09 +02:00
co-authored by Claude Opus 5
parent e9f6c87d27
commit d3e4cb6410
20 changed files with 411 additions and 13 deletions
+1
View File
@@ -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
+2 -1
View File
@@ -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 },"
+77
View File
@@ -0,0 +1,77 @@
#include "foreground_app.h"
#include "common/json.h"
#include "common/log.h"
#include <luna-service2/lunaservice.h>
#include <pthread.h>
#include <stdio.h>
#include <string.h>
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);
}
+28
View File
@@ -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 <stdbool.h>
#include <stddef.h>
// 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);
+78
View File
@@ -4,6 +4,7 @@
#include "common/log.h"
#include "config.h"
#include "engine.h"
#include "foreground_app.h"
#include "sinks/sink.h"
#include <stdarg.h>
@@ -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);
+46 -2
View File
@@ -25,6 +25,7 @@
#include "sink.h"
#include "../common/log.h"
#include "../foreground_app.h"
#include <arpa/inet.h>
#include <errno.h>
@@ -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;
}