diff --git a/Makefile b/Makefile index 07c8d78..d0e05fa 100644 --- a/Makefile +++ b/Makefile @@ -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) $^ diff --git a/assets/icon0.png b/assets/icon0.png new file mode 100644 index 0000000..4f2f066 Binary files /dev/null and b/assets/icon0.png differ diff --git a/assets/icon0.svg b/assets/icon0.svg new file mode 100644 index 0000000..40e67e5 --- /dev/null +++ b/assets/icon0.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + PD + PatchDL + PS5 Patch Tool + diff --git a/assets/param.json b/assets/param.json new file mode 100644 index 0000000..560f7bf --- /dev/null +++ b/assets/param.json @@ -0,0 +1,10 @@ +{ + "titleId": "PTDL00001", + "deeplinkUri": "http://127.0.0.1:12880/", + "localizedParameters": { + "defaultLanguage": "en-US", + "en-US": { + "titleName": "PatchDL" + } + } +} diff --git a/src/patchdl_tile.c b/src/patchdl_tile.c new file mode 100644 index 0000000..b6a64d3 --- /dev/null +++ b/src/patchdl_tile.c @@ -0,0 +1,205 @@ +#include "patchdl_tile.h" +#include "patchdl_install.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +/* 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; +} diff --git a/src/patchdl_tile.h b/src/patchdl_tile.h new file mode 100644 index 0000000..5335ab4 --- /dev/null +++ b/src/patchdl_tile.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +/* 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//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); diff --git a/src/patchdl_websrv.c b/src/patchdl_websrv.c index 18b6855..05ea929 100644 --- a/src/patchdl_websrv.c +++ b/src/patchdl_websrv.c @@ -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();