Files
SirHumza--orbisRPC/orbisrpc/daemon.c
T

437 lines
19 KiB
C

/* daemon.c - orbisRPC core loop, usable from the ELF payload (main.c) or from a
* GoldHEN plugin. When `fixed_game_name` is non-NULL the daemon posts presence
* for that game unconditionally (the plugin runs inside the game process and
* already knows the title); otherwise it detects the foreground game.
*
* 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 plugin_unload() can shut the loop down cleanly.
*
* KNOWN GAP: no suspend/resume notification handling. After Rest Mode the
* sockets are dead; the heartbeat ack timeout plus reconnect loop should
* recover, but suspend behavior is UNVERIFIED on hardware.
*/
#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 "updater.h"
#include "version.h"
#include "jsonlite.h"
#include "art.h"
#include <sys/stat.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <time.h>
extern cfg_t g_cfg;
static volatile int s_stop = 0;
/* Tells a running daemon_run() to shut down (called from plugin_unload). */
void daemon_request_stop(void){ s_stop = 1; }
void daemon_clear_stop(void){ s_stop = 0; }
int daemon_stop_requested(void){ return s_stop; }
/* Sleep up to `secs` but wake within a second when the plugin asks us to
* stop, so plugin_unload() never hangs 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;
}
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);
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);
}
/* fixed_game_name != NULL -> post presence for that game only, no detection.
* NULL -> poll the foreground app like the payload daemon does.
* Returns 0 normal stop, 1 config error, 2 auth-fatal (bad token). */
int daemon_run(const char *fixed_game_name){
s_stop = 0;
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)");
/* Boot watchdog: a staged update that never proved itself healthy
* gets rolled back to .bak before anything runs it. */
{
static const char *targets[] = {
"/data/GoldHEN/payloads/orbisrpc.bin",
"/data/GoldHEN/plugins/orbisrpc_plugin.prx",
};
for(unsigned ti = 0; ti < sizeof targets/sizeof targets[0]; ti++){
int vr = health_verify_or_rollback(targets[ti]);
if(vr == 1)
log_msg("WARN: %s rolled back to last-good backup", targets[ti]);
else if(vr == -1)
log_msg("WARN: %s failed verification (no backup)", targets[ti]);
}
}
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");
}
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)");
/* 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);
log_close();
return 1;
}
/* Self-update once per boot, before first connect. Never fatal:
* staged artifacts take effect on next launch/injection. */
if(g_cfg.auto_update && !safe_mode){
int ur = updater_check_and_stage();
log_msg("updater: %s (local %s)",
ur > 0 ? "staged newer build" : 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;
int64_t boot_mono = orbis_mono_s();
if(base_poll < 5) base_poll = 5;
if(base_poll > 60) base_poll = 60;
int backoff = base_poll;
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);
if(sleep_stop(backoff)) break;
continue;
}
int rc = discord_connect(&dc, g_cfg.token);
if(rc == -2){
log_msg("FATAL: token rejected by gateway (close 4004). "
"Fix \"token\" in %s", CFG_PATH);
health_mark_clean();
return 2;
}
if(rc != 0){
log_msg("gateway connect failed; retry in %ds", backoff);
if(sleep_stop(backoff)) break;
backoff *= 2; if(backoff > 300) backoff = 300;
continue;
}
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 char last[128] = "";
static char sess_tid[16] = "";
static int64_t started = 0;
static int64_t last_tsync = 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;
char sj[256];
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){
strncpy(prev_tid, t->str, sizeof prev_tid-1);
prev_started = (int64_t)s2->num;
/* 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);
}
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 home_posted = 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 */
int64_t now = orbis_mono_s();
if(now - last_tsync >= 3600){ last_tsync = now; time_sync(); }
if(now != last_poll){
/* Sleep/wake detection without a suspend API: if the loop
* itself froze (Rest Mode), mono jumps and heartbeats died.
* Log it and force a re-post so presence resumes cleanly. */
if(last_poll != 0 && now - last_poll > 120){
log_msg("wake: loop was frozen %llds (likely Rest Mode); resuming",
(long long)(now - last_poll));
need_post = active && last[0];
}
last_poll = now;
if(now - last_alive >= 60){
last_alive = now;
log_msg("alive: %s", active ? last : "idle");
}
/* 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;
if(fixed_game_name){
strncpy(name, fixed_game_name, sizeof name-1);
name[sizeof name-1] = 0;
}else{
/* detect_current_game already checks foreground-active
* internally; don't double-call it. -2 means the scan
* itself failed: hold position, touch nothing. */
scan_unknown = (detect_current_game(name, sizeof name, NULL, 0) == -2);
if(scan_unknown){
log_dbg("detect scan failed; holding");
name[0] = 0;
}
}
if(name[0]){
const char *cur_tid = detect_last_titleid();
if(!cur_tid) cur_tid = "";
if(!strncmp(cur_tid, cand_title, sizeof 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(fixed_game_name || cand_hits >= 2){
if(!active || strncmp(cur_tid,sess_tid,sizeof 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] &&
strncmp(cur_tid,sess_tid,sizeof 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(!strncmp(cur_tid, prev_tid, sizeof 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);
} else if(strncmp(name,last,sizeof last)!=0){
strncpy(last, name, sizeof last-1);
last[sizeof last-1] = 0;
need_post = 1;
}
if(need_post){
const char *state = g_cfg.presence_state[0] ? g_cfg.presence_state : NULL;
const char *tart = detect_last_art();
discord_set_presence_ex(&dc, state, last, sess_tid[0]?sess_tid:NULL, g_cfg.application_id, g_cfg.art_base_url, tart, started);
log_msg("presence: %s", last);
active = 1; need_post = 0;
}
}
}else if(active && !scan_unknown){
cand_title[0] = 0;
cand_hits = 0;
if(++miss_hits >= 2){
discord_clear_presence(&dc);
log_msg("presence cleared");
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. */
const char *state = g_cfg.presence_state[0] ? g_cfg.presence_state : NULL;
discord_set_presence_ex(&dc, state, "PlayStation 4", NULL,
g_cfg.application_id, NULL, NULL, 0);
log_msg("presence: home");
home_posted = 1;
}
}
/* 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){
log_msg("FATAL: token rejected (close 4004). Fix %s", CFG_PATH);
ws_close(&dc.ws);
health_mark_clean();
log_close();
return 2;
}
if(tr == -3){ log_msg("invalid session; fresh identify"); ws_close(&dc.ws); sleep_stop(backoff); break; }
if(tr != 0){
log_msg("gateway dropped; reconnecting");
ws_close(&dc.ws);
backoff *= 2; if(backoff > 300) backoff = 300;
sleep_stop(backoff);
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);
}
log_close();
return 0;
}