-
-
+ ) : multiSources && enrichedSources.length > 0 ? (
+ /* ===== MULTI-SOURCE: accordion catalogs ===== */
+
+ {enrichedSources.map(src => {
+ const availablePayloads = src.payloads.filter(p => !p.isInstalled || p.isUpdate)
+ const isExpanded = expandedSource === src.id
+ return (
+
+ {/* Catalog header */}
+
+ {/* Catalog payload list */}
+ {isExpanded && (
+
+ {availablePayloads.length === 0 ? (
+
+
All payloads installed
+
+ ) : (
+ availablePayloads.map(p => (
+
+
+
+ {p.description && (
+
{p.description}
+ )}
+
+
+
+ ))
+ )}
+
+ )}
- ))
+ )
+ })}
+
+ ) : (
+ /* ===== SINGLE-SOURCE: flat list (legacy / multi-source disabled) ===== */
+
+ {legacyLastUpdate > 0 && (
+
+ Last Sync: {new Date(legacyLastUpdate * 1000).toLocaleString()}
+
)}
+ {(() => {
+ const cloudItems = remotePayloads.filter(p => !p.isInstalled || p.isUpdate)
+ return cloudItems.length === 0 ? (
+
+
Repository Up to Date
+
+ ) : (
+ cloudItems.map(p => (
+
+
+
+
+ ))
+ )
+ })()}
)}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 231e7a1..fa25a21 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -38,7 +38,14 @@ export default defineConfig({
target: 'http://127.0.0.1:8081',
changeOrigin: true,
sse: true
- }
+ },
+ '/sources_list': { target: 'http://127.0.0.1:8081', changeOrigin: true },
+ '/sources_set': { target: 'http://127.0.0.1:8081', changeOrigin: true },
+ '/sources_add': { target: 'http://127.0.0.1:8081', changeOrigin: true },
+ '/sources_remove': { target: 'http://127.0.0.1:8081', changeOrigin: true },
+ '/repository_payloads': { target: 'http://127.0.0.1:8081', changeOrigin: true },
+ '/repository_refresh': { target: 'http://127.0.0.1:8081', changeOrigin: true },
+ '/repository_install': { target: 'http://127.0.0.1:8081', changeOrigin: true }
}
}
})
diff --git a/include/payload_mgr.h b/include/payload_mgr.h
index a0940b5..4732a1d 100644
--- a/include/payload_mgr.h
+++ b/include/payload_mgr.h
@@ -26,4 +26,14 @@ int payload_mgr_usb_move(const char *usb_path, int overwrite, char *out_json, si
int payload_mgr_check_self_update(char *out_path, size_t out_size);
+/* Sources Management */
+int payload_mgr_sources_list_json(char *buf, size_t size);
+int payload_mgr_sources_save(const char *json, size_t len);
+int payload_mgr_sources_add(const char *url, char *msg_buf, size_t msg_size);
+int payload_mgr_sources_remove(int index, char *msg_buf, size_t msg_size);
+
+/* Multi-Source Repository */
+size_t payload_mgr_multi_repository_list_json(char *buf, size_t size, int force_refresh);
+int payload_mgr_multi_repository_install(const char *filename, const char *source_id, const char *repo_url, char *msg, size_t msg_size);
+
#endif
diff --git a/include/pldmgr.h b/include/pldmgr.h
index 08d4dcc..371f76f 100644
--- a/include/pldmgr.h
+++ b/include/pldmgr.h
@@ -26,6 +26,10 @@
#define ROUTE_REPO_INSTALL "/repository_install"
#define ROUTE_REPO_PUSH "/repository_push"
#define ROUTE_REPO_INSTALL_PUSH "/repository_install_push"
+#define ROUTE_SOURCES_LIST "/sources_list"
+#define ROUTE_SOURCES_SET "/sources_set"
+#define ROUTE_SOURCES_ADD "/sources_add"
+#define ROUTE_SOURCES_REMOVE "/sources_remove"
#define ROUTE_USB_MOVE_CHECK "/usb_move_check"
#define ROUTE_USB_MOVE_PERFORM "/usb_move_perform"
#define ROUTE_CACHE_MANIFEST "/cache.appcache"
@@ -50,6 +54,8 @@ int pldmgr_read_config_bool(const char *key, int default_val);
/* Paths */
#define BASE_DATA_DIR "/data/pldmgr"
+#define SOURCES_CONFIG_PATH "/data/pldmgr/sources.json"
+#define MAX_SOURCES 9
/* Scan Locations (Internal + 8 USB ports) */
static const char *SCAN_DIRS[] = {
diff --git a/src/main.c b/src/main.c
index a66a7ee..ee79a16 100644
--- a/src/main.c
+++ b/src/main.c
@@ -272,7 +272,8 @@ struct UploadStatus {
static void read_next_config_values(int *enabled, long *repo_update,
int *browser_open, int *auto_delay,
- int *kill_disc_player, int *scan_usb, int *auto_install_app) {
+ int *kill_disc_player, int *scan_usb, int *auto_install_app,
+ int *multi_sources) {
FILE *f = fopen(PLDMGR_CONFIG_PATH, "r");
char line[256];
@@ -283,6 +284,7 @@ static void read_next_config_values(int *enabled, long *repo_update,
*kill_disc_player = 1; /* Default on */
*scan_usb = 0; /* Default off */
*auto_install_app = 1; /* Default on */
+ *multi_sources = 0; /* Default off */
if (!f) {
return;
@@ -303,6 +305,8 @@ static void read_next_config_values(int *enabled, long *repo_update,
*scan_usb = atoi(line + 18);
} else if (strncmp(line, "AUTO_INSTALL_APP=", 17) == 0) {
*auto_install_app = atoi(line + 17);
+ } else if (strncmp(line, "MULTI_SOURCES_ENABLED=", 22) == 0) {
+ *multi_sources = atoi(line + 22);
}
}
fclose(f);
@@ -310,7 +314,8 @@ static void read_next_config_values(int *enabled, long *repo_update,
static int write_next_config_values(int enabled, long repo_update,
int browser_open, int auto_delay,
- int kill_disc_player, int scan_usb, int auto_install_app) {
+ int kill_disc_player, int scan_usb, int auto_install_app,
+ int multi_sources) {
FILE *f;
mkdir(BASE_DATA_DIR, 0777);
@@ -326,6 +331,7 @@ static int write_next_config_values(int enabled, long repo_update,
fprintf(f, "KILL_DISC_PLAYER_ON_STARTUP=%d\n", kill_disc_player ? 1 : 0);
fprintf(f, "SCAN_USB_PAYLOADS=%d\n", scan_usb ? 1 : 0);
fprintf(f, "AUTO_INSTALL_APP=%d\n", auto_install_app ? 1 : 0);
+ fprintf(f, "MULTI_SOURCES_ENABLED=%d\n", multi_sources ? 1 : 0);
fclose(f);
return 0;
}
@@ -407,6 +413,15 @@ static enum MHD_Result on_request(void *cls, struct MHD_Connection *conn,
return MHD_YES;
}
+ if (strcmp(url, ROUTE_SOURCES_SET) == 0 && strcmp(method, "POST") == 0) {
+ struct PostStatus *status = malloc(sizeof(struct PostStatus));
+ status->data = NULL;
+ status->size = 0;
+ status->error = 0;
+ *con_cls = status;
+ return MHD_YES;
+ }
+
if (strncmp(url, ROUTE_REPO_INSTALL_PUSH,
strlen(ROUTE_REPO_INSTALL_PUSH)) == 0 &&
strcmp(method, "POST") == 0) {
@@ -495,9 +510,9 @@ static enum MHD_Result on_request(void *cls, struct MHD_Connection *conn,
}
if (enabled != -1) {
- int ex_en, ex_br, ex_del, ex_kill, ex_usb, ex_install;
+ int ex_en, ex_br, ex_del, ex_kill, ex_usb, ex_install, ex_multi;
long ex_repo;
- read_next_config_values(&ex_en, &ex_repo, &ex_br, &ex_del, &ex_kill, &ex_usb, &ex_install);
+ read_next_config_values(&ex_en, &ex_repo, &ex_br, &ex_del, &ex_kill, &ex_usb, &ex_install, &ex_multi);
/* Update individual fields from JSON if present */
int browser_open = ex_br;
@@ -573,8 +588,23 @@ static enum MHD_Result on_request(void *cls, struct MHD_Connection *conn,
}
}
+ int multi_sources = ex_multi;
+ char *multi_pos = strstr(status->data, "\"MULTI_SOURCES_ENABLED\"");
+ if (multi_pos) {
+ char *val = strchr(multi_pos, ':');
+ if (val) {
+ val++;
+ while (*val == ' ')
+ val++;
+ if (strncmp(val, "true", 4) == 0)
+ multi_sources = 1;
+ else if (strncmp(val, "false", 5) == 0)
+ multi_sources = 0;
+ }
+ }
+
if (write_next_config_values(enabled, ex_repo, browser_open,
- auto_delay, kill_disc, scan_usb, auto_install) == 0) {
+ auto_delay, kill_disc, scan_usb, auto_install, multi_sources) == 0) {
pldmgr_log("[PLDMGR] Saved config to %s\n", PLDMGR_CONFIG_PATH);
}
if (enabled == 0)
@@ -675,6 +705,41 @@ static enum MHD_Result on_request(void *cls, struct MHD_Connection *conn,
}
}
+ /* Handle POST data for /sources_set */
+ if (strcmp(url, ROUTE_SOURCES_SET) == 0 && strcmp(method, "POST") == 0) {
+ struct PostStatus *status = (struct PostStatus *)*con_cls;
+ if (*upload_data_size != 0) {
+ char *nd = realloc(status->data, status->size + *upload_data_size + 1);
+ if (!nd) {
+ status->error = 1;
+ } else {
+ status->data = nd;
+ memcpy(status->data + status->size, upload_data, *upload_data_size);
+ status->size += *upload_data_size;
+ status->data[status->size] = '\0';
+ }
+ *upload_data_size = 0;
+ return MHD_YES;
+ } else {
+ int ok = -1;
+ if (status->data && !status->error)
+ ok = payload_mgr_sources_save(status->data, status->size);
+ if (status->data)
+ free(status->data);
+ free(status);
+ *con_cls = NULL;
+
+ const char *msg = ok == 0 ? MSG_OK : "Failed to save sources";
+ struct MHD_Response *resp_ss = MHD_create_response_from_buffer(
+ strlen(msg), (void *)msg, MHD_RESPMEM_MUST_COPY);
+ add_cors_headers(resp_ss);
+ enum MHD_Result ret_ss = MHD_queue_response(
+ conn, ok == 0 ? MHD_HTTP_OK : MHD_HTTP_INTERNAL_SERVER_ERROR, resp_ss);
+ MHD_destroy_response(resp_ss);
+ return ret_ss;
+ }
+ }
+
/* Chunked data arrival */
if (strncmp(url, ROUTE_UPLOAD, strlen(ROUTE_UPLOAD)) == 0) {
struct UploadStatus *status = (struct UploadStatus *)*con_cls;
@@ -911,8 +976,14 @@ static enum MHD_Result on_request(void *cls, struct MHD_Connection *conn,
struct MHD_Response *oom_resp = alloc_response_buffer(&resp_buf);
if (oom_resp)
return MHD_queue_response(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, oom_resp);
- size_t len = payload_mgr_repository_list_json(resp_buf,
- RESPONSE_BUFFER_SIZE, 0);
+ int multi_en = 0, _a, _b, _c, _d, _e, _f;
+ long _g;
+ read_next_config_values(&_a, &_g, &_b, &_c, &_d, &_e, &_f, &multi_en);
+ size_t len;
+ if (multi_en)
+ len = payload_mgr_multi_repository_list_json(resp_buf, RESPONSE_BUFFER_SIZE, 0);
+ else
+ len = payload_mgr_repository_list_json(resp_buf, RESPONSE_BUFFER_SIZE, 0);
resp = MHD_create_response_from_buffer(len, (void *)resp_buf,
MHD_RESPMEM_MUST_FREE);
MHD_add_response_header(resp, "Content-Type", "application/json");
@@ -921,14 +992,21 @@ static enum MHD_Result on_request(void *cls, struct MHD_Connection *conn,
struct MHD_Response *oom_resp = alloc_response_buffer(&resp_buf);
if (oom_resp)
return MHD_queue_response(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, oom_resp);
- size_t len = payload_mgr_repository_list_json(resp_buf,
- RESPONSE_BUFFER_SIZE, 1);
- resp = MHD_create_response_from_buffer(len, (void *)resp_buf,
+ int multi_en2 = 0, _a2, _b2, _c2, _d2, _e2, _f2;
+ long _g2;
+ read_next_config_values(&_a2, &_g2, &_b2, &_c2, &_d2, &_e2, &_f2, &multi_en2);
+ size_t len2;
+ if (multi_en2)
+ len2 = payload_mgr_multi_repository_list_json(resp_buf, RESPONSE_BUFFER_SIZE, 1);
+ else
+ len2 = payload_mgr_repository_list_json(resp_buf, RESPONSE_BUFFER_SIZE, 1);
+ resp = MHD_create_response_from_buffer(len2, (void *)resp_buf,
MHD_RESPMEM_MUST_FREE);
MHD_add_response_header(resp, "Content-Type", "application/json");
} else if (strcmp(url, ROUTE_REPO_INSTALL) == 0) {
const char *filename = MHD_lookup_connection_value(conn, MHD_GET_ARGUMENT_KIND, "filename");
const char *repo_url = MHD_lookup_connection_value(conn, MHD_GET_ARGUMENT_KIND, "repo_url");
+ const char *source_id = MHD_lookup_connection_value(conn, MHD_GET_ARGUMENT_KIND, "source_id");
if (!filename) {
const char *err = "{\"ok\":false,\"message\":\"Missing filename\"}";
resp = MHD_create_response_from_buffer(strlen(err), (void *)err, MHD_RESPMEM_MUST_COPY);
@@ -945,8 +1023,16 @@ static enum MHD_Result on_request(void *cls, struct MHD_Connection *conn,
}
char msg_buf[1024] = "";
- const char *detail = (repo_url && repo_url[0]) ? repo_url : REPOSITORY_SOURCE_URL;
- int rc = payload_mgr_repository_install_download(filename, detail, msg_buf, sizeof(msg_buf));
+ int rc;
+ /* Use multi-source install if source_id is provided */
+ if (source_id && source_id[0]) {
+ rc = payload_mgr_multi_repository_install(filename, source_id,
+ repo_url ? repo_url : "",
+ msg_buf, sizeof(msg_buf));
+ } else {
+ const char *detail = (repo_url && repo_url[0]) ? repo_url : REPOSITORY_SOURCE_URL;
+ rc = payload_mgr_repository_install_download(filename, detail, msg_buf, sizeof(msg_buf));
+ }
if (msg_buf[0] == '\0') {
snprintf(msg_buf, sizeof(msg_buf), rc == 0 ? "Installed" : "Install failed");
}
@@ -958,6 +1044,62 @@ static enum MHD_Result on_request(void *cls, struct MHD_Connection *conn,
MHD_add_response_header(resp, "Content-Type", "application/json");
add_cors_headers(resp);
return MHD_queue_response(conn, rc == 0 ? MHD_HTTP_OK : MHD_HTTP_INTERNAL_SERVER_ERROR, resp);
+ } else if (strcmp(url, ROUTE_SOURCES_LIST) == 0) {
+ char *resp_buf;
+ struct MHD_Response *oom_resp = alloc_response_buffer(&resp_buf);
+ if (oom_resp)
+ return MHD_queue_response(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, oom_resp);
+ payload_mgr_sources_list_json(resp_buf, RESPONSE_BUFFER_SIZE);
+ resp = MHD_create_response_from_buffer(strlen(resp_buf), (void *)resp_buf, MHD_RESPMEM_MUST_FREE);
+ MHD_add_response_header(resp, "Content-Type", "application/json");
+ } else if (strcmp(url, ROUTE_SOURCES_ADD) == 0) {
+ const char *src_url = MHD_lookup_connection_value(conn, MHD_GET_ARGUMENT_KIND, "url");
+ if (!src_url || !src_url[0]) {
+ const char *err = "{\"ok\":false,\"message\":\"Missing url parameter\"}";
+ resp = MHD_create_response_from_buffer(strlen(err), (void *)err, MHD_RESPMEM_MUST_COPY);
+ MHD_add_response_header(resp, "Content-Type", "application/json");
+ add_cors_headers(resp);
+ return MHD_queue_response(conn, MHD_HTTP_BAD_REQUEST, resp);
+ }
+ char msg_buf_src[512] = "";
+ int rc_src = payload_mgr_sources_add(src_url, msg_buf_src, sizeof(msg_buf_src));
+ char json_resp_src[1024];
+ if (rc_src == 0) {
+ char name_escaped[512];
+ pldmgr_json_escape(msg_buf_src, name_escaped, sizeof(name_escaped));
+ snprintf(json_resp_src, sizeof(json_resp_src),
+ "{\"ok\":true,\"name\":\"%s\"}", name_escaped);
+ } else {
+ char msg_escaped[512];
+ pldmgr_json_escape(msg_buf_src, msg_escaped, sizeof(msg_escaped));
+ snprintf(json_resp_src, sizeof(json_resp_src),
+ "{\"ok\":false,\"message\":\"%s\"}", msg_escaped);
+ }
+ resp = MHD_create_response_from_buffer(strlen(json_resp_src), (void *)json_resp_src, MHD_RESPMEM_MUST_COPY);
+ MHD_add_response_header(resp, "Content-Type", "application/json");
+ add_cors_headers(resp);
+ return MHD_queue_response(conn, rc_src == 0 ? MHD_HTTP_OK : MHD_HTTP_BAD_REQUEST, resp);
+ } else if (strcmp(url, ROUTE_SOURCES_REMOVE) == 0) {
+ const char *idx_str = MHD_lookup_connection_value(conn, MHD_GET_ARGUMENT_KIND, "index");
+ if (!idx_str) {
+ const char *err = "{\"ok\":false,\"message\":\"Missing index parameter\"}";
+ resp = MHD_create_response_from_buffer(strlen(err), (void *)err, MHD_RESPMEM_MUST_COPY);
+ MHD_add_response_header(resp, "Content-Type", "application/json");
+ add_cors_headers(resp);
+ return MHD_queue_response(conn, MHD_HTTP_BAD_REQUEST, resp);
+ }
+ int idx = atoi(idx_str);
+ char msg_rm[256] = "";
+ int rc_rm = payload_mgr_sources_remove(idx, msg_rm, sizeof(msg_rm));
+ char json_rm[512];
+ char msg_rm_e[256];
+ pldmgr_json_escape(msg_rm, msg_rm_e, sizeof(msg_rm_e));
+ snprintf(json_rm, sizeof(json_rm), "{\"ok\":%s,\"message\":\"%s\"}",
+ rc_rm == 0 ? "true" : "false", msg_rm_e);
+ resp = MHD_create_response_from_buffer(strlen(json_rm), (void *)json_rm, MHD_RESPMEM_MUST_COPY);
+ MHD_add_response_header(resp, "Content-Type", "application/json");
+ add_cors_headers(resp);
+ return MHD_queue_response(conn, rc_rm == 0 ? MHD_HTTP_OK : MHD_HTTP_BAD_REQUEST, resp);
} else if (strncmp(url, ROUTE_LOAD_PAYLOAD, strlen(ROUTE_LOAD_PAYLOAD)) ==
0) {
const char *path = url + strlen(ROUTE_LOAD_PAYLOAD);
@@ -1080,10 +1222,10 @@ static enum MHD_Result on_request(void *cls, struct MHD_Connection *conn,
fclose(f);
}
- int config_enabled, config_browser, config_delay, config_kill, config_usb, config_install;
+ int config_enabled, config_browser, config_delay, config_kill, config_usb, config_install, config_multi;
long config_repo;
read_next_config_values(&config_enabled, &config_repo, &config_browser,
- &config_delay, &config_kill, &config_usb, &config_install);
+ &config_delay, &config_kill, &config_usb, &config_install, &config_multi);
char current_escaped[256];
char list_escaped[8192];
@@ -1124,10 +1266,10 @@ static enum MHD_Result on_request(void *cls, struct MHD_Connection *conn,
MHD_RESPMEM_MUST_COPY);
MHD_add_response_header(resp, "Content-Type", "application/json");
} else if (strcmp(url, ROUTE_GET_CONFIG) == 0) {
- int enabled = 0, browser_open = 1, auto_delay = 5, kill_disc = 1, scan_usb = 0, auto_install = 1;
+ int enabled = 0, browser_open = 1, auto_delay = 5, kill_disc = 1, scan_usb = 0, auto_install = 1, multi_src = 0;
long last_repo_update = 0;
read_next_config_values(&enabled, &last_repo_update, &browser_open,
- &auto_delay, &kill_disc, &scan_usb, &auto_install);
+ &auto_delay, &kill_disc, &scan_usb, &auto_install, &multi_src);
/* Get list */
char list_buf[4096] = {0};
@@ -1157,11 +1299,13 @@ static enum MHD_Result on_request(void *cls, struct MHD_Connection *conn,
"{\"AUTOLOAD_ENABLED\":%s,\"AUTOLOAD_LIST\":\"%s\",\"LAST_"
"REPOSITORY_UPDATE\":%ld,"
"\"AUTO_BROWSER_OPEN\":%s,\"AUTOLOAD_DELAY\":%d,\"KILL_DISC_"
- "PLAYER_ON_STARTUP\":%s,\"SCAN_USB_PAYLOADS\":%s,\"AUTO_INSTALL_APP\":%s}",
+ "PLAYER_ON_STARTUP\":%s,\"SCAN_USB_PAYLOADS\":%s,\"AUTO_INSTALL_APP\":%s,"
+ "\"MULTI_SOURCES_ENABLED\":%s}",
enabled ? "true" : "false", list_escaped, last_repo_update,
browser_open ? "true" : "false", auto_delay,
kill_disc ? "true" : "false", scan_usb ? "true" : "false",
- auto_install ? "true" : "false");
+ auto_install ? "true" : "false",
+ multi_src ? "true" : "false");
resp = MHD_create_response_from_buffer(strlen(resp_buf),
(void *)resp_buf,
MHD_RESPMEM_MUST_FREE);
@@ -1241,9 +1385,9 @@ int main(int argc, char *argv[]) {
}
/* Start Autoload Sequence (if config exists) */
- int ex_en, ex_br = 1, ex_del = 5, ex_kill = 1, ex_usb = 0, ex_install = 1;
+ int ex_en, ex_br = 1, ex_del = 5, ex_kill = 1, ex_usb = 0, ex_install = 1, ex_multi = 0;
long ex_repo = 0;
- read_next_config_values(&ex_en, &ex_repo, &ex_br, &ex_del, &ex_kill, &ex_usb, &ex_install);
+ read_next_config_values(&ex_en, &ex_repo, &ex_br, &ex_del, &ex_kill, &ex_usb, &ex_install, &ex_multi);
/* Install app if requested */
if (ex_install) {
diff --git a/src/payload_mgr.c b/src/payload_mgr.c
index 8ff7416..7b2ff57 100644
--- a/src/payload_mgr.c
+++ b/src/payload_mgr.c
@@ -32,6 +32,7 @@ typedef struct RepoPayload {
char last_update[64];
char version[64];
char checksum[65];
+ char source_name[256]; /* human-readable name of the source that installed this payload */
} RepoPayload;
typedef struct JsonListBuilder {
@@ -624,7 +625,7 @@ static int is_allowed_usb_path(const char *path) {
static int write_payload_details_json(const RepoPayload *item, const char *details_path, const char *install_source, const char *install_source_detail) {
char name[256], filename[384], url[1400], source[1400], source_direct[1400];
char description[1400], last_update[128], version[128], checksum[128];
- char downloaded_at[64], i_src[256], i_detail[1400];
+ char downloaded_at[64], i_src[256], i_detail[1400], i_src_name[256];
char json_buf[8192];
time_t now = time(NULL);
struct tm tmv;
@@ -644,6 +645,10 @@ static int is_allowed_usb_path(const char *path) {
pldmgr_json_escape(item->checksum, checksum, sizeof(checksum));
pldmgr_json_escape(install_source ? install_source : "unknown", i_src, sizeof(i_src));
pldmgr_json_escape(install_source_detail ? install_source_detail : "", i_detail, sizeof(i_detail));
+ /* source_name: the human-readable name of the source that supplied this
+ * payload. Stored in item->source_direct when set by multi-source
+ * install; falls back to empty string for default/uploaded payloads. */
+ pldmgr_json_escape(item->source_name[0] ? item->source_name : "", i_src_name, sizeof(i_src_name));
snprintf(json_buf, sizeof(json_buf),
"{\n"
@@ -658,10 +663,12 @@ static int is_allowed_usb_path(const char *path) {
" \"checksum\": \"%s\",\n"
" \"downloaded_at\": \"%s\",\n"
" \"install_source\": \"%s\",\n"
- " \"install_source_detail\": \"%s\"\n"
+ " \"install_source_detail\": \"%s\",\n"
+ " \"source_name\": \"%s\"\n"
"}\n",
name, filename, url, source, source_direct, description,
- last_update, version, checksum, downloaded_at, i_src, i_detail);
+ last_update, version, checksum, downloaded_at, i_src, i_detail,
+ i_src_name);
FILE *f = fopen(details_path, "w");
if (!f) return -1;
@@ -874,7 +881,150 @@ static int is_allowed_usb_path(const char *path) {
}
}
- json_append(&jb, "]}");
+ if (json_append(&jb, "],") != 0 ||
+ json_append(&jb, "\"meta\":{") != 0) {
+ return 0;
+ }
+
+ /* Emit meta: walk PAYLOADS_STORAGE_DIR/
/*.elf.json sidecars.
+ * Each sidecar is named .json. Strip ".json" to get
+ * the key. Only emit entries where source_name is non-empty. */
+ int meta_first = 1;
+ DIR *top = opendir(PAYLOADS_STORAGE_DIR);
+ if (top) {
+ struct dirent *sub;
+ while ((sub = readdir(top)) != NULL) {
+ if (sub->d_name[0] == '.') continue;
+ char subdir[512];
+ snprintf(subdir, sizeof(subdir), "%s/%s",
+ PAYLOADS_STORAGE_DIR, sub->d_name);
+ DIR *d = opendir(subdir);
+ if (!d) continue;
+ struct dirent *de;
+ while ((de = readdir(d)) != NULL) {
+ const char *n = de->d_name;
+ size_t nl = strlen(n);
+ /* Must end with ".json" and the part before must end
+ * with .elf / .bin / .lua e.g. "kstuff.elf.json" */
+ if (nl < 9) continue;
+ if (strcasecmp(n + nl - 5, ".json") != 0) continue;
+ /* base_len = length of name without ".json" */
+ size_t base_len = nl - 5;
+ /* Check last 4 chars of base for payload extension */
+ if (base_len < 4) continue;
+ char last4[5];
+ memcpy(last4, n + base_len - 4, 4);
+ last4[4] = '\0';
+ if (strcasecmp(last4, ".elf") != 0 &&
+ strcasecmp(last4, ".bin") != 0 &&
+ strcasecmp(last4, ".lua") != 0) continue;
+
+ /* Read sidecar */
+ char sidecar[700];
+ snprintf(sidecar, sizeof(sidecar), "%s/%s", subdir, n);
+ char *sc_json = NULL;
+ size_t sc_size = 0;
+ if (read_file_text(sidecar, &sc_json, &sc_size) != 0 || !sc_json)
+ continue;
+
+ /* Extract "source_name": "..." */
+ char sn_val[256] = "";
+ const char *sn_key = strstr(sc_json, "\"source_name\"");
+ if (sn_key) {
+ const char *colon = strchr(sn_key + 13, ':');
+ if (colon) {
+ const char *q = colon + 1;
+ while (*q == ' ' || *q == '\t') q++;
+ if (*q == '"') {
+ q++;
+ size_t pos = 0;
+ while (*q && *q != '"' && pos < sizeof(sn_val) - 1)
+ sn_val[pos++] = *q++;
+ sn_val[pos] = '\0';
+ }
+ }
+ }
+ /* Fallback for pre-source_name sidecars: if install_source is
+ * "repository" and install_source_detail has a URL, use its
+ * hostname so that old third-party installs still get a badge. */
+ if (!sn_val[0]) {
+ char i_src[64] = "";
+ const char *is_key = strstr(sc_json, "\"install_source\"");
+ if (is_key) {
+ const char *colon = strchr(is_key + 16, ':');
+ if (colon) {
+ const char *q = colon + 1;
+ while (*q == ' ' || *q == '\t') q++;
+ if (*q == '"') {
+ q++;
+ size_t pos = 0;
+ while (*q && *q != '"' && pos < sizeof(i_src) - 1)
+ i_src[pos++] = *q++;
+ i_src[pos] = '\0';
+ }
+ }
+ }
+ if (strcmp(i_src, "repository") == 0) {
+ char detail_url[512] = "";
+ const char *id_key = strstr(sc_json, "\"install_source_detail\"");
+ if (id_key) {
+ const char *colon = strchr(id_key + 23, ':');
+ if (colon) {
+ /* Skip "http://" or "https://" if next char is not '"' */
+ const char *q = colon + 1;
+ while (*q == ' ' || *q == '\t') q++;
+ if (*q == '"') {
+ q++;
+ size_t pos = 0;
+ while (*q && *q != '"' && pos < sizeof(detail_url) - 1)
+ detail_url[pos++] = *q++;
+ detail_url[pos] = '\0';
+ }
+ }
+ }
+ /* Only badge if it's NOT the official repository */
+ if (detail_url[0] &&
+ strstr(detail_url, "itsplk.github.io") == NULL) {
+ /* Extract hostname from URL */
+ const char *host = strstr(detail_url, "://");
+ host = host ? host + 3 : detail_url;
+ size_t pos = 0;
+ while (*host && *host != '/' && *host != ':' &&
+ pos < sizeof(sn_val) - 1)
+ sn_val[pos++] = *host++;
+ sn_val[pos] = '\0';
+ }
+ }
+ }
+ free(sc_json);
+ if (!sn_val[0]) continue; /* official source — no badge */
+
+ /* key = filename without trailing ".json" */
+ char key[300];
+ if (base_len >= sizeof(key)) base_len = sizeof(key) - 1;
+ memcpy(key, n, base_len);
+ key[base_len] = '\0';
+
+ char fn_esc[350], sn_esc[300];
+ pldmgr_json_escape(key, fn_esc, sizeof(fn_esc));
+ pldmgr_json_escape(sn_val, sn_esc, sizeof(sn_esc));
+ char entry[750];
+ snprintf(entry, sizeof(entry),
+ "%s\"%s\":{\"source_name\":\"%s\"}",
+ meta_first ? "" : ",", fn_esc, sn_esc);
+ if (json_append(&jb, entry) != 0) {
+ closedir(d);
+ closedir(top);
+ goto meta_done;
+ }
+ meta_first = 0;
+ }
+ closedir(d);
+ }
+ closedir(top);
+ }
+ meta_done:
+ json_append(&jb, "}}");
return jb.pos;
}
@@ -1522,3 +1672,639 @@ static int is_allowed_usb_path(const char *path) {
closedir(dir);
return -1;
}
+
+/* =========================================================
+ * SOURCE MANAGEMENT
+ * ========================================================= */
+
+typedef struct {
+ char id[64];
+ char name[256];
+ char url[1024];
+ int removable;
+} SourceEntry;
+
+/*
+ * Build the sources array from SOURCES_CONFIG_PATH.
+ * If the file is missing or corrupt, returns a default-only list.
+ */
+static int load_sources(SourceEntry *out, int *out_count) {
+ char *json = NULL;
+ size_t size = 0;
+ int count = 0;
+
+ memset(out, 0, sizeof(SourceEntry) * MAX_SOURCES);
+
+ /* Always start with the default source at index 0 */
+ strncpy(out[0].id, "default", sizeof(out[0].id) - 1);
+ strncpy(out[0].name, "Official Repository", sizeof(out[0].name) - 1);
+ strncpy(out[0].url, REPOSITORY_SOURCE_URL, sizeof(out[0].url) - 1);
+ out[0].removable = 0;
+ count = 1;
+
+ if (read_file_text(SOURCES_CONFIG_PATH, &json, &size) != 0 || !json || size == 0) {
+ if (json) free(json);
+ *out_count = count;
+ return 0;
+ }
+
+ /* Find "sources": [...] array */
+ const char *arr_start = strstr(json, "\"sources\"");
+ if (!arr_start) { free(json); *out_count = count; return 0; }
+
+ arr_start = strchr(arr_start, '[');
+ if (!arr_start) { free(json); *out_count = count; return 0; }
+
+ const char *p = arr_start + 1;
+ while (count < MAX_SOURCES && (p = strchr(p, '{')) != NULL) {
+ const char *end = strchr(p, '}');
+ if (!end) break;
+
+ char id[64] = "", name[256] = "", url[1024] = "";
+ char removable_str[8] = "";
+
+ json_extract_string(p, end, "id", id, sizeof(id));
+ json_extract_string(p, end, "name", name, sizeof(name));
+ json_extract_string(p, end, "url", url, sizeof(url));
+ json_extract_string(p, end, "removable", removable_str, sizeof(removable_str));
+
+ /* Skip the default entry stored in file — we already added it */
+ if (strcmp(id, "default") == 0) { p = end + 1; continue; }
+
+ if (name[0] && url[0] && id[0]) {
+ strncpy(out[count].id, id, sizeof(out[count].id) - 1);
+ strncpy(out[count].name, name, sizeof(out[count].name) - 1);
+ strncpy(out[count].url, url, sizeof(out[count].url) - 1);
+ out[count].removable = (strcmp(removable_str, "false") != 0);
+ count++;
+ }
+ p = end + 1;
+ }
+
+ free(json);
+ *out_count = count;
+ return 0;
+}
+
+static int save_sources(SourceEntry *sources, int count) {
+ char buf[16384];
+ JsonListBuilder jb = { buf, sizeof(buf), 0, 1 };
+
+ ensure_dir_recursive(BASE_DATA_DIR);
+
+ json_append(&jb, "{\"sources\":[\n");
+ for (int i = 0; i < count; i++) {
+ char id_e[128], name_e[512], url_e[2048];
+ pldmgr_json_escape(sources[i].id, id_e, sizeof(id_e));
+ pldmgr_json_escape(sources[i].name, name_e, sizeof(name_e));
+ pldmgr_json_escape(sources[i].url, url_e, sizeof(url_e));
+ if (json_append(&jb, " {\"id\":\"%s\",\"name\":\"%s\",\"url\":\"%s\",\"removable\":%s}%s\n",
+ id_e, name_e, url_e,
+ sources[i].removable ? "true" : "false",
+ (i < count - 1) ? "," : "") != 0) {
+ break;
+ }
+ }
+ json_append(&jb, "]}\n");
+
+ return write_file_text(SOURCES_CONFIG_PATH, buf, jb.pos);
+}
+
+int payload_mgr_sources_list_json(char *buf, size_t size) {
+ SourceEntry sources[MAX_SOURCES] = {0};
+ int count = 0;
+ load_sources(sources, &count);
+
+ JsonListBuilder jb = { buf, size, 0, 1 };
+ buf[0] = '\0';
+
+ json_append(&jb, "{\"sources\":[\n");
+ for (int i = 0; i < count; i++) {
+ char id_e[128], name_e[512], url_e[2048];
+ pldmgr_json_escape(sources[i].id, id_e, sizeof(id_e));
+ pldmgr_json_escape(sources[i].name, name_e, sizeof(name_e));
+ pldmgr_json_escape(sources[i].url, url_e, sizeof(url_e));
+ if (json_append(&jb, " {\"id\":\"%s\",\"name\":\"%s\",\"url\":\"%s\",\"removable\":%s}%s\n",
+ id_e, name_e, url_e,
+ sources[i].removable ? "true" : "false",
+ (i < count - 1) ? "," : "") != 0) {
+ break;
+ }
+ }
+ json_append(&jb, "]}\n");
+ return 0;
+}
+
+int payload_mgr_sources_save(const char *json, size_t len) {
+ SourceEntry sources[MAX_SOURCES] = {0};
+ int count = 0;
+
+ if (!json || len == 0) return -1;
+
+ /* Always put default at slot 0 */
+ strncpy(sources[0].id, "default", sizeof(sources[0].id) - 1);
+ strncpy(sources[0].name, "Official Repository", sizeof(sources[0].name) - 1);
+ strncpy(sources[0].url, REPOSITORY_SOURCE_URL, sizeof(sources[0].url) - 1);
+ sources[0].removable = 0;
+ count = 1;
+
+ const char *arr_start = strstr(json, "\"sources\"");
+ if (!arr_start) return -1;
+ arr_start = strchr(arr_start, '[');
+ if (!arr_start) return -1;
+
+ const char *p = arr_start + 1;
+ while (count < MAX_SOURCES && (p = strchr(p, '{')) != NULL) {
+ const char *end = strchr(p, '}');
+ if (!end) break;
+
+ char id[64] = "", name[256] = "", url[1024] = "";
+ json_extract_string(p, end, "id", id, sizeof(id));
+ json_extract_string(p, end, "name", name, sizeof(name));
+ json_extract_string(p, end, "url", url, sizeof(url));
+
+ if (strcmp(id, "default") == 0) { p = end + 1; continue; }
+ if (!name[0] || !url[0] || !id[0]) { p = end + 1; continue; }
+
+ strncpy(sources[count].id, id, sizeof(sources[count].id) - 1);
+ strncpy(sources[count].name, name, sizeof(sources[count].name) - 1);
+ strncpy(sources[count].url, url, sizeof(sources[count].url) - 1);
+ sources[count].removable = 1;
+ count++;
+ p = end + 1;
+ }
+
+ return save_sources(sources, count);
+}
+
+/*
+ * Download the JSON at url into a temp buffer, validate it has a "name" field
+ * and at least one payload, then append it to the sources list.
+ */
+int payload_mgr_sources_add(const char *url, char *msg_buf, size_t msg_size) {
+ char tmp_path[512];
+ char *json = NULL;
+ size_t json_size = 0;
+ RepoPayload *items = NULL;
+ size_t count = 0;
+ char source_name[256] = "";
+
+ if (!url || !url[0]) {
+ snprintf(msg_buf, msg_size, "URL is empty");
+ return -1;
+ }
+
+ ensure_dir_recursive(BASE_DATA_DIR);
+ snprintf(tmp_path, sizeof(tmp_path), "%s/source_validate.tmp", BASE_DATA_DIR);
+
+ if (download_to_file(url, tmp_path) != 0) {
+ snprintf(msg_buf, msg_size, "URL unreachable or download failed");
+ remove(tmp_path);
+ return -1;
+ }
+
+ if (read_file_text(tmp_path, &json, &json_size) != 0 || !json || json_size == 0) {
+ snprintf(msg_buf, msg_size, "Failed to read downloaded JSON");
+ remove(tmp_path);
+ if (json) free(json);
+ return -1;
+ }
+ remove(tmp_path);
+
+ /* Read top-level "name" field.
+ * Only search BEFORE the "payloads" key to avoid picking up individual
+ * payload items' own "name" fields.
+ * If there is no "payloads" key the JSON is a raw array — no top-level
+ * name is possible, so skip the search entirely. */
+ {
+ const char *payloads_key = strstr(json, "\"payloads\"");
+ if (payloads_key) {
+ /* Only scan the header section before "payloads" */
+ size_t search_len = (size_t)(payloads_key - json);
+ const char *name_key = NULL;
+ for (size_t i = 0; i + 6 <= search_len; i++) {
+ if (memcmp(json + i, "\"name\"", 6) == 0) {
+ name_key = json + i;
+ break;
+ }
+ }
+ if (name_key) {
+ const char *colon = strchr(name_key + 6, ':');
+ if (colon && (size_t)(colon - json) < search_len) {
+ const char *q = colon + 1;
+ while (*q == ' ' || *q == '\t') q++;
+ if (*q == '"') {
+ q++;
+ size_t pos = 0;
+ while (*q && *q != '"' && pos < sizeof(source_name) - 1)
+ source_name[pos++] = *q++;
+ source_name[pos] = '\0';
+ }
+ }
+ }
+ }
+ /* If still empty, fall back to the hostname portion of the URL */
+ if (!source_name[0]) {
+ /* Skip scheme: "http://" or "https://" */
+ const char *host = strstr(url, "://");
+ host = host ? host + 3 : url;
+ size_t pos = 0;
+ while (*host && *host != '/' && *host != ':' &&
+ pos < sizeof(source_name) - 1)
+ source_name[pos++] = *host++;
+ source_name[pos] = '\0';
+ }
+ }
+
+ /* The JSON may be in two formats:
+ * 1. { "name": "...", "payloads": [...] } <-- new format with name
+ * 2. { "payloads": [...] } <-- legacy format (array at root or under "payloads")
+ * parse_repository_payloads handles both since it just looks for '{' objects */
+ if (parse_repository_payloads(json, &items, &count) != 0 || count == 0) {
+ free(json);
+ if (items) free(items);
+ snprintf(msg_buf, msg_size, "No valid payloads found in source JSON");
+ return -1;
+ }
+ free(items);
+ free(json);
+
+ /* Load current sources and append */
+ SourceEntry sources[MAX_SOURCES] = {0};
+ int src_count = 0;
+ load_sources(sources, &src_count);
+
+ if (src_count >= MAX_SOURCES) {
+ snprintf(msg_buf, msg_size, "Maximum number of sources (%d) reached", MAX_SOURCES);
+ return -1;
+ }
+
+ /* Check for duplicate URL */
+ for (int i = 0; i < src_count; i++) {
+ if (strcmp(sources[i].url, url) == 0) {
+ snprintf(msg_buf, msg_size, "Source already added");
+ return -1;
+ }
+ }
+
+ /* Assign a unique ID */
+ char new_id[64];
+ snprintf(new_id, sizeof(new_id), "source_%d", (int)time(NULL));
+
+ strncpy(sources[src_count].id, new_id, sizeof(sources[src_count].id) - 1);
+ strncpy(sources[src_count].name, source_name, sizeof(sources[src_count].name) - 1);
+ strncpy(sources[src_count].url, url, sizeof(sources[src_count].url) - 1);
+ sources[src_count].removable = 1;
+ src_count++;
+
+ if (save_sources(sources, src_count) != 0) {
+ snprintf(msg_buf, msg_size, "Failed to save sources");
+ return -1;
+ }
+
+ /* Return the source name in msg_buf for the caller to forward to the UI */
+ snprintf(msg_buf, msg_size, "%s", source_name);
+ return 0;
+}
+
+int payload_mgr_sources_remove(int index, char *msg_buf, size_t msg_size) {
+ if (index <= 0) {
+ snprintf(msg_buf, msg_size, "Cannot remove the default source");
+ return -1;
+ }
+
+ SourceEntry sources[MAX_SOURCES] = {0};
+ int count = 0;
+ load_sources(sources, &count);
+
+ if (index >= count) {
+ snprintf(msg_buf, msg_size, "Invalid source index");
+ return -1;
+ }
+
+ /* Shift entries down */
+ for (int i = index; i < count - 1; i++) {
+ sources[i] = sources[i + 1];
+ }
+ count--;
+
+ if (save_sources(sources, count) != 0) {
+ snprintf(msg_buf, msg_size, "Failed to save sources");
+ return -1;
+ }
+
+ snprintf(msg_buf, msg_size, "OK");
+ return 0;
+}
+
+/* =========================================================
+ * MULTI-SOURCE REPOSITORY
+ * ========================================================= */
+
+/*
+ * Get the cache file path for a given source index.
+ * Index 0 (default) uses the original REPOSITORY_CACHE_PATH.
+ */
+static void get_source_cache_path(int index, char *out, size_t out_size) {
+ if (index == 0) {
+ strncpy(out, REPOSITORY_CACHE_PATH, out_size - 1);
+ out[out_size - 1] = '\0';
+ } else {
+ snprintf(out, out_size, "%s.src%d.json", REPOSITORY_CACHE_PATH, index);
+ }
+}
+
+/*
+ * Ensure the cache for a specific source URL is fresh.
+ * Returns 0 on success, -1 on failure.
+ */
+static int ensure_source_fresh(const char *url, const char *cache_path, int force_refresh) {
+ long last_update = 0;
+ time_t now = time(NULL);
+ char tmp_path[512];
+ char *json = NULL;
+ size_t json_size = 0;
+ RepoPayload *items = NULL;
+ size_t count = 0;
+
+ ensure_dir_recursive(BASE_DATA_DIR);
+
+ /* Read last update time from the cache file's companion .ts file */
+ char ts_path[512];
+ snprintf(ts_path, sizeof(ts_path), "%s.ts", cache_path);
+ FILE *tf = fopen(ts_path, "r");
+ if (tf) {
+ fscanf(tf, "%ld", &last_update);
+ fclose(tf);
+ }
+
+ if (!force_refresh && access(cache_path, F_OK) == 0) {
+ long delta = (long)now - last_update;
+ if (delta >= 0 && delta < REPOSITORY_REFRESH_INTERVAL_SEC) {
+ return 0;
+ }
+ }
+
+ snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", cache_path);
+ if (download_to_file(url, tmp_path) != 0) {
+ return -1;
+ }
+
+ if (read_file_text(tmp_path, &json, &json_size) != 0 || json_size == 0) {
+ if (json) free(json);
+ remove(tmp_path);
+ return -1;
+ }
+
+ /* Validate: must have at least 1 payload */
+ if (parse_repository_payloads(json, &items, &count) != 0 || count == 0) {
+ free(items);
+ free(json);
+ remove(tmp_path);
+ return -1;
+ }
+ free(items);
+ free(json);
+
+ if (rename(tmp_path, cache_path) != 0) {
+ remove(tmp_path);
+ return -1;
+ }
+
+ /* Write timestamp */
+ tf = fopen(ts_path, "w");
+ if (tf) {
+ fprintf(tf, "%ld", (long)now);
+ fclose(tf);
+ }
+
+ return 0;
+}
+
+/*
+ * Build JSON grouped by source:
+ * {
+ * "sources": [
+ * { "id": "default", "name": "Official Repository", "payload_count": 5,
+ * "last_update": 123456, "error": false,
+ * "payloads": [ {...}, ... ] },
+ * ...
+ * ]
+ * }
+ */
+size_t payload_mgr_multi_repository_list_json(char *buf, size_t size, int force_refresh) {
+ SourceEntry sources[MAX_SOURCES] = {0};
+ int src_count = 0;
+ load_sources(sources, &src_count);
+
+ JsonListBuilder jb = { buf, size, 0, 1 };
+ buf[0] = '\0';
+
+ json_append(&jb, "{\"sources\":[\n");
+
+ for (int si = 0; si < src_count; si++) {
+ char cache_path[512];
+ get_source_cache_path(si, cache_path, sizeof(cache_path));
+
+ int fetch_ok = (ensure_source_fresh(sources[si].url, cache_path, force_refresh) == 0);
+
+ char id_e[128], name_e[512];
+ pldmgr_json_escape(sources[si].id, id_e, sizeof(id_e));
+ pldmgr_json_escape(sources[si].name, name_e, sizeof(name_e));
+
+ /* Read last_update timestamp */
+ long last_update = 0;
+ char ts_path[512];
+ snprintf(ts_path, sizeof(ts_path), "%s.ts", cache_path);
+ FILE *tf = fopen(ts_path, "r");
+ if (tf) { fscanf(tf, "%ld", &last_update); fclose(tf); }
+
+ if (json_append(&jb, " {\"id\":\"%s\",\"name\":\"%s\",\"last_update\":%ld,\"error\":%s,\"payloads\":[\n",
+ id_e, name_e, last_update, fetch_ok ? "false" : "true") != 0) {
+ break;
+ }
+
+ if (fetch_ok) {
+ char *cached = NULL;
+ size_t cached_size = 0;
+ RepoPayload *items = NULL;
+ size_t count = 0;
+
+ if (read_file_text(cache_path, &cached, &cached_size) == 0 && cached) {
+ parse_repository_payloads(cached, &items, &count);
+ free(cached);
+ }
+
+ for (size_t pi = 0; pi < count; pi++) {
+ char name_pe[256], filename_e[512], desc_e[2048];
+ char ver_e[128], url_e[2048], src_id_e[128], src_name_e[512];
+
+ pldmgr_json_escape(items[pi].name, name_pe, sizeof(name_pe));
+ pldmgr_json_escape(items[pi].filename, filename_e, sizeof(filename_e));
+ pldmgr_json_escape(items[pi].description, desc_e, sizeof(desc_e));
+ pldmgr_json_escape(items[pi].version, ver_e, sizeof(ver_e));
+ pldmgr_json_escape(items[pi].url, url_e, sizeof(url_e));
+ pldmgr_json_escape(sources[si].id, src_id_e, sizeof(src_id_e));
+ pldmgr_json_escape(sources[si].name, src_name_e, sizeof(src_name_e));
+
+ if (json_append(&jb, " {\"name\":\"%s\",\"filename\":\"%s\",\"description\":\"%s\","
+ "\"version\":\"%s\",\"url\":\"%s\","
+ "\"source_id\":\"%s\",\"source_name\":\"%s\"}%s\n",
+ name_pe, filename_e, desc_e, ver_e, url_e,
+ src_id_e, src_name_e,
+ (pi < count - 1) ? "," : "") != 0) {
+ break;
+ }
+ }
+
+ if (items) free(items);
+ }
+
+ if (json_append(&jb, " ]}%s\n", (si < src_count - 1) ? "," : "") != 0) {
+ break;
+ }
+ }
+
+ json_append(&jb, "]}\n");
+ return jb.pos;
+}
+
+int payload_mgr_multi_repository_install(const char *filename, const char *source_id, const char *repo_url, char *msg, size_t msg_size) {
+ SourceEntry sources[MAX_SOURCES] = {0};
+ int src_count = 0;
+ load_sources(sources, &src_count);
+
+ /* Find the matching source */
+ int si = -1;
+ for (int i = 0; i < src_count; i++) {
+ if (strcmp(sources[i].id, source_id) == 0) { si = i; break; }
+ }
+
+ if (si < 0) {
+ /* Fall back to matching by URL */
+ for (int i = 0; i < src_count; i++) {
+ if (repo_url && strcmp(sources[i].url, repo_url) == 0) { si = i; break; }
+ }
+ }
+
+ if (si < 0) {
+ /* Last resort: use default */
+ si = 0;
+ }
+
+ char cache_path[512];
+ get_source_cache_path(si, cache_path, sizeof(cache_path));
+
+ /* Ensure cache is fresh */
+ if (access(cache_path, F_OK) != 0) {
+ if (ensure_source_fresh(sources[si].url, cache_path, 1) != 0) {
+ snprintf(msg, msg_size, "Failed to fetch source repository");
+ return -1;
+ }
+ }
+
+ /* Load cached payloads for this source */
+ char *cached = NULL;
+ size_t cached_size = 0;
+ RepoPayload *items = NULL;
+ size_t count = 0;
+
+ if (read_file_text(cache_path, &cached, &cached_size) != 0 || !cached) {
+ snprintf(msg, msg_size, "Repository cache unavailable");
+ return -1;
+ }
+
+ parse_repository_payloads(cached, &items, &count);
+ free(cached);
+
+ /* Find the requested payload */
+ int found = -1;
+ for (size_t i = 0; i < count; i++) {
+ if (strcmp(items[i].filename, filename) == 0) { found = (int)i; break; }
+ }
+
+ if (found < 0) {
+ if (items) free(items);
+ snprintf(msg, msg_size, "Payload not found in source");
+ return -1;
+ }
+
+ /* Download */
+ ensure_dir_recursive(PAYLOADS_STORAGE_DIR);
+ char tmp_path[512];
+ snprintf(tmp_path, sizeof(tmp_path), "%s/%s.part", PAYLOADS_STORAGE_DIR, items[found].filename);
+
+ if (download_to_file(items[found].url, tmp_path) != 0) {
+ if (items) free(items);
+ remove(tmp_path);
+ snprintf(msg, msg_size, "Download failed");
+ return -1;
+ }
+
+ /* Commit — reuse existing logic with the source URL as the detail */
+ const char *detail = (repo_url && repo_url[0]) ? repo_url : sources[si].url;
+
+ /* Use install_commit directly with the loaded item data */
+ char folder_name[128];
+ char payload_dir[512];
+ char final_path[640];
+ char details_path[700];
+
+ pldmgr_utils_get_payload_folder_name(items[found].filename, folder_name, sizeof(folder_name));
+ snprintf(payload_dir, sizeof(payload_dir), "%s/%s", PAYLOADS_STORAGE_DIR, folder_name);
+
+ if (ensure_dir_recursive(payload_dir) != 0) {
+ if (items) free(items);
+ remove(tmp_path);
+ snprintf(msg, msg_size, "Failed to create payload directory");
+ return -1;
+ }
+
+ snprintf(final_path, sizeof(final_path), "%s/%s", payload_dir, items[found].filename);
+ snprintf(details_path, sizeof(details_path), "%s/%s.json", payload_dir, items[found].filename);
+
+ /* Checksum verification (optional — skip if checksum absent) */
+ if (strlen(items[found].checksum) == 64) {
+ char calculated[65];
+ if (compute_sha256_file(tmp_path, calculated) != 0) {
+ if (items) free(items);
+ remove(tmp_path);
+ snprintf(msg, msg_size, "Checksum computation failed");
+ return -1;
+ }
+ if (strcasecmp(calculated, items[found].checksum) != 0) {
+ pldmgr_log("[PLDMGR] !!! Checksum mismatch for %s\n", items[found].filename);
+ if (items) free(items);
+ remove(tmp_path);
+ snprintf(msg, msg_size, "Checksum mismatch");
+ return -1;
+ }
+ }
+
+ remove_regular_files_in_dir(payload_dir, items[found].filename);
+
+ if (rename(tmp_path, final_path) != 0) {
+ if (items) free(items);
+ remove(tmp_path);
+ snprintf(msg, msg_size, "Failed to finalize payload file");
+ return -1;
+ }
+
+ /* Attach source_id and source_name to the item so metadata captures them */
+ strncpy(items[found].source, sources[si].id, sizeof(items[found].source) - 1);
+ strncpy(items[found].source_direct, sources[si].name, sizeof(items[found].source_direct) - 1);
+ strncpy(items[found].source_name, sources[si].name, sizeof(items[found].source_name) - 1);
+
+ if (write_payload_details_json(&items[found], details_path, "repository", detail) != 0) {
+ if (items) free(items);
+ snprintf(msg, msg_size, "Failed to write payload metadata");
+ return -1;
+ }
+
+ pldmgr_log("[PLDMGR] Multi-source install complete: %s (source: %s)\n",
+ items[found].filename, sources[si].name);
+ snprintf(msg, msg_size, "Installed %s", items[found].filename);
+ if (items) free(items);
+ return 0;
+}
+