/* PS5 RetroArch - the WebUI daemon, /app0/webui/ps5-retroarch-webui.elf.
*
* Copyright (C) 2026 Mihawk
* SPDX-License-Identifier: GPL-3.0-or-later
*
* A payload, started through the console's ELF loader by the first program of the
* title that finds none running (src/webui_link.cpp): it serves the WebUI
* (src/webui_ps5.cpp) on 6769 from the title's folder for as long as the title is
* open, whichever frontend it shows, so a frontend change (a LoadExec, which ends the
* program it leaves) never cuts an upload or a page. The protocol is in
* src/webui_link.h.
*
* It stops when no program of the title has been linked for kIdleSeconds and no
* request is being answered: the title closed. An update the WebUI is asked to
* install makes every linked program close the title; once none is left, this
* installs it (ps5_update::install), the way eboot.bin did after RetroArch quit.
* What the Game Forwarder page's tiles left for the title's close (removals and new names
* or icons, src/ps5_forwarders.h) is done once no program of the title is linked: tried at
* once and every few seconds while ShadowMountPlus refuses, and at the stop.
*
* Its own record is
/webui-daemon.txt (the last run's is webui-daemon.1.txt).
* tests/test_webui_daemon.py builds it for the host, with its folder, ports and idle
* time its own.
*/
#include "../src/webui_ps5.h"
#include "../src/webui_update.h"
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include "../src/webui_link.h"
#ifndef PS5_WEBUI_HOMEBREW
#define PS5_WEBUI_HOMEBREW "/data/homebrew"
#endif
#ifndef PS5_WEBUI_HTTP_PORT
#define PS5_WEBUI_HTTP_PORT 6769
#endif
#ifndef PS5_WEBUI_IDLE_SECONDS
#define PS5_WEBUI_IDLE_SECONDS 20.0
#endif
#ifdef __PROSPERO__
/* What the daemon creates in the title's folder is given 0777, as eboot.bin gives
* everything under /app0 (src/permissions_ps5.cpp), so FTP can change it: open,
* mkdir and fopen are wrapped at link time (tools/build-webui-daemon.sh). */
extern "C"
{
int __real_mkdir(const char *, mode_t);
int __real_open(const char *, int, ...);
std::FILE *__real_fopen(const char *, const char *);
int __wrap_mkdir(const char *path, mode_t mode)
{
const int result = __real_mkdir(path, mode | 0777);
if (result == 0)
chmod(path, 0777);
return result;
}
int __wrap_open(const char *path, int flags, ...)
{
if ((flags & O_CREAT) == 0)
return __real_open(path, flags);
va_list arguments;
va_start(arguments, flags);
const int mode = va_arg(arguments, int);
va_end(arguments);
const int fd = __real_open(path, flags, mode | 0777);
if (fd >= 0)
fchmod(fd, 0777);
return fd;
}
std::FILE *__wrap_fopen(const char *path, const char *mode)
{
std::FILE *const file = __real_fopen(path, mode);
if (file && mode && std::strpbrk(mode, "wa+"))
fchmod(fileno(file), 0777);
return file;
}
}
#endif
namespace
{
constexpr double kIdleSeconds = PS5_WEBUI_IDLE_SECONDS; /* no program linked: closed */
constexpr double kInstallSeconds = 5.0; /* no program linked, an install asked */
constexpr double kNoHelloSeconds = 30.0; /* started, and no program ever linked */
struct Link
{
int fd;
std::string pending, frontend;
bool hello = false;
};
std::string root, title, version, log_path;
std::vector early_lines;
double now_s()
{
timespec now{};
clock_gettime(CLOCK_MONOTONIC, &now);
return double(now.tv_sec) + double(now.tv_nsec) / 1e9;
}
void say(const char *format, ...)
{
char line[512];
va_list arguments;
va_start(arguments, format);
std::vsnprintf(line, sizeof line, format, arguments);
va_end(arguments);
std::fprintf(stderr, "[webui-daemon] %s\n", line);
if (log_path.empty())
{
early_lines.emplace_back(line);
return;
}
if (std::FILE *file = std::fopen(log_path.c_str(), "a"))
{
std::time_t t = std::time(nullptr);
char stamp[32];
std::strftime(stamp, sizeof stamp, "%Y-%m-%d %H:%M:%S", std::gmtime(&t));
/* The process: a stale daemon still finishing writes to the same file. */
std::fprintf(file, "%s [%d] %s\n", stamp, int(getpid()), line);
std::fclose(file);
}
}
std::string read_small(const std::string &path)
{
std::string out;
if (std::FILE *file = std::fopen(path.c_str(), "rb"))
{
char buffer[4096];
size_t got;
while ((got = std::fread(buffer, 1, sizeof buffer, file)) > 0 && out.size() < 65536)
out.append(buffer, got);
std::fclose(file);
}
return out;
}
bool is_file(const std::string &path)
{
struct stat st{};
return stat(path.c_str(), &st) == 0 && S_ISREG(st.st_mode);
}
bool valid_id(const std::string &id)
{
if (id.size() != 9)
return false;
for (char c : id)
if (!((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')))
return false;
return true;
}
/* The title's folder: what is mounted as its application folder
* (/data/homebrew/PPSA99169 -> /system_ex/app/PPSA99169, docs/FINDINGS.md), else
* the usual place; either only when it holds this title's eboot.bin and WebUI. */
std::string title_folder(const std::string &id)
{
std::vector candidates;
#ifdef __PROSPERO__
struct statfs *mounts = nullptr;
const int count = getmntinfo(&mounts, MNT_NOWAIT);
const std::string app = "/system_ex/app/" + id;
for (int i = 0; i < count; ++i)
if (app == mounts[i].f_mntonname && mounts[i].f_mntfromname[0] == '/')
candidates.emplace_back(mounts[i].f_mntfromname);
#endif
candidates.push_back(PS5_WEBUI_HOMEBREW "/" + id);
for (const auto &folder : candidates)
if (is_file(folder + "/eboot.bin") && is_file(folder + "/webui/index.html") &&
read_small(folder + "/sce_sys/param.json").find(id) != std::string::npos)
return folder;
return "";
}
bool send_line(int fd, const char *line)
{
const size_t size = std::strlen(line);
return send(fd, line, size, 0) == ssize_t(size);
}
int listen_local(unsigned short port)
{
const int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0)
return -1;
int yes = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes);
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_port = htons(port);
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (bind(fd, reinterpret_cast(&address), sizeof address) != 0 || listen(fd, 8) != 0)
{
close(fd);
return -1;
}
return fd;
}
void show_frontend(const std::vector &links)
{
const char *shown = "";
for (const auto &link : links)
if (link.hello)
shown = link.frontend.c_str();
ps5_webui_set_frontend(shown);
}
} // namespace
int main()
{
std::signal(SIGPIPE, SIG_IGN);
int listener = listen_local(PS5_WEBUI_LINK_PORT);
if (listener < 0)
{
std::fprintf(stderr, "[webui-daemon] another daemon has %d; this one stops\n",
PS5_WEBUI_LINK_PORT);
return 0;
}
ps5_webui_set_frontend("");
std::vector links;
bool serving = false, stale = false, install_sent = false, installed = false, closed = false;
bool tiles_done = false;
const double started = now_s();
double idle_since = started, installed_at = 0, last_serve_try = 0, last_tiles = 0;
unsigned serve_failures = 0;
/* The WebUI starts on a thread of its own (reading the library and the saved scraping
* jobs takes seconds): the title's hello is answered meanwhile. 0 idle, 1 starting,
* 2 serving, 3 failed. */
static std::atomic start_state{0};
static std::string start_folder;
pthread_t starter{};
bool starter_running = false;
say("started; listening for the title on 127.0.0.1:%d", PS5_WEBUI_LINK_PORT);
for (;;)
{
std::vector waits;
if (listener >= 0)
waits.push_back({listener, POLLIN, 0});
for (const auto &link : links)
waits.push_back({link.fd, POLLIN, 0});
poll(waits.data(), waits.size(), 250);
if (listener >= 0 && (waits[0].revents & POLLIN))
{
const int fd = accept(listener, nullptr, nullptr);
if (fd >= 0 && links.size() < 16)
links.push_back({fd, "", "", false});
else if (fd >= 0)
close(fd);
}
for (auto &link : links)
{
pollfd wait{link.fd, POLLIN, 0};
if (poll(&wait, 1, 0) <= 0)
continue;
char buffer[512];
const ssize_t got = recv(link.fd, buffer, sizeof buffer, 0);
if (got <= 0 || link.pending.size() > 4096)
{
close(link.fd);
link.fd = -1;
continue;
}
link.pending.append(buffer, size_t(got));
size_t end;
while (link.fd >= 0 && (end = link.pending.find('\n')) != std::string::npos)
{
const std::string line = link.pending.substr(0, end);
link.pending.erase(0, end + 1);
char word[16] = {}, frontend[32] = {}, id[16] = {};
const int fields = std::sscanf(line.c_str(), "%15s %31s %15s", word, frontend, id);
if (fields == 3 && std::strcmp(word, "hello") == 0)
{
const char *reply = "ok\n";
if (stale)
reply = "stale\n";
else if (root.empty())
{
const std::string folder = valid_id(id) ? title_folder(id) : "";
if (folder.empty())
{
say("hello from %s of %s: no folder of that title found", frontend, id);
reply = "unknown\n";
}
else
{
root = folder;
title = id;
version = read_small(root + "/webui/version.json");
log_path = root + "/webui-daemon.txt";
rename(log_path.c_str(), (root + "/webui-daemon.1.txt").c_str());
for (const auto &early : early_lines)
say("%s", early.c_str());
early_lines.clear();
say("the title is %s, in %s", title.c_str(), root.c_str());
}
}
else if (title != id)
reply = "other\n";
else if (read_small(root + "/webui/version.json") != version)
{
say("hello from another build of the title: this daemon stops once "
"its requests end");
stale = true;
reply = "stale\n";
}
send_line(link.fd, reply);
if (std::strcmp(reply, "ok\n") == 0)
{
link.hello = true;
link.frontend = frontend;
say("linked: %s (%zu linked)", frontend, links.size());
if (install_sent)
send_line(link.fd, "install\n");
}
else
{
close(link.fd);
link.fd = -1;
}
}
else if (fields >= 2 && std::strcmp(word, "frontend") == 0 && link.hello)
{
link.frontend = frontend;
say("frontend: %s", frontend);
}
}
}
for (auto it = links.begin(); it != links.end();)
if (it->fd < 0)
{
if (it->hello)
say("unlinked: %s", it->frontend.c_str());
it = links.erase(it);
}
else
++it;
show_frontend(links);
const double now = now_s();
bool linked = false;
for (const auto &link : links)
linked |= link.hello;
if (linked)
idle_since = 0;
else if (idle_since == 0)
idle_since = now;
/* What a Game Forwarder tile waits for (a removal, a new name or picture, which
* ShadowMountPlus refuses while a game runs): tried once no program of the title is
* linked, and every few seconds after, not only at the stop kIdleSeconds later */
if (linked)
tiles_done = false; /* the page can leave more while the title runs */
else if (serving && !tiles_done && now - last_tiles >= 3.0)
{
last_tiles = now;
tiles_done = ps5_webui_finish_forwarders([](const char *line) { say("%s", line); });
}
if (start_state == 2 || start_state == 3)
{
if (starter_running)
pthread_join(starter, nullptr);
starter_running = false;
serving = start_state == 2;
start_state = 0;
if (serving)
say("serving the WebUI on %d from %s (%u s busy before)", PS5_WEBUI_HTTP_PORT,
root.c_str(), serve_failures);
else if (serve_failures++ == 0)
say("%d is busy (another daemon's last transfer?); trying every second",
PS5_WEBUI_HTTP_PORT);
}
if (!serving && start_state == 0 && !root.empty() && !stale && now - last_serve_try >= 1.0)
{
last_serve_try = now;
start_state = 1;
start_folder = root;
/* A payload's default thread stack is small; the library scan needs more. */
pthread_attr_t attributes;
pthread_attr_init(&attributes);
pthread_attr_setstacksize(&attributes, 4u << 20);
starter_running =
pthread_create(
&starter, &attributes,
[](void *) -> void *
{
#ifdef PS5_WEBUI_TEST_START_DELAY_MS /* the tests: a start as slow as a big library's */
usleep(PS5_WEBUI_TEST_START_DELAY_MS * 1000);
#endif
start_state =
ps5_webui_start(start_folder.c_str(), PS5_WEBUI_HTTP_PORT, false) ? 2
: 3;
return nullptr;
},
nullptr) == 0;
pthread_attr_destroy(&attributes);
if (!starter_running)
start_state = 3;
}
if (serving && !install_sent && ps5_update::exit_requested())
{
install_sent = true;
say("install requested: asking %zu linked program(s) to close the title", links.size());
for (const auto &link : links)
if (link.hello)
send_line(link.fd, "install\n");
}
if (install_sent && !installed && !linked && now - idle_since >= kInstallSeconds)
{
installed = true;
installed_at = now;
const bool ok = ps5_update::install();
say("install: %s", ok ? "done" : "failed (see .update-result)");
stale = true; /* this daemon is the old build's now */
}
// What must not be cut: transfers in flight, not an open page's idle keep-alive
// connections (counting those, a stale daemon kept 6769 from the new build).
const unsigned busy = ps5_webui_transfers();
const std::string update = ps5_update::status().state;
// A download, or a scraping job, goes on with the title closed and no page open.
const bool updating =
update == "downloading" || update == "verifying" || ps5_webui_scraping();
bool stop = false;
if (root.empty() && !linked && now - started >= kNoHelloSeconds)
{
say("no program of a title linked within %.0f s", kNoHelloSeconds);
stop = true;
}
else if (stale && !busy && !linked && (!installed || now - installed_at >= 15.0))
{
say("stopping for the new build's daemon (no transfer in flight%s)",
ps5_webui_scraping() ? "; the scraping job is saved and resumes there" : "");
stop = true;
}
else if (!root.empty() && !linked && !busy && !updating && !install_sent &&
now - idle_since >= kIdleSeconds)
{
say("no program of the title linked for %.0f s: the title is closed", kIdleSeconds);
stop = closed = true;
}
if (stale && listener >= 0 && !installed)
{
close(listener); /* the new build's daemon can start meanwhile */
listener = -1;
}
if (stop)
break;
}
if (starter_running)
pthread_join(starter, nullptr); /* a start under way ends before the stop */
for (const auto &link : links)
close(link.fd);
if (listener >= 0)
close(listener);
ps5_webui_stop();
/* ShadowMountPlus refuses to uninstall while a game runs, and RetroArch is one */
if (closed && !tiles_done)
ps5_webui_finish_forwarders([](const char *line) { say("%s", line); });
say("stopped");
return 0;
}