helper web UI: free seen jobs, hide the tile from the PC library, games-only Close

- /api/jobs releases the page's finished jobs after replying
  (ava1_jobs_release_seen), so batches cannot fill the shared table.
- list_registered_titles_json leaves out WEBUI_TILE_TITLE_ID; a PC's
  APP_UNREGISTER of the tile is Settings -> tile off (saved tile:false,
  tile thread removes it) so the next start does not re-register it.
- Now running: the focused app is never the tile; the fallback names
  only a game title id (PPSA/CUSA/... prefixes), never the browser, the
  tile or homebrew. Codec selftest covers it.
- /api/settings carries tile_busy/tile_failed (webui_tile_state); the
  tile request is made before the reply.
- put_str/r_status clamp `at` after every escape/printf; send_error and
  r_fs_delete clamp too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
This commit is contained in:
phantomptrandClaude Opus 5.5 committed 2026-10-17 07:35:02 -07:00
1 parent 9d2625850e
commit 284d22c6e5
9 files changed
+178 -32

No files matched your search

+7
View File
@@ -21,6 +21,13 @@
* /api/settings answers them and /api/settings/set changes them. Call once, before webui_start. */
void webui_api_settings_init(const webui_settings_t *s);
/* A paired PC unregistered the tile (APP_UNREGISTER of WEBUI_TILE_TITLE_ID): the same as Settings ->
* tile off. Saves tile:false to WEBUI_SETTINGS_PATH (so the next start does not register it again)
* and asks the tile thread to remove it (webui_tile_request(0): unregister, then the tile folder and
* the staged /user/app copy are deleted). Returns at once; 0, or -1 when the save failed (the
* removal is requested either way). */
int webui_api_tile_off(void);
int webui_api_route(const webui_req_t *r, const uint8_t *body, size_t body_len, int fd);
#endif
+10 -4
View File
@@ -67,11 +67,17 @@ const char *webui_mime_for(const char *path);
int webui_kv_str(const char *text, size_t n, const char *key, char *out, size_t cap);
int webui_kv_int(const char *text, size_t n, const char *key, long *out);
/* 1 when `tid` is a game's title id: four letters of a game prefix (PPSA, CUSA, PCJS, PLJS, ...:
* Sony's disc and digital game ranges for every region) and five digits. Homebrew (FAKE..., PLDM...,
* WKAL...), system apps (NPXS...) and the page's own tile (WEBUI_TILE_TITLE_ID) are not games. */
int webui_is_game_title_id(const char *tid);
/* From a PROC_FOCUS reply ({"apps":[{"title_id":..,"app_id":..},...],"focus_app_id":N,
* "big_app_id":N,...}): the running game. Candidates are the apps whose title id is set and is
* not a system app (NPXS...); the one that owns the screen (focus_app_id, else big_app_id) wins,
* else the first candidate. Returns 1 with title_id / *app_id set, 0 when no game runs, -1 when
* the reply does not parse. */
* "big_app_id":N,...}): what Home shows as running (and may close). First the app that owns the
* screen (focus_app_id, else big_app_id) when its title id is set, not a system app (NPXS...) and
* not the page's own tile; else the first app whose title id is a game's (webui_is_game_title_id),
* so the fallback never names the browser, the tile or a homebrew app. Returns 1 with title_id /
* *app_id set, 0 when no game runs, -1 when the reply does not parse. */
int webui_focus_running(const char *json, size_t n, char *title_id, size_t cap, uint32_t *app_id);
#endif
+4
View File
@@ -65,6 +65,10 @@ int webui_tile_remove(void);
* applies the LATEST request and repeats while new ones arrive, so ensure and remove never run
* at the same time and a fast on/off/on ends in the last state asked for. Failures are logged. */
void webui_tile_request(int tile, uint16_t port);
/* What the page shows after a tile switch: *busy 1 while the worker still applies a request,
* *failed 1 when the last ensure/remove it applied failed (the tile is not in the state asked for). */
void webui_tile_state(int *busy, int *failed);
#endif
#endif
+4
View File
@@ -28,6 +28,7 @@
#include <sys/sysctl.h>
#include "authid.h"
#include "config.h"
#include "register.h"
#include "sony_api_lock.h"
#include "proc_list.h"
@@ -1116,6 +1117,9 @@ int list_registered_titles_json(char *out_json,
while ((entry = readdir(dir)) != NULL) {
const char *tid = entry->d_name;
if (!looks_like_title_id(tid)) continue;
/* The helper's own home-screen tile (webui_tile.c) is not a game: the PC app's library must
* not list it (or offer to uninstall it as one). */
if (strcmp(tid, WEBUI_TILE_TITLE_ID) == 0) continue;
char lnk[REG_MAX_PATH];
char lnk_img[REG_MAX_PATH];
+10
View File
@@ -31,6 +31,7 @@
#include <fnmatch.h>
#include <regex.h>
#include "config.h"
#include "webui_api.h"
#include "runtime.h"
#include "sandbox_unmount.h"
#include "instance_verdict.h"
@@ -3472,6 +3473,15 @@ static int handle_app_unregister(runtime_state_t *state, const char *request_bod
if (title_id[0] == '\0') {
return mgmt_reply(MGMT_FRAME_ERROR, "unregister_title_id_missing", 27);
}
if (strcmp(title_id, WEBUI_TILE_TITLE_ID) == 0) {
/* The helper's own home-screen tile: a PC removing it means "tile off", as in the page's
* Settings. Saved, so the next helper start does not register it again; the tile thread
* unregisters it and deletes its folders (webui_tile_remove). */
static const char ack[] = "{\"sony_uninstall_rc\":0,\"tile_off\":true}";
(void)webui_api_tile_off();
pop_notification("PS5Upload tile removed");
return mgmt_reply(MGMT_FRAME_APP_UNREGISTER_ACK, ack, (uint64_t)(sizeof ack - 1));
}
unsigned sony_rc = 0u;
if (unregister_title(title_id, &err, &sony_rc) != 0) {
const char *reason = err ? err : "unregister_failed";
+80 -24
View File
@@ -8,6 +8,7 @@
#include "webui_api.h"
#include <pthread.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -52,7 +53,9 @@ static void send_error(int fd, int http, const char *token, size_t len) {
if (len > API_CAUSE_MAX) len = API_CAUSE_MAX;
at = (size_t)snprintf(buf, sizeof buf, "{\"error\":\"");
at += webui_json_escape(buf + at, sizeof buf - at, token, len);
if (at + 3 > sizeof buf) at = sizeof buf - 3; /* cannot happen: 6 bytes per input byte at most */
/* escape returns the full length: clamp before any `sizeof buf - at` (cannot trigger: 6 bytes
* per input byte at most, and the buffer holds that) */
if (at + 3 > sizeof buf) at = sizeof buf - 3;
memcpy(buf + at, "\"}", 3);
send_json(fd, http, buf, at + 2);
}
@@ -195,18 +198,37 @@ static const char *kind_name(uint8_t k) {
typedef void (*api_fn)(const char *b, size_t n, uint8_t *out, int fd);
/* snprintf and webui_json_escape return the length they WANTED to write: after a truncation *at
* would point past the buffer and `cap - *at` would wrap. Keep it on the NUL instead. */
static void clamp_at(size_t *at, size_t cap) {
if (cap && *at > cap - 1) *at = cap - 1;
}
/* Appends printf output to buf at *at, never past cap (a truncated append is cut short). */
static void put_fmt(char *buf, size_t cap, size_t *at, const char *fmt, ...) __attribute__((format(printf, 4, 5)));
static void put_fmt(char *buf, size_t cap, size_t *at, const char *fmt, ...) {
va_list ap;
int k;
clamp_at(at, cap);
if (*at + 1 >= cap) return;
va_start(ap, fmt);
k = vsnprintf(buf + *at, cap - *at, fmt, ap);
va_end(ap);
if (k > 0) *at += (size_t)k;
clamp_at(at, cap);
}
/* Appends `"key":"<escaped s>"` or `"key":null` (s NULL) to buf at *at. */
static void put_str(char *buf, size_t cap, size_t *at, const char *key, const char *s) {
if (*at >= cap) return;
if (!s) {
*at += (size_t)snprintf(buf + *at, cap - *at, "\"%s\":null", key);
put_fmt(buf, cap, at, "\"%s\":null", key);
return;
}
*at += (size_t)snprintf(buf + *at, cap - *at, "\"%s\":\"", key);
if (*at >= cap) return;
put_fmt(buf, cap, at, "\"%s\":\"", key);
if (*at + 1 >= cap) return;
*at += webui_json_escape(buf + *at, cap - *at, s, strlen(s));
if (*at >= cap) return;
*at += (size_t)snprintf(buf + *at, cap - *at, "\"");
clamp_at(at, cap);
put_fmt(buf, cap, at, "\"");
}
/* NODE_STATUS + HW_INFO (model) + HW_TEMPS + PROC_FOCUS. Each call is in-process and bounded by
@@ -248,22 +270,23 @@ static void r_status(const char *b, size_t n, uint8_t *out, int fd) {
{
char *j = (char *)out;
size_t cap = API_OUT_MAX;
at = (size_t)snprintf(j, cap, "{");
at = 0;
put_fmt(j, cap, &at, "{");
put_str(j, cap, &at, "version", have_ver ? version : NULL);
at += (size_t)snprintf(j + at, cap - at, ",");
put_fmt(j, cap, &at, ",");
put_str(j, cap, &at, "firmware", have_ver ? firmware : NULL);
at += (size_t)snprintf(j + at, cap - at, ",");
put_fmt(j, cap, &at, ",");
put_str(j, cap, &at, "model", have_model ? model : NULL);
if (have_temp) at += (size_t)snprintf(j + at, cap - at, ",\"temp_c\":%ld,\"running\":", temp);
else at += (size_t)snprintf(j + at, cap - at, ",\"temp_c\":null,\"running\":");
if (have_temp) put_fmt(j, cap, &at, ",\"temp_c\":%ld,\"running\":", temp);
else put_fmt(j, cap, &at, ",\"temp_c\":null,\"running\":");
if (running) {
at += (size_t)snprintf(j + at, cap - at, "{");
put_fmt(j, cap, &at, "{");
put_str(j, cap, &at, "title_id", title);
at += (size_t)snprintf(j + at, cap - at, ",\"app_id\":%lu,\"name\":null}", (unsigned long)app_id);
put_fmt(j, cap, &at, ",\"app_id\":%lu,\"name\":null}", (unsigned long)app_id);
} else {
at += (size_t)snprintf(j + at, cap - at, "null");
put_fmt(j, cap, &at, "null");
}
at += (size_t)snprintf(j + at, cap - at, "}");
put_fmt(j, cap, &at, "}");
send_json(fd, 200, j, at);
}
}
@@ -512,6 +535,7 @@ static void r_fs_delete(const char *b, size_t n, uint8_t *out, int fd) {
/* {"path":...}: fs_jobs.c's op_delete reads it with fsj_json_str (\" \\ \uXXXX understood) */
at = (size_t)snprintf(args, args_cap, "{\"path\":\"");
at += webui_json_escape(args + at, args_cap - at, path, strlen(path));
if (at + 3 > args_cap) at = args_cap - 3; /* escape returns the full length (cannot trigger: 6x fits) */
memcpy(args + at, "\"}", 3);
at += 2;
memset(&q, 0, sizeof q);
@@ -554,8 +578,15 @@ static void r_jobs(const char *b, size_t n, uint8_t *out, int fd) {
send_error(fd, 500, "out_of_memory", 13);
return;
}
if (webui_jobs_to_json(out, ol, is_mine, NULL, json, API_JOBS_JSON) < 0) send_error(fd, 500, "bad_jobs_reply", 14);
else send_json(fd, 200, json, strlen(json));
if (webui_jobs_to_json(out, ol, is_mine, NULL, json, API_JOBS_JSON) < 0) {
send_error(fd, 500, "bad_jobs_reply", 14);
} else {
/* The page has now seen how its finished jobs ended: release them (after a short grace, so
* a second open page still sees them) instead of letting each hold one of the 32 job slots
* every owner shares for the full park age. A paired PC's jobs are left alone. */
ava1_jobs_release_seen(k_console_id);
send_json(fd, 200, json, strlen(json));
}
free(json);
}
@@ -633,10 +664,17 @@ void webui_api_settings_init(const webui_settings_t *s) {
pthread_mutex_unlock(&g_set_mu);
}
/* tile_busy / tile_failed: the tile switch is applied on the tile thread after this reply (Sony calls
* take seconds), so the page re-reads /api/settings until tile_busy is false and then says so when
* the last ensure/remove failed (webui_tile_state). */
static void send_settings(int fd, const webui_settings_t *s) {
char json[160];
int k = snprintf(json, sizeof json, "{\"lan\":%s,\"tile\":%s,\"version\":\"%s\"}", s->lan ? "true" : "false",
s->tile ? "true" : "false", PS5UPLOAD2_VERSION);
char json[200];
int busy, failed, k;
webui_tile_state(&busy, &failed);
k = snprintf(json, sizeof json,
"{\"lan\":%s,\"tile\":%s,\"tile_busy\":%s,\"tile_failed\":%s,\"version\":\"%s\"}",
s->lan ? "true" : "false", s->tile ? "true" : "false", busy ? "true" : "false",
failed ? "true" : "false", PS5UPLOAD2_VERSION);
send_json(fd, 200, json, (size_t)k);
}
@@ -710,11 +748,29 @@ static void r_settings_set(const char *b, size_t n, uint8_t *out, int fd) {
}
g_set = s;
pthread_mutex_unlock(&g_set_mu);
send_settings(fd, &s); /* written in full (or the deadline passed) before anything below runs */
if (s.lan != old.lan) spawn_lan_apply();
/* tile:true is applied even when unchanged: it retries an ensure that failed (and costs one statfs
* plus three file compares when the tile is already in place). A remove only on a change. */
* plus three file compares when the tile is already in place). A remove only on a change. Requested
* before the reply (it only starts the tile thread), so the reply already says tile_busy. */
if (has_tile && (s.tile || s.tile != old.tile)) webui_tile_request(s.tile, WEBUI_PORT);
send_settings(fd, &s); /* written in full (or the deadline passed) before the rebind below runs */
if (s.lan != old.lan) spawn_lan_apply();
}
int webui_api_tile_off(void) {
webui_settings_t s;
int rc = 0;
pthread_mutex_lock(&g_set_mu);
s = g_set;
s.tile = 0;
if (g_set.tile && webui_settings_save(WEBUI_SETTINGS_PATH, &s) != 0) {
fprintf(stderr, "webui: cannot save tile:false after a PC removed the tile\n");
rc = -1;
} else {
g_set = s;
}
pthread_mutex_unlock(&g_set_mu);
webui_tile_request(0, WEBUI_PORT);
return rc;
}
/* The allowlist: nothing else is reachable. */
+20 -2
View File
@@ -6,6 +6,7 @@
#include "ava1_gen.h"
#include "ava1_wire.h"
#include "config.h"
/* ---- a bounded JSON scanner (never reads past `e`, never recurses) ---- */
@@ -544,6 +545,20 @@ int webui_kv_int(const char *text, size_t n, const char *key, long *out) {
return 1;
}
int webui_is_game_title_id(const char *tid) {
/* PS5 (PPSA, and PCSx/PLxS... for older regional ranges), PS4 (CUSA, PCJS/PLJS Japan, PCAS/PLAS
* Asia, PCKS/PLKS Korea, ECAS/ELAS Asia) and the PS Vita/PS3-era PCSA..PCSH ranges. */
static const char *const k_prefixes[] = { "PPSA", "CUSA", "PCJS", "PCAS", "PLJS", "PLAS", "ECAS", "ELAS", "PCKS",
"PLKS", "PCSA", "PCSB", "PCSC", "PCSD", "PCSE", "PCSF", "PCSG", "PCSH" };
size_t i;
if (!tid || strlen(tid) != 9 || strcmp(tid, WEBUI_TILE_TITLE_ID) == 0) return 0;
for (i = 4; i < 9; i++)
if (tid[i] < '0' || tid[i] > '9') return 0;
for (i = 0; i < sizeof k_prefixes / sizeof k_prefixes[0]; i++)
if (memcmp(tid, k_prefixes[i], 4) == 0) return 1;
return 0;
}
int webui_focus_running(const char *json, size_t n, char *title_id, size_t cap, uint32_t *app_id) {
const char *e = json + n, *p = top_value(json, n, "apps");
long long focus = -1, big = -1;
@@ -552,7 +567,8 @@ int webui_focus_running(const char *json, size_t n, char *title_id, size_t cap,
if (!p || *p != '[') return -1;
(void)json_i64(json, n, "focus_app_id", &focus);
(void)json_i64(json, n, "big_app_id", &big);
/* pass 0: the app on screen (focus flag, else big app); pass 1: the first game at all */
/* pass 0: the app on screen (focus flag, else big app); pass 1: the first GAME (never the tile,
* the browser or a homebrew app: Close would kill it) */
for (pass = 0; pass < 2; pass++) {
long long want = pass == 0 ? (focus > 0 ? focus : big) : -1;
const char *q = p + 1;
@@ -568,7 +584,9 @@ int webui_focus_running(const char *json, size_t n, char *title_id, size_t cap,
end = skip_val(q, e);
if (!end) return -1;
if (*el == '{' && webui_json_str(el, (size_t)(end - el), "title_id", tid, sizeof tid) == 1 && tid[0] &&
strncmp(tid, "NPXS", 4) != 0 && webui_json_u64(el, (size_t)(end - el), "app_id", &aid) == 1 &&
strncmp(tid, "NPXS", 4) != 0 && strcmp(tid, WEBUI_TILE_TITLE_ID) != 0 &&
(pass == 0 || webui_is_game_title_id(tid)) &&
webui_json_u64(el, (size_t)(end - el), "app_id", &aid) == 1 &&
(pass == 1 || (long long)aid == want) && strlen(tid) < cap) {
memcpy(title_id, tid, strlen(tid) + 1);
*app_id = (uint32_t)aid;
+10 -2
View File
@@ -282,6 +282,7 @@ static unsigned g_req_gen;
static int g_req_tile;
static uint16_t g_req_port;
static int g_worker_running;
static int g_last_failed; /* the last ensure/remove the worker applied returned -1 */
static void *tile_worker(void *arg) {
unsigned done = 0;
@@ -294,16 +295,23 @@ static void *tile_worker(void *arg) {
uint16_t port = g_req_port;
if (done == gen) break;
pthread_mutex_unlock(&g_req_mu);
if (tile) (void)webui_tile_ensure(port);
else (void)webui_tile_remove();
int rc = tile ? webui_tile_ensure(port) : webui_tile_remove();
done = gen;
pthread_mutex_lock(&g_req_mu);
g_last_failed = rc != 0;
}
g_worker_running = 0;
pthread_mutex_unlock(&g_req_mu);
return NULL;
}
void webui_tile_state(int *busy, int *failed) {
pthread_mutex_lock(&g_req_mu);
*busy = g_worker_running;
*failed = g_last_failed;
pthread_mutex_unlock(&g_req_mu);
}
void webui_tile_request(int tile, uint16_t port) {
pthread_attr_t attr;
pthread_t t;
+33
View File
@@ -493,6 +493,38 @@ static void focus_running(void) {
CHECK(webui_focus_running("nope", 4, tid, sizeof tid, &app) == -1);
}
/* The fallback (nothing game-like on screen) names only a game: never the page's own tile, the
* browser or a homebrew app, which Close would kill. The focused app never names the tile either. */
static void focus_running_fallback_games_only(void) {
char tid[16];
uint32_t app = 0;
const char *j = "{\"apps\":[{\"title_id\":\"PSUP00001\",\"app_id\":3},{\"title_id\":\"FAKE10101\",\"app_id\":4},"
"{\"title_id\":\"PLDM00001\",\"app_id\":5},{\"title_id\":\"WKAL00001\",\"app_id\":6},"
"{\"title_id\":\"PPSA01650\",\"app_id\":7}],\"focus_app_id\":-1,\"big_app_id\":0}";
CHECK(webui_focus_running(j, strlen(j), tid, sizeof tid, &app) == 1);
CHECK(strcmp(tid, "PPSA01650") == 0 && app == 7);
/* only non-games run: nothing to close */
j = "{\"apps\":[{\"title_id\":\"PSUP00001\",\"app_id\":3},{\"title_id\":\"FAKE10101\",\"app_id\":4}],"
"\"focus_app_id\":-1}";
CHECK(webui_focus_running(j, strlen(j), tid, sizeof tid, &app) == 0);
/* the tile focused (the browser it opened owns the screen): not "running", the game behind it is */
j = "{\"apps\":[{\"title_id\":\"PSUP00001\",\"app_id\":3},{\"title_id\":\"CUSA07842\",\"app_id\":9}],"
"\"focus_app_id\":3}";
CHECK(webui_focus_running(j, strlen(j), tid, sizeof tid, &app) == 1);
CHECK(strcmp(tid, "CUSA07842") == 0 && app == 9);
/* a focused homebrew app is still what is on screen (the user sees it; pass 0 keeps it) */
j = "{\"apps\":[{\"title_id\":\"FAKE10101\",\"app_id\":4},{\"title_id\":\"PPSA01650\",\"app_id\":7}],"
"\"focus_app_id\":4}";
CHECK(webui_focus_running(j, strlen(j), tid, sizeof tid, &app) == 1);
CHECK(strcmp(tid, "FAKE10101") == 0 && app == 4);
CHECK(webui_is_game_title_id("PPSA01650") && webui_is_game_title_id("CUSA07842") &&
webui_is_game_title_id("PLJS00001") && webui_is_game_title_id("PCSH00001"));
CHECK(!webui_is_game_title_id("PSUP00001") && !webui_is_game_title_id("NPXS40087") &&
!webui_is_game_title_id("FAKE10101") && !webui_is_game_title_id("PPSA0165") &&
!webui_is_game_title_id("PPSA0165X") && !webui_is_game_title_id("PPSA016500") &&
!webui_is_game_title_id(""));
}
int main(void) {
unicode_path_round_trips();
surrogate_pair_decodes();
@@ -506,6 +538,7 @@ int main(void) {
mime_types();
kv_lines();
focus_running();
focus_running_fallback_games_only();
if (failures) {
fprintf(stderr, "webui_codec_selftest: %d failure(s)\n", failures);
return 1;