Home-screen tile via sceAppInstUtilAppInstallTitleDir

Write param.json + icon0.png into /user/app/<TITLE_ID>/sce_sys/, then
call sceAppInstUtilAppInstallTitleDir to register the directory as an
app. No package, no code signing — the installer reads the metadata
files directly.

The tile uses TITLE_ID PTDL00001 and deeplinkUri
http://127.0.0.1:12880/, so tapping it opens PatchDL's own UI in the
on-console browser. Only useful while the ELF is running.

Asset pipeline: param.json + icon0.png live under assets/ and get
.incbin'd straight into .rodata. Makefile lists them as TILE_ASSETS so
a touch on either forces a relink.

Stat-guard: file_matches() diffs each asset against the on-disk copy
first; when nothing changed the install API isn't called at all. Keeps
repeated payload starts from re-registering the app. Repeated
/api/install_tile calls return "already installed and up to date".

Backend lazy-load: AppInstUtil isn't mapped until something pokes it,
so the helper polls patchdl_install_backend_check() for up to ~15 s
before resolving sceAppInstUtilAppInstallTitleDir.

Wiring: /api/install_tile POST triggers the install on demand;
patchdl_websrv_start() runs it at startup when home_shortcut is on;
and flipping the toggle from off to on in /api/config also fires it.
All three paths share the stat-guarded helper so they're safe to
repeat.
This commit is contained in:
Knutwurst committed 2026-06-24 21:50:59 +02:00
1 parent d0ca22d42d
commit fcd43b54ae
7 files changed
+302 -5

No files matched your search

