Files
CyberMask367 6faa0d5d6b Add community title fallback lookup
Adds a community TITLEID-to-name fallback sourced from config/custom.json for apps or homebrew that are not on pkg-zone, drops the payload bin validation path
2026-10-12 04:25:38 +01:00

813 lines
39 KiB
C

/* daemon.c - orbisRPC core loop (payload ELF only).
*
* Auth model: a Discord USER SESSION token pasted into config.json ("token").
* There is no OAuth flow anymore — OAuth2 access tokens are rejected by the
* gateway (close 4004), which was why v1 never worked.
*
* A `stop` flag is polled so a signal handler can shut the loop down cleanly. */
#include "cfg.h"
#include "clock.h"
#include "lock.h"
#include "timesync.h"
#include "health.h"
#include "log.h"
#include "ws.h"
#include "discord.h"
#include "detect.h"
#include "fw.h"
#include "notify.h"
#include "updater.h"
#include "version.h"
#include "jsonlite.h"
#include "art.h"
#include <sys/stat.h>
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>
#include <time.h>
extern cfg_t g_cfg;
static volatile sig_atomic_t s_stop = 0;
/* Tells a running daemon_run() to shut down. */
void daemon_request_stop(void){ s_stop = 1; }
void daemon_clear_stop(void){ s_stop = 0; }
int daemon_stop_requested(void){ return s_stop; }
/* SIGTERM/SIGINT (payload managers, kill): set the flag only — the loops
* unwind through their normal cleanup (presence clear, session save,
* socket close) instead of dying mid-write. */
static void daemon_on_signal(int sig){
(void)sig;
s_stop = 1;
}
/* Sleep up to `secs` but wake within a second when stop is requested,
* so we never hang on a long backoff. Returns 1 stopped. */
static int sleep_stop(int secs){
for(int i = 0; i < secs; i++){
if(s_stop) return 1;
sleep(1);
}
return s_stop;
}
/* Last known Settings-screen state: 1 in Settings, 0 elsewhere, -1 never
* determined. Settings overlays a running game without closing it, so while it
* is up the presence is REPLACED by a dedicated "In Settings" one rather than
* the game carrying a changed state line. -1 is sticky: an unknown probe
* result must not overwrite a real reading. */
static int s_in_settings = -1;
/* 1 once the Settings presence is on the wire, so a poll storm does not
* re-post it; cleared when Settings closes so returning re-posts the game. */
static int s_settings_posted = 0;
/* Same for the web browser. */
static int s_in_browser = -1;
static int s_browser_posted = 0;
/* Shown when the gateway closes with 4004 (token rejected). Same text at
* connect time and mid-session, so one constant covers both. Throttled
* there rather than once-per-boot because a rejected token persists and
* the daemon keeps retrying on purpose -- it never exits on 4004. */
#define TOKEN_REJECTED_MSG "Discord token rejected - make sure Token is valid"
/* "Firmware 13.52" for the Settings presence. Reads through the same helper the
* rest of the daemon uses, so an unavailable firmware string degrades to
* "unknown" instead of stalling the post. */
static void firmware_state_line(char *out, size_t cap){
if(!g_cfg.show_firmware){ out[0] = 0; return; } /* "" omits `state` */
char fw[16] = "unknown";
fw_version(fw, sizeof fw);
snprintf(out, cap, "Firmware %s", fw);
}
/* The second visible line, per context. Beside the firmware helper so all
* three lines of a presence are chosen in one place. */
static const char *details_for_home(void){
return g_cfg.presence_details_home[0] ? g_cfg.presence_details_home
: "Idling on Home Menu";
}
static const char *details_for_game(const char *title_id){
/* Media apps get their own phrasing: Discord draws a "Watching" context
* of its own for type 3, so this line says where, not what. */
if(title_id && title_id[0] && detect_media_type(title_id) == 3)
return "Watching on PlayStation 4";
/* presence_state is legacy ("On PS4"). If the operator has changed it from
* that default it wins, so an existing customisation is not silently lost
* now that presence_details_game exists. */
if(g_cfg.presence_state[0] && strcmp(g_cfg.presence_state, "On PS4") != 0)
return g_cfg.presence_state;
return g_cfg.presence_details_game[0] ? g_cfg.presence_details_game
: "Playing on PlayStation 4";
}
static const char *details_for_settings(void){
return g_cfg.presence_settings_text[0] ? g_cfg.presence_settings_text
: "In Settings";
}
/* Effective artwork URLs, most specific first.
* large: large_art -> home_art. small: small_art -> large.
* Returns NULL when nothing is configured, which makes discord.c
* omit the assets rather than send a dangling key. */
static const char *large_art_url(void){
if(g_cfg.large_art[0]) return g_cfg.large_art;
if(g_cfg.home_art[0]) return g_cfg.home_art;
return NULL;
}
static const char *small_art_url(void){
if(g_cfg.small_art[0]) return g_cfg.small_art;
return large_art_url();
}
/* Browser image: its own key, falling back to the idle logo so a console
* without a browser.png still renders something sensible. */
static const char *browser_art_url(void){
if(g_cfg.browser_art[0]) return g_cfg.browser_art;
return large_art_url();
}
static int have_token(const cfg_t *c){
if(!c || !c->token[0]) return 0;
if(strcmp(c->token,"SET_ME")==0) return 0;
/* reject whitespace-only / trivially short tokens */
size_t n = strlen(c->token);
if(n < 16) return 0;
for(size_t i = 0; i < n; i++){
if(c->token[i]!=' ' && c->token[i]!='\t' && c->token[i]!='\r' && c->token[i]!='\n')
return 1;
}
return 0;
}
/* Persist the live session (atomic tmp+rename). Called on every
* transition and on clear so a restart resumes instead of resetting. */
static void sess_save(const char *tid, const char *name, int64_t started){ jl_val_t *r = jl_new_object();
if(!r) return;
jl_obj_set(r, "title_id", jl_new_string(tid ? tid : ""));
jl_obj_set(r, "name", jl_new_string(name ? name : ""));
jl_obj_set(r, "started", jl_new_number((double)started));
jl_obj_set(r, "saved_at", jl_new_number((double)time(NULL)));
char *s = jl_stringify(r);
jl_free(r);
if(!s) return;
FILE *f = fopen("/data/orbisRPC/session.json.new", "wb");
if(f){
int ok = (fputs(s, f) >= 0) && (fflush(f) == 0);
/* force bytes to disk before rename (same as cfg_save) */
if(ok){ int fd = fileno(f); if(fd < 0 || fsync(fd) != 0) ok = 0; }
if(fclose(f) != 0) ok = 0;
if(ok) rename("/data/orbisRPC/session.json.new",
"/data/orbisRPC/session.json");
else remove("/data/orbisRPC/session.json.new");
}
free(s);
}
/* Playtime ledger: append-only "<title_id> <name> <seconds>" per finished
* session. Totals are computed by readers (app/docs); the daemon only
* appends, so a corrupt ledger can never break the runtime. */
static void ledger_append(const char *tid, const char *name,
int64_t started, int64_t ended){
if(!tid || !tid[0] || ended <= started || started <= 0) return;
FILE *f = fopen("/data/orbisRPC/playtime.log", "ab");
if(!f) return;
fprintf(f, "%s %s %lld\n", tid, (name && name[0]) ? name : "?",
(long long)(ended - started));
fclose(f);
}
/* Deterministic jitter ±20% without RNG state: hash of mono time and a
* counter. Keeps reconnect storms decorrelated across restarts. */
static int backoff_jitter(int base, unsigned *ctr){
unsigned x = (unsigned)orbis_mono_s() * 2654435761u + (++(*ctr)) * 40503u;
x ^= x >> 15; x *= 0x2c1b3c6du; x ^= x >> 12;
int pct = 80 + (int)(x % 41); /* 80..120 */
return (base * pct) / 100;
}
/* Next reconnect delay: exponential from base, cap 60s for the first 10
* consecutive failures, then 10-minute cadence (quiet persistence, never
* exit). Returns the delay; bumps *fails. */
static int reconnect_delay(int *fails, int base, unsigned *jctr){
int n = ++(*fails);
int b = base;
for(int i = 1; i < n && b < 60; i++) b *= 2;
if(b > 60) b = 60;
if(n > 10) b = 600;
return backoff_jitter(b, jctr);
}
/* Explicit presence state (logged on every transition; no ambiguous
* states): NONE -> HOME <-> GAME, with reconnects re-posting. */
typedef enum { PS_NONE = 0, PS_HOME, PS_GAME } pres_state_t;
static const char *pres_name(pres_state_t s){
return s == PS_GAME ? "GAME" : s == PS_HOME ? "HOME" : "NONE";
}
static void pres_set(pres_state_t *cur, pres_state_t next){
if(*cur == next) return;
log_msg("STATE: presence %s -> %s", pres_name(*cur), pres_name(next));
*cur = next;
}
/* Returns 0 normal stop, 1 config error, 2 auth-fatal (bad token). */
int daemon_run(void){
s_stop = 0;
#ifdef SIGTERM
signal(SIGTERM, daemon_on_signal);
#endif
#ifdef SIGINT
signal(SIGINT, daemon_on_signal);
#endif
if(mkdir(DATA_DIR, 0777) != 0){
/* EEXIST is fine; anything else means payload can't persist config/log */
struct stat st;
if(stat(DATA_DIR, &st) != 0){
log_init(LOG_PATH);
log_msg("FATAL: cannot create %s; check /data writable", DATA_DIR);
log_close();
return 1;
}
}
log_init(LOG_PATH);
log_msg("orbisRPC daemon start — build %s %s", __DATE__, __TIME__);
/* Single writer: a second launch (or a stale pileup from repeated
* injections) stands down instead of fighting over the gateway. */
{
int lr = lock_acquire();
if(lr == 1){ log_msg("another daemon holds the lock; standing down"); log_close(); return 0; }
if(lr != 0) log_msg("WARN: lock error; continuing without guard");
}
/* Crash recovery: unclean-boot marker + consecutive-failure counter.
* Only boots that die before going healthy count; normal reboots and
* token-less idles never trip safe mode. */
int safe_mode = health_boot_note_crash();
if(safe_mode)
log_msg("WARN: repeated unclean boots; safe mode (updates off)");
if(cfg_load(CFG_PATH, &g_cfg) != 0){
/* First boot: drop a template so FTP edit is the only step */
FILE *probe = fopen(CFG_PATH, "rb");
if(!probe){
cfg_defaults(&g_cfg);
if(cfg_save(CFG_PATH, &g_cfg) != 0)
log_msg("WARN: template config unwritable; token edits will not persist");
else
log_msg("created template %s; edit \"token\" then reboot", CFG_PATH);
} else fclose(probe);
log_msg("config load failed; running on defaults until valid config appears");
notify_once_boot("cfgfail", "config.json unreadable - using defaults");
}
if(!g_cfg.enabled){ log_msg("disabled in config; exiting"); health_mark_clean(); log_close(); return 0; }
log_set_debug(g_cfg.debug);
if(g_cfg.debug) log_dbg("debug logging on (config)");
/* Boot census. MUST come after cfg_load(): an earlier revision printed this
* before the load and so reported the zero-initialised global, which made a
* correctly enabled pkgzone_enabled read as "off". */
{
char fw[16] = "unknown";
fw_version(fw, sizeof fw);
int st = detect_settings_active();
log_msg("env: firmware=%s pkgzone=%s settings_probe=%s debug=%d",
fw, g_cfg.pkgzone_enabled ? "on" : "off",
st == 1 ? "in-settings" : (st == 0 ? "ok" : "unavailable"),
g_cfg.debug);
}
/* LAN kill switch: lets a fresh payload be injected without rebooting the
* console. Non-fatal if the bind fails. */
/* Wall-clock correction for Discord timestamps (PSN time sync is
* typically blocked on jailbroken consoles). Boot sweep is bounded;
* hourly ticks are single-host attempts (never stall heartbeats). */
time_sync_all();
if(!have_token(&g_cfg)){
/* Waiting for configuration is a HEALTHY boot, not a crash:
* mark clean so token-less boots never trip safe mode. */
health_mark_healthy();
log_msg("FATAL: put your Discord user token in %s as \"token\":\"...\"", CFG_PATH);
notify_once_boot("notoken", "No Discord token - edit config.json");
log_close();
return 1;
}
/* Update check once per boot, before first connect. Never fatal. Does not
* download or install anything; only notifies when a newer signed
* release exists, so the user can run the setup app. */
if(g_cfg.auto_update && !safe_mode){
/* Update CHECK only. Nothing is downloaded or installed here -- the
* setup app applies updates. A verified newer release just raises a
* notification so the user knows to run it. */
char newer[32] = "";
int ur = updater_check_notify(newer, sizeof newer);
if(ur > 0){
char msg[96];
snprintf(msg, sizeof msg,
"Update %s available - use setup app to update", newer);
notify_once_boot("update", msg);
log_msg("updater: notification sent for %s (local %s)",
newer, ORBISRPC_VERSION);
} else {
log_msg("updater: %s (local %s)",
ur == 0 ? "already current" : "check failed",
ORBISRPC_VERSION);
}
} else if(safe_mode){
log_msg("updater: skipped (safe mode)");
}
discord_t dc;
int base_poll = g_cfg.poll_interval_s;
if(base_poll < 5) base_poll = 5;
if(base_poll > 60) base_poll = 60;
/* Reconnect policy: exponential backoff with deterministic jitter,
* cap 60s for the first 10 consecutive failures, then 10-minute
* cadence (quiet persistence, never exit). Success resets. */
int backoff = base_poll;
int conn_fails = 0;
unsigned jctr = 0;
/* "Connected to Discord" policy: once on the first successful connect,
* then only again if a connect attempt actually failed in between.
*
* Deliberately NOT counting mid-session drops. The gateway can drop and
* come straight back (heartbeat ACKs, a brief outage) and treating that
* as a failure would re-notify on every cycle, which is the spam this
* replaces. Only a failed connect attempt -- rc != 0 from
* discord_connect -- counts as "we could not reach Discord". */
int announced_connected = 0;
int connect_failed_since_ok = 0;
/* Health metrics (hourly HEALTH line). */
int64_t boot_mono = orbis_mono_s();
unsigned n_posts = 0, n_reconnects = 0;
int64_t last_health = 0;
for(;;){ /* outer: connect cycles with backoff on failure */
if(s_stop) break;
/* re-read config every cycle so token edits land without a reboot.
* On parse failure keep last-good config instead of stale defaults. */
{
cfg_t next = g_cfg;
if(cfg_load(CFG_PATH, &next) == 0){
if(next.debug != g_cfg.debug)
log_msg("debug logging %s", next.debug ? "on" : "off");
g_cfg = next;
log_set_debug(g_cfg.debug);
}
else log_msg("config reload failed; keeping last-good config");
}
if(!have_token(&g_cfg)){
log_msg("no token in config; waiting %ds", backoff);
notify_once_boot("notoken", "No Discord token - edit config.json");
if(sleep_stop(backoff)) break;
continue;
}
int rc = discord_connect(&dc, g_cfg.token);
if(rc == -2){
/* Rejected token: do NOT exit (that would strand the daemon
* until the next manual injection). The per-cycle config
* reload picks up a fixed token on its own. */
log_msg("WARN: token rejected by gateway (close 4004). "
"Fix \"token\" in %s; retrying", CFG_PATH);
notify_throttled("token4004", TOKEN_REJECTED_MSG, 300);
connect_failed_since_ok = 1;
int wait = reconnect_delay(&conn_fails, base_poll, &jctr);
if(sleep_stop(wait)) break;
continue;
}
if(rc != 0){
int wait = reconnect_delay(&conn_fails, base_poll, &jctr);
/* Throttled: the backoff already grows, so an unthrottled
* notification here fires every 13s and then every minute. */
notify_throttled("connect", "Cannot reach Discord - retrying", 300);
connect_failed_since_ok = 1;
log_msg("gateway connect failed (attempt %d); retry in %ds",
conn_fails, wait);
if(sleep_stop(wait)) break;
continue;
}
if(!announced_connected || connect_failed_since_ok){
notify_show("Connected to Discord");
announced_connected = 1;
connect_failed_since_ok = 0;
log_msg("notify: %s",
conn_fails > 0 ? "connected after failed attempts"
: "connected (first)");
}
if(conn_fails > 0)
log_msg("gateway connected after %d failures", conn_fails);
conn_fails = 0;
n_reconnects++;
backoff = g_cfg.poll_interval_s; /* success resets backoff */
if(backoff < 5) backoff = 5;
if(backoff > 60) backoff = 60;
/* last/started live OUTSIDE the session loop so the playing timer
* survives gateway reconnects (same game keeps its original start).
* A reconnect re-posts presence only when needed, timer intact. */
static int active = 0;
static pres_state_t pres = PS_NONE;
static char last[128] = "";
static char sess_tid[16] = "";
static int64_t started = 0;
static int64_t last_tsync = 0;
static int home_posted = 0;
/* Fresh (re)connect invalidates whatever Discord shows: re-post the
* current state (game via need_post below, home via home_posted
* reset) so a drop can never leave a stale/blank tile behind. */
home_posted = 0;
/* Resume window: if the same title vanishes briefly (detection
* flicker, quick menu hop) and returns within 10 minutes, the
* timer resumes instead of resetting to 0:00. */
static char prev_tid[16] = "";
static int64_t prev_started = 0, prev_end = 0;
/* Cross-restart resume: a previous run's session (saved on every
* transition) seeds the resume window, so reboots and updates
* keep the timer instead of resetting it. */
{
static int sess_restored = 0;
if(!sess_restored){
sess_restored = 1;
FILE *sf = fopen("/data/orbisRPC/session.json", "rb");
if(sf){
fseek(sf, 0, SEEK_END);
long ssz = ftell(sf);
fseek(sf, 0, SEEK_SET);
if(ssz > 0 && ssz < 1024){
char *sb = (char*)malloc((size_t)ssz + 1);
if(sb && fread(sb, 1, (size_t)ssz, sf) == (size_t)ssz){
sb[ssz] = 0;
jl_val_t *sr = jl_parse(sb, (size_t)ssz);
if(sr && sr->type == JL_OBJECT){
const jl_val_t *t = jl_obj_get(sr, "title_id");
const jl_val_t *s2 = jl_obj_get(sr, "started");
if(t && t->type == JL_STRING && t->str &&
s2 && s2->type == JL_NUMBER){
/* Validate before trusting: 9-char id,
* sane epoch (2020-2100). A corrupt file
* must never seed a bogus timer. */
int64_t st = (int64_t)s2->num;
if(strlen(t->str) == 9 &&
st >= 1577836800LL &&
st <= 4102444800LL){
strncpy(prev_tid, t->str, sizeof prev_tid-1);
prev_started = st;
/* Wall saved_at can't mix with the
* monotonic resume window: start the
* window at boot. Same title seen
* within minutes of boot resumes. */
prev_end = orbis_mono_s();
log_msg("session restored: %s (timer may resume)",
prev_tid);
} else {
log_msg("session file failed validation; starting fresh");
}
}
jl_free(sr);
}
free(sb);
} else if(sb) free(sb);
}
fclose(sf);
}
}
}
/* Transition debounce: shell flickers during launches, so a new
* title (or disappearance) needs consecutive polls (2 at ~1s). */
static char cand_title[16] = "";
static int cand_hits = 0, miss_hits = 0;
int64_t last_poll = 0;
int64_t last_alive = 0;
static int healthy_marked = 0;
/* re-post after every (re)connect so Discord never sticks on stale */
int need_post = active && last[0];
while(!s_stop){ /* inner: live session, serviced every second */
/* Poll every tick, not every detect poll: STOP must land within a
* second even while a title is being resolved. */
int64_t now = orbis_mono_s();
if(now - last_tsync >= 3600){ last_tsync = now; time_sync(); }
if(now != last_poll){
last_poll = now;
if(now - last_alive >= 60){
last_alive = now;
log_msg("alive: %s", active ? last : "idle");
}
/* State reconciliation: re-post current presence every
* 15 min so a silently desynced tile (dropped update,
* stale cache, missed reconnect) heals itself without
* any state change. Timer/source values are re-read at
* post time, so this never disturbs a live session. */
{
static int64_t last_reconcile = 0;
if(last_reconcile == 0) last_reconcile = now;
if(now - last_reconcile >= 900){
last_reconcile = now;
if(active && last[0]) need_post = 1;
else home_posted = 0;
log_msg("reconcile: reposting current state");
}
}
/* Hourly health metrics: uptime, posts, reconnects, fails. */
if(now - last_health >= 3600){
last_health = now;
log_msg("HEALTH: %lldh uptime, %u posts, %u reconnects, %d recent fails",
(long long)((now - boot_mono) / 3600),
n_posts, n_reconnects, conn_fails);
}
/* A boot that survives HEALTH_STABLE_SECS of runtime was
* healthy: clear the unclean-boot marker so only real
* crashes count toward safe mode. */
if(!healthy_marked && now - boot_mono >= HEALTH_STABLE_SECS){
healthy_marked = 1;
health_mark_healthy();
log_msg("health: boot marked healthy");
}
char name[128] = "";
int scan_unknown = 0;
{
int dr = detect_current_game(name, sizeof name, NULL, 0);
/* -2 uncertain and -3 system app covering a live game both
* mean hold. They are logged apart because only one says
* the daemon has lost its view of the console. Both must
* suppress the clear path below: neither is a close. */
scan_unknown = (dr == -2 || dr == -3);
if(dr == -2) log_dbg("detect read failed; holding");
else if(dr == -3) log_dbg("system UI in front; holding %s", last);
if(scan_unknown) name[0] = 0;
}
/* Settings screen. Only a definite answer moves the state:
* a probe that cannot see kern.msgbuf leaves the last known
* value alone instead of flapping the presence off and on. */
{
int sstate = detect_settings_active();
if(sstate >= 0 && sstate != s_in_settings){
s_in_settings = sstate;
log_msg("settings screen: %s", sstate ? "in" : "out");
/* Leaving Settings always hands control back to the
* normal path below: re-arm the game post, and re-arm
* the home post so returning to the home screen is
* allowed to speak again. Previously this only set
* need_post, which the home-screen branch below never
* looks at, so leaving Settings wedged the daemon. */
if(!sstate){
s_settings_posted = 0;
need_post = active && last[0];
home_posted = 0;
}
}
}
/* Browser: same shape as Settings. Probed only when
* Settings is not in front, so Settings keeps priority
* when both would match. */
int bstate = (s_in_settings == 1) ? -1 : detect_browser_active();
if(bstate >= 0 && bstate != s_in_browser){
s_in_browser = bstate;
log_msg("browser screen: %s", bstate ? "in" : "out");
if(!bstate){
s_browser_posted = 0;
need_post = active && last[0];
home_posted = 0;
}
}
/* While Settings is up it replaces the whole presence: its
* own name, no artwork, no timer, no small icon. The game
* underneath keeps `started`, so returning restores the
* original elapsed time rather than restarting the clock.
* It also counts as "hold" below -- Settings must never
* look like a game closing. */
int in_settings = (s_in_settings == 1);
int in_browser = (s_in_settings != 1 && s_in_browser == 1);
/* Neither may look like a game closing. */
if(in_settings || in_browser) scan_unknown = 1;
/* Visibility gates. Detection always runs; this only
* decides whether Discord is told about it.
*
* "Don't send" is safe wherever something could be
* running underneath -- Settings, the browser, a media app,
* homebrew. Home is the exception: nothing is underneath
* there, so a closed game must be cleared rather than held,
* otherwise the tile would linger forever. */
const char *cur_tid_now = detect_last_titleid();
int gate_idle = g_cfg.show_idle == 0;
int gate_media = (g_cfg.show_media == 0) &&
cur_tid_now && detect_media_type(cur_tid_now) == 3;
int gate_homebrew = (g_cfg.show_homebrew == 0) &&
is_homebrew_id(cur_tid_now);
if(in_settings && gate_idle) log_dbg("presence: settings suppressed");
else if(in_settings && !s_settings_posted){
char st[64];
firmware_state_line(st, sizeof st);
int pr = discord_set_presence_ex(&dc, st,
"PlayStation 4", details_for_settings(),
g_cfg.asset_idle, NULL,
"settings", g_cfg.application_id, NULL,
large_art_url(), NULL, 0);
if(pr == 0){
log_msg("presence: settings");
s_settings_posted = 1;
n_posts++;
} else {
log_msg("settings presence send failed; will retry");
}
} else if(in_browser && gate_idle) log_dbg("presence: browser suppressed");
else if(in_browser && !s_browser_posted){
/* Standalone presence: its own name, line and artwork,
* no timer and no badge. A game may be running
* underneath; it comes back on its own when the
* browser closes. */
char st[64];
firmware_state_line(st, sizeof st);
int pr = discord_set_presence_ex(&dc, st,
"PlayStation 4", "Using Web Browser",
NULL, NULL,
"browser", g_cfg.application_id, NULL,
browser_art_url(), NULL, 0);
if(pr == 0){
log_msg("presence: browser");
s_browser_posted = 1;
n_posts++;
} else {
log_msg("browser presence send failed; will retry");
}
}
/* Settings or the browser has already spoken for this poll:
* skip the whole game/home/clear decision below rather than
* letting a stale name or a pending miss counter fight it. */
else if(name[0]){
const char *cur_tid = detect_last_titleid();
if(!cur_tid) cur_tid = "";
if(!strcmp(cur_tid, cand_title)){
cand_hits++;
} else {
strncpy(cand_title, cur_tid, sizeof cand_title-1);
cand_title[sizeof cand_title-1] = 0;
cand_hits = 1;
}
miss_hits = 0;
if(cand_hits >= 2){
if(!active || strcmp(cur_tid,sess_tid)!=0){
/* Switching away from a live session: bank its time.
* (Fresh starts and resumes have nothing to bank;
* the clear path already banked ended sessions.) */
if(active && sess_tid[0] &&
strcmp(cur_tid,sess_tid)!=0)
ledger_append(sess_tid, last, started, time_fixed());
active = 1;
strncpy(sess_tid, cur_tid, sizeof sess_tid-1);
strncpy(last, name, sizeof last-1);
last[sizeof last-1] = 0;
if(!strcmp(cur_tid, prev_tid) &&
prev_started > 0 &&
now - prev_end < 600){
started = prev_started;
log_msg("SESSION_RESUMED title=%s (gap %llds, timer kept)",
cur_tid[0]?cur_tid:"?", (long long)(now - prev_end));
} else {
started = time_fixed();
}
need_post = 1;
log_msg("GAME_DETECTED title=%s name=%s", cur_tid[0]?cur_tid:"?", name);
art_cache_clear();
sess_save(cur_tid, name, started);
/* No metadata is written to config.json any more.
* detect.c persists id+name+cover to games_cache.json
* when a title resolves; that file is the only place
* the daemon learns anything. config.json keeps only
* operator settings and hand-edited overrides. */
} else if(strcmp(name,last)!=0){
strncpy(last, name, sizeof last-1);
last[sizeof last-1] = 0;
need_post = 1;
}
/* A media app or homebrew title that is switched off is
* never announced. Sending nothing leaves whatever was
* last posted standing, so a game underneath keeps
* showing and an empty console simply stays quiet. */
int gate_this_title = gate_media || gate_homebrew;
if(need_post && gate_this_title){
log_dbg("presence: %s suppressed", last);
need_post = 0;
}
if(need_post){
/* name / details / state: what is playing, where it
* is, and the firmware. */
char gst[64];
firmware_state_line(gst, sizeof gst);
const char *state = gst[0] ? gst : NULL;
const char *details = details_for_game(sess_tid);
const char *tart = detect_last_art();
/* Playback queue of one: only clear need_post after
* the bytes actually go out. A failed send stays
* queued and rides the next tick/reconnect. */
int pr = discord_set_presence_ex(&dc, state, last, details,
NULL, g_cfg.asset_playing,
sess_tid[0]?sess_tid:NULL, g_cfg.application_id,
g_cfg.art_base_url, tart,
small_art_url(), started);
if(pr == 0){
log_msg("presence: %s", last);
pres_set(&pres, PS_GAME);
n_posts++;
active = 1; need_post = 0;
} else {
log_msg("presence send failed; will retry");
}
}
}
}else if(active && !scan_unknown){
cand_title[0] = 0;
cand_hits = 0;
if(++miss_hits >= 2){
discord_clear_presence(&dc);
log_msg("presence cleared");
pres_set(&pres, PS_NONE);
ledger_append(sess_tid, last, started, time_fixed());
strncpy(prev_tid, sess_tid, sizeof prev_tid-1);
prev_started = started;
prev_end = now;
remove("/data/orbisRPC/session.json");
last[0]=0; sess_tid[0]=0; active=0; started=0;
home_posted = 0;
}
} else if(gate_idle && active){
/* Home with idle switched off. Clear rather than leave the
* previous game up: nothing is running underneath on the
* home screen, so holding would strand a closed game's
* tile for good. This is the one place where "don't send"
* would be wrong. */
discord_clear_presence(&dc);
log_msg("presence cleared (home, idle suppressed)");
pres_set(&pres, PS_NONE);
ledger_append(sess_tid, last, started, time_fixed());
strncpy(prev_tid, sess_tid, sizeof prev_tid-1);
prev_started = started;
prev_end = now;
remove("/data/orbisRPC/session.json");
last[0]=0; sess_tid[0]=0; active=0; started=0;
home_posted = 0;
} else if(!home_posted && !scan_unknown){
/* Home screen support: no game running. Post a timerless
* home presence once (instead of bare online), clear it
* the moment a game commits. */
char hst[64];
firmware_state_line(hst, sizeof hst);
const char *state = hst[0] ? hst : NULL;
if(discord_set_presence_ex(&dc, state, "PlayStation 4",
details_for_home(),
g_cfg.asset_idle, NULL, "home",
g_cfg.application_id, g_cfg.art_base_url,
large_art_url(), NULL, 0) == 0){
log_msg("presence: home");
pres_set(&pres, PS_HOME);
n_posts++;
home_posted = 1;
} else {
log_msg("home presence send failed; will retry");
}
}
}
/* service the gateway every pass (~1s): heartbeats must never be
* more than a second or two late or the server drops us */
int tr = discord_tick(&dc);
if(tr == -2){
/* Token rejected mid-session: same policy as connect-time
* 4004 (never strand the daemon). Drop to the outer loop:
* backoff + config reload picks up a fixed token alone. */
log_msg("WARN: token rejected (close 4004). Fix %s; retrying", CFG_PATH);
notify_throttled("token4004", TOKEN_REJECTED_MSG, 300);
ws_close(&dc.ws);
pres_set(&pres, PS_NONE);
if(sleep_stop(reconnect_delay(&conn_fails, base_poll, &jctr))) break;
break;
}
if(tr == -3){
int wait = reconnect_delay(&conn_fails, base_poll, &jctr);
log_msg("invalid session; fresh identify (attempt %d) in %ds",
conn_fails, wait);
ws_close(&dc.ws);
pres_set(&pres, PS_NONE);
if(sleep_stop(wait)) break;
break;
}
if(tr != 0){
int wait = reconnect_delay(&conn_fails, base_poll, &jctr);
log_msg("gateway dropped (attempt %d); reconnecting in %ds",
conn_fails, wait);
ws_close(&dc.ws);
pres_set(&pres, PS_NONE);
if(sleep_stop(wait)) break;
break;
}
for(int i = 0; i < 10 && !s_stop; i++) usleep(100000);
}
if(s_stop && dc.connected) discord_clear_presence(&dc);
ws_close(&dc.ws);
}
/* Session file already reflects the live session (saved on every
* transition), so shutdown is: clear presence (above), release the
* lock so a successor starts immediately, close the log. */
lock_release();
log_close();
return 0;
}