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 @@
+
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();