+8 -3
View File
@@ -23,7 +23,12 @@ SRCS := src/main.c \
src/patchdl_resolve.c \
src/patchdl_verxml.c \
src/patchdl_install.c \
src/patchdl_notify.c
src/patchdl_notify.c \
src/patchdl_tile.c
# patchdl_tile.c uses .incbin to embed param.json + icon0.png; touching the
# assets must trigger a rebuild.
TILE_ASSETS := assets/param.json assets/icon0.png
WEB_ASSETS := web/index.html web/styles.css web/app.js
GEN_SRCS := $(patsubst web/%,gen/web/%.c,$(WEB_ASSETS))
@@ -56,8 +61,8 @@ gen/web/%.c: web/% scripts/gen_asset_module.py | gen/web
$(SQLITE_OBJ): $(SQLITE_DIR)/sqlite3.c
$(CC) $(SQLITE_CFLAGS) -c -o $@ $<
$(BIN): $(SRCS) $(GEN_SRCS) $(SQLITE_OBJ)
$(CC) $(CFLAGS) -o $@ $^ $(LDADD)
$(BIN): $(SRCS) $(GEN_SRCS) $(SQLITE_OBJ) $(TILE_ASSETS)
$(CC) $(CFLAGS) -o $@ $(SRCS) $(GEN_SRCS) $(SQLITE_OBJ) $(LDADD)
test: $(BIN)
$(PS5_DEPLOY) -h $(PS5_HOST) -p $(PS5_PORT) $^
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+20
View File
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0f2a1e"/>
<stop offset="1" stop-color="#0a1612"/>
</linearGradient>
<linearGradient id="badge" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#21d07a"/>
<stop offset="1" stop-color="#179a59"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="72" fill="url(#bg)"/>
<rect x="56" y="56" width="160" height="160" rx="36" fill="url(#badge)"/>
<text x="136" y="178" font-family="Helvetica,Arial,sans-serif" font-size="116"
font-weight="800" fill="#0a1612" text-anchor="middle">PD</text>
<text x="256" y="346" font-family="Helvetica,Arial,sans-serif" font-size="80"
font-weight="700" fill="#e7ecea" text-anchor="middle">PatchDL</text>
<text x="256" y="404" font-family="Helvetica,Arial,sans-serif" font-size="32"
font-weight="500" fill="#7a8f86" text-anchor="middle">PS5 Patch Tool</text>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+10
View File
@@ -0,0 +1,10 @@
{
"titleId": "PTDL00001",
"deeplinkUri": "http://127.0.0.1:12880/",
"localizedParameters": {
"defaultLanguage": "en-US",
"en-US": {
"titleName": "PatchDL"
}
}
}
+205
View File
@@ -0,0 +1,205 @@
#include "patchdl_tile.h"
#include "patchdl_install.h"
#include <errno.h>
#include <fcntl.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include <ps5/kernel.h>
/* Embed the tile assets into .rodata directly via .incbin — no codegen step,
no Python helper, no second translation unit. Matches itsPLK's pattern. */
#define INCASSET(name, file) \
__asm__(".section .rodata\n" \
".global " #name "\n" \
".global " #name "_end\n" \
".global " #name "_size\n" \
".align 16\n" #name ":\n" \
".incbin \"" file "\"\n" #name "_end:\n" #name "_size:\n" \
".quad " #name "_end - " #name "\n" \
".previous\n"); \
extern const uint8_t name[]; \
extern const size_t name##_size;
INCASSET(tile_param_json, "assets/param.json");
INCASSET(tile_icon0_png, "assets/icon0.png");
#define TILE_TITLE_ID "PTDL00001"
/* Forward decls — we resolve sceAppInstUtilInitialize/AppInstallTitleDir at
runtime via the kernel dynlib helpers so the ELF stays loader-friendly. */
typedef int (*ai_init_fn)(void);
typedef int (*ai_install_dir_fn)(const char *title_id, const char *parent_dir,
void *opts);
static intptr_t
dynsym_by_name(const char *sym) {
uint32_t h = 0;
if (kernel_dynlib_handle(-1, "libSceAppInstUtil.sprx", &h) < 0) return 0;
return kernel_dynlib_dlsym(-1, h, sym);
}
static intptr_t
dynsym_by_nid(const char *nid) {
uint32_t h = 0;
if (kernel_dynlib_handle(-1, "libSceAppInstUtil.sprx", &h) < 0) return 0;
return kernel_dynlib_resolve(-1, h, nid);
}
static int
write_all(const char *path, const uint8_t *data, size_t size) {
int fd;
ssize_t w;
size_t off = 0;
/* O_NOFOLLOW + 0600: don't follow a symlink at the destination, and don't
create the file with the libc default 0666 mode. Same pattern as the
net layer's fopen_safe. */
fd = open(path, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW | O_CLOEXEC, 0600);
if (fd < 0) return -1;
while (off < size) {
w = write(fd, data + off, size - off);
if (w < 0) {
if (errno == EINTR) continue;
close(fd);
return -1;
}
off += (size_t)w;
}
close(fd);
return 0;
}
static int
file_matches(const char *path, const uint8_t *expected, size_t expected_size) {
struct stat st;
uint8_t *buf;
int fd;
ssize_t n;
int match = 0;
if (stat(path, &st) != 0) return 0;
if ((size_t)st.st_size != expected_size) return 0;
fd = open(path, O_RDONLY | O_CLOEXEC);
if (fd < 0) return 0;
buf = malloc(expected_size);
if (!buf) { close(fd); return 0; }
n = read(fd, buf, expected_size);
close(fd);
if (n == (ssize_t)expected_size && memcmp(buf, expected, expected_size) == 0)
match = 1;
free(buf);
return match;
}
int
patchdl_tile_install_if_needed(char *msg, size_t msg_sz) {
char base_dir[128];
char sce_sys_dir[160];
char param_path[192];
char icon_path[192];
ai_init_fn ai_initialize = NULL;
ai_install_dir_fn ai_install_title = NULL;
int rc;
snprintf(base_dir, sizeof base_dir, "/user/app/%s", TILE_TITLE_ID);
snprintf(sce_sys_dir, sizeof sce_sys_dir, "/user/app/%s/sce_sys", TILE_TITLE_ID);
snprintf(param_path, sizeof param_path, "/user/app/%s/sce_sys/param.json", TILE_TITLE_ID);
snprintf(icon_path, sizeof icon_path, "/user/app/%s/sce_sys/icon0.png", TILE_TITLE_ID);
/* stat-guard: if everything on disk already matches, do nothing. Re-running
the install API every payload start would be wasted work and burns the
only safe path through Sony's installer state machine. */
{
struct stat st;
if (stat(base_dir, &st) == 0 &&
file_matches(param_path, tile_param_json, tile_param_json_size) &&
file_matches(icon_path, tile_icon0_png, tile_icon0_png_size)) {
snprintf(msg, msg_sz, "tile already installed and up to date");
return 0;
}
}
/* AppInstUtil is loaded lazily by the install backend thread. Poke it +
poll briefly so libSceAppInstUtil.sprx is mapped before we try to
resolve symbols out of it. Bounded to a few seconds so a stuck init
doesn't wedge an MHD worker forever. */
{
char ready_msg[128];
int ready = -1;
for (int i = 0; i < 60 && ready != 0; i++) {
ready = patchdl_install_backend_check(ready_msg, sizeof ready_msg);
if (ready != 0) usleep(250 * 1000);
}
if (ready != 0) {
snprintf(msg, msg_sz,
"install backend not ready: %s", ready_msg);
return -1;
}
}
/* Resolve the install API now so we can fail fast before touching disk.
itsPLK uses the NID (Wudg3Xe3heE) because the symbol export is
Sony-private; try both, NID first since it survives a stripped sprx. */
ai_install_title = (ai_install_dir_fn)dynsym_by_nid("Wudg3Xe3heE");
if (!ai_install_title)
ai_install_title = (ai_install_dir_fn)dynsym_by_name(
"sceAppInstUtilAppInstallTitleDir");
if (!ai_install_title) {
snprintf(msg, msg_sz, "sceAppInstUtilAppInstallTitleDir not resolved");
return -1;
}
ai_initialize = (ai_init_fn)dynsym_by_name("sceAppInstUtilInitialize");
if (ai_initialize) {
rc = ai_initialize();
/* SCE_OK == 0; a non-zero rc here usually means "already initialised"
in this process, which is fine. We only bail on a clearly fatal
code (anything that isn't already the success case). */
if (rc != 0 && rc != 0x80B21161 /* ALREADY_INITIALIZED */) {
snprintf(msg, msg_sz,
"sceAppInstUtilInitialize failed 0x%08x", (unsigned)rc);
return -1;
}
}
if (mkdir(base_dir, 0755) && errno != EEXIST) {
snprintf(msg, msg_sz, "mkdir %s failed errno=%d", base_dir, errno);
return -1;
}
if (mkdir(sce_sys_dir, 0755) && errno != EEXIST) {
snprintf(msg, msg_sz, "mkdir %s failed errno=%d", sce_sys_dir, errno);
return -1;
}
if (write_all(param_path, tile_param_json, tile_param_json_size)) {
snprintf(msg, msg_sz, "write param.json failed errno=%d", errno);
return -1;
}
if (write_all(icon_path, tile_icon0_png, tile_icon0_png_size)) {
snprintf(msg, msg_sz, "write icon0.png failed errno=%d", errno);
return -1;
}
rc = ai_install_title(TILE_TITLE_ID, "/user/app/", NULL);
if (rc != 0) {
snprintf(msg, msg_sz,
"AppInstallTitleDir(%s) returned 0x%08x",
TILE_TITLE_ID, (unsigned)rc);
return -1;
}
snprintf(msg, msg_sz, "tile installed (%s)", TILE_TITLE_ID);
return 0;
}
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <stddef.h>
/* Install / refresh the PatchDL home-screen tile.
*
* Approach (from itsPLK's ps5-payload-manager/app_installer.c, which adapted
* John Tornblom's ftpsrv work): write param.json + icon0.png into
* /user/app/<TITLE_ID>/sce_sys/
* then call sceAppInstUtilAppInstallTitleDir(title_id, "/user/app/", 0)
* which registers the directory as an app. The tile's deeplinkUri opens
* Sony's WebKit browser at http://127.0.0.1:12880/, i.e. PatchDL's own UI
* — only useful while the ELF is running.
*
* No PKG, no code signing, no debug-magic. Files only — Sony's installer
* registers the directory as an app.
*
* stat-guard: the asset bytes are diffed against the on-disk copy first;
* if nothing changed the install API isn't called at all (avoids a costly
* re-register every payload start, and avoids the dangerous CancelInstall
* code path). Returns 0 on success or when nothing needed doing.
*/
int patchdl_tile_install_if_needed(char *msg, size_t msg_sz);
+36 -2
View File
@@ -5,6 +5,7 @@
#include "patchdl_install.h"
#include "patchdl_net.h"
#include "patchdl_resolve.h"
#include "patchdl_tile.h"
#include "patchdl_verxml.h"
#include "patchdl_version.h"
@@ -1847,6 +1848,7 @@ static enum MHD_Result
handle_config_post(struct MHD_Connection *conn, const char *body) {
char pol[8];
int mc;
int tile_just_enabled = 0;
json_get_str(body, "default_policy", pol, sizeof(pol));
/* Only the two real values are accepted; anything else is silently
@@ -1866,8 +1868,12 @@ handle_config_post(struct MHD_Connection *conn, const char *body) {
json_get_bool(body, "delete_pkg_after_install", g_cfg.delete_pkg_after_install);
g_cfg.verify_downloads =
json_get_bool(body, "verify_downloads", g_cfg.verify_downloads);
g_cfg.home_shortcut =
json_get_bool(body, "home_shortcut", g_cfg.home_shortcut);
{
int prev_tile = g_cfg.home_shortcut;
g_cfg.home_shortcut =
json_get_bool(body, "home_shortcut", g_cfg.home_shortcut);
tile_just_enabled = (!prev_tile && g_cfg.home_shortcut);
}
g_cfg.max_connections =
json_get_int(body, "max_connections", g_cfg.max_connections);
if (g_cfg.max_connections < 1) g_cfg.max_connections = 1;
@@ -1887,6 +1893,16 @@ handle_config_post(struct MHD_Connection *conn, const char *body) {
pthread_mutex_unlock(&g_pool.mtx);
save_config();
/* Trigger the tile install when the toggle just flipped on. Best-effort —
the config save is already committed; we don't want a tile error to
roll that back. Stat-guard inside the helper makes repeated calls a
no-op, so a save without flipping the toggle still costs nothing. */
if (tile_just_enabled) {
char tile_msg[256];
(void)patchdl_tile_install_if_needed(tile_msg, sizeof(tile_msg));
}
return queue_json_owned(conn, MHD_HTTP_OK, build_config_json());
}
@@ -1998,6 +2014,15 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
return queue_json_owned(conn, rc == 0 ? MHD_HTTP_OK : MHD_HTTP_BAD_GATEWAY,
strdup(resp));
}
if (!strcmp(url, "/api/install_tile")) {
char msg[256], resp[320];
int rc = patchdl_tile_install_if_needed(msg, sizeof(msg));
snprintf(resp, sizeof(resp),
"{\"ok\":%s,\"rc\":%d,\"message\":\"%s\"}",
rc == 0 ? "true" : "false", rc, msg);
return queue_json_owned(conn, rc == 0 ? MHD_HTTP_OK : MHD_HTTP_BAD_GATEWAY,
strdup(resp));
}
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
}
@@ -2251,6 +2276,15 @@ patchdl_websrv_start(unsigned short port) {
g_titles[i].enabled = (g_titles[i].source_type != PATCHDL_SOURCE_UNKNOWN);
load_config();
/* If the user opted into a home-screen tile, refresh it now. The helper
is stat-guarded — when nothing changed on disk it's a no-op, so the
cost on subsequent startups is two file reads. Best-effort: never
block startup on a tile install failure. */
if (g_cfg.home_shortcut) {
char tile_msg[256];
(void)patchdl_tile_install_if_needed(tile_msg, sizeof(tile_msg));
}
/* Flag titles that have a partial download on disk so the UI can offer
Resume after a reboot. */
detect_resumable_partials();