Compare commits

..
7 Commits
Author SHA1 Message Date
Knutwurst 04b63eb800 Rebuild when a header changes 2026-10-20 08:23:48 +02:00
Knutwurst e853bbb0b4 Bump version to 0.3.3 2026-10-20 08:22:16 +02:00
Knutwurst e6e7054e95 Measure the game's drive through the system root
Game paths (/user/app/<id>) don't resolve in PatchDL's own root view, so
the install-space checks must go through the system root the scanner
keeps, or they silently skipped. Whether the download folder and the
game share a drive is decided by the file system id or, failing that,
its exact size. A full drive (0 bytes free) now counts as full instead
of as not measurable. The download-time check moved into
download_and_install_fit().
2026-10-20 08:12:03 +02:00
Knutwurst 687966bb8e Check room for the install before a download starts
A download that fits can still leave too little room to install it: the
installer needs about the package's size again on the game's drive. When
the download starts and the manifest gives the size, PatchDL now checks
both. On the same drive the download and the install have to fit
together; on different drives each one on its own. If not, the download
doesn't start and the card says how much is needed and suggests freeing
space or another download drive.
2026-10-20 08:01:23 +02:00
Knutwurst 4d44427737 Warn before an install the game's drive has no room for
The console's installer writes the update next to the game. With less
free space there than the package needs, it reads the header and stops
without saying why (#20). Before starting, PatchDL compares the free
space on the game's drive with the package size plus 1 GB and, if it's
short, asks whether to install anyway. The margin is an estimate; the
installer's exact need isn't known.
2026-10-20 07:41:41 +02:00
Knutwurst cdde608369 Bump version to 0.3.2 2026-10-19 20:49:33 +02:00
Knutwurst 85be96b02a Pick the download drive; move the home screen tile to Media
Settings lists the internal storage and every mounted drive (/mnt/extN,
/mnt/usbN) with its free space. New downloads go to <drive>/patchdl on
the chosen drive and install from there. A title keeps its folder on
the drive where it already has one, so packages downloaded earlier are
still found, listed and installed; while the chosen drive is unplugged,
downloads go to the internal storage. Config and caches stay in
/data/patchdl. Closes #18.

The tile's param.json sets applicationCategoryType 65536, which puts it
in the Media section. The home screen keeps the section a tile was
registered in, so an older tile is uninstalled once and registered
again. Closes #19.
2026-10-19 20:49:01 +02:00
12 changed files with 579 additions and 102 deletions

No files matched your search

+2 -1
View File
@@ -63,7 +63,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) $(TILE_ASSETS)
# Headers too: a changed header (the version string, say) must rebuild.
$(BIN): $(SRCS) $(wildcard src/*.h) $(GEN_SRCS) $(SQLITE_OBJ) $(TILE_ASSETS)
$(CC) $(CFLAGS) -o $@ $(SRCS) $(GEN_SRCS) $(SQLITE_OBJ) $(LDADD)
test: $(BIN)
+1
View File
@@ -1,5 +1,6 @@
{
"titleId": "PTDL00001",
"applicationCategoryType": 65536,
"deeplinkUri": "http://127.0.0.1:12880/",
"localizedParameters": {
"defaultLanguage": "en-US",
+4 -3
View File
@@ -674,11 +674,12 @@ patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
}
snprintf(file_uri, sizeof(file_uri), "file://%s", sdk_path);
title_dir = strstr(local_path, "/data/patchdl/");
/* <drive>/patchdl/<title>/<file>, on any download drive */
title_dir = strstr(local_path, "/patchdl/");
file_base = strrchr(local_path, '/');
if (title_dir && file_base && file_base > title_dir + strlen("/data/patchdl/")) {
if (title_dir && file_base && file_base > title_dir + strlen("/patchdl/")) {
char title_id[32] = {0};
const char *t = title_dir + strlen("/data/patchdl/");
const char *t = title_dir + strlen("/patchdl/");
size_t tlen = (size_t)(file_base - t);
if (tlen > 0 && tlen < sizeof(title_id)) {
char ip[INET_ADDRSTRLEN] = {0};
+12
View File
@@ -22,6 +22,7 @@
#include <string.h>
#include <sys/mount.h>
#include <sys/stat.h>
#include <sys/statvfs.h>
#include <unistd.h>
#define MAX_TITLES 512
@@ -683,6 +684,17 @@ read_title_file(const char *const *bases, const char *title_id, const char *name
return -1;
}
int
patchdl_scan_sys_statvfs(const char *path, struct statvfs *out) {
int fd, rc;
if (g_sysroot_fd < 0 || !path || path[0] != '/') return -1;
fd = openat(g_sysroot_fd, path[1] ? path + 1 : ".", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (fd < 0) return -1;
rc = fstatvfs(fd, out);
close(fd);
return rc;
}
int
patchdl_scan_read_app_file(const char *title_id, const char *name,
char *buf, size_t sz) {
+6
View File
@@ -84,6 +84,12 @@ const char *patchdl_scan_step(void);
/* Read <title dir>/<name> of an installed title (e.g. app.json) through the
kept system root handle; thread-safe, no working-directory change.
0 on success with a NUL-terminated buf. */
/* statvfs of a path as the system sees it (e.g. a game's /user/app/<id>),
through the system root kept by the first scan: the process's own root
doesn't show those paths. 0 on success. */
struct statvfs;
int patchdl_scan_sys_statvfs(const char *path, struct statvfs *out);
int patchdl_scan_read_app_file(const char *title_id, const char *name,
char *buf, size_t sz);
/* Same for an installed PS4 patch: /user/patch/<id>/<name>. */
+27 -1
View File
@@ -47,6 +47,7 @@ typedef int (*ai_init_fn)(void);
typedef int (*ai_install_dir_fn)(const char *title_id, const char *parent_dir,
void *opts);
typedef int (*ai_install_all_fn)(void *opts);
typedef int (*ai_uninstall_fn)(const char *title_id);
static intptr_t
dynsym_by_name(const char *sym) {
@@ -123,7 +124,7 @@ patchdl_tile_install_if_needed(char *msg, size_t msg_sz) {
ai_init_fn ai_initialize = NULL;
ai_install_dir_fn ai_install_title = NULL;
ai_install_all_fn ai_install_all = NULL;
int rc;
int rc, was_registered;
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);
@@ -205,6 +206,31 @@ patchdl_tile_install_if_needed(char *msg, size_t msg_sz) {
return -1;
}
/* The home screen keeps the section (Games or Media) a tile was first
registered in. A tile from an older PatchDL sits under Games; take
it off before registering it again so it moves to Media. Only ever
PatchDL's own tile id is passed here. */
{
struct stat st;
/* an older param.json on disk, whether or not the receipt exists
(tiles from before 0.0.7 have none) */
was_registered = stat(param_path, &st) == 0 &&
!file_matches(param_path, tile_param_json, tile_param_json_size);
}
if (was_registered) {
ai_uninstall_fn ai_uninstall = (ai_uninstall_fn)dynsym_by_name(
"sceAppInstUtilAppUnInstall");
if (ai_uninstall) (void)ai_uninstall(TILE_TITLE_ID);
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;
}
}
unlink(receipt_path);
if (write_all(param_path, tile_param_json, tile_param_json_size)) {
snprintf(msg, msg_sz, "write param.json failed errno=%d", errno);
+1 -1
View File
@@ -9,4 +9,4 @@
#pragma once
#define PATCHDL_VERSION "0.3.1"
#define PATCHDL_VERSION "0.3.3"
+404 -70
View File
@@ -35,6 +35,7 @@
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/statvfs.h>
#include <sys/mount.h>
#include <unistd.h>
static struct MHD_Daemon *web_daemon;
@@ -183,7 +184,8 @@ static struct {
int verify_downloads; /* SHA-256 each manifest piece (default off) */
int home_shortcut; /* user wants a home-screen browser tile */
int max_connections; /* parallel download connections (1..POOL_MAX_CONN) */
} g_cfg = { "deny", 0, 1, 0, 1, 4 };
char download_drive[32]; /* "/data" or a mounted drive like "/mnt/ext1" */
} g_cfg = { "deny", 0, 1, 0, 1, 4, "/data" };
/* ---------- download connection pool ----------------------------------- */
/* N worker threads share one job at a time (admit_max=1): a single download
@@ -248,7 +250,8 @@ typedef struct dl_job {
int verifying; /* admit is hashing a package found on disk */
int in_admit; /* admit_next holds this job with the lock dropped */
long long verify_bytes; /* bytes hashed so far */
long long free_bytes; /* rc -3: bytes free on /data at the time */
long long free_bytes; /* rc -3: bytes free on that drive at the time */
int space_install; /* rc -3: short because the install needs room too */
int last_curl_rc; /* CURLcode from the most recent piece */
char fail_step[24]; /* why admit failed (manifest fetch), or "" */
long last_http_code; /* HTTP status from the most recent piece */
@@ -270,13 +273,102 @@ static struct {
volatile long long inflight_bytes[POOL_MAX_CONN]; /* per-slot live counters */
} g_pool = { .mtx = PTHREAD_MUTEX_INITIALIZER, .cv = PTHREAD_COND_INITIALIZER };
#define PATCHDL_DL_DIR "/data/patchdl"
#define PATCHDL_DATA_DIR "/data/patchdl" /* config and caches; always internal */
#define PATCHDL_CFG_PATH "/data/patchdl/config.json"
/* ---------- download drives ---------------------------------------------
Packages live in <drive>/patchdl/<title>/. The drive is the internal
storage (/data) or a mounted drive (/mnt/extN for extended storage and
M.2, /mnt/usbN for USB). A title's folder stays where its download
started: lookups check every drive first, and only a title without a
folder anywhere goes to the drive chosen in Settings. */
#define MAX_DRIVES 12
/* Guards g_cfg.download_drive for current_root, which runs with the pool
lock held too: a leaf lock, taken after g_mutex or g_pool.mtx and never
holding anything else. Writers hold g_mutex as well. */
static pthread_mutex_t g_drive_mtx = PTHREAD_MUTEX_INITIALIZER;
static int
drive_name_ok(const char *m) {
if (!strcmp(m, "/data")) return 1;
return (!strncmp(m, "/mnt/ext", 8) || !strncmp(m, "/mnt/usb", 8)) &&
isdigit((unsigned char)m[8]) && (!m[9] || (isdigit((unsigned char)m[9]) && !m[10]));
}
/* The drive is mounted right now (an unplugged USB drive is not). */
static int
drive_mounted(const char *drive) {
struct statfs sf;
if (!strcmp(drive, "/data")) return 1;
return statfs(drive, &sf) == 0 && !strcmp(sf.f_mntonname, drive);
}
/* /data first, then every mounted extN/usbN drive. Returns the count. */
static int
list_drives(char out[][32], int max) {
struct statfs *mnt;
int n = 0, k;
snprintf(out[n++], 32, "/data");
/* getfsstat into our own buffer: getmntinfo shares a static one, and
this runs on several threads at once */
k = getfsstat(NULL, 0, MNT_NOWAIT);
if (k <= 0 || !(mnt = calloc((size_t)k + 4, sizeof *mnt))) return n;
k = getfsstat(mnt, (long)(((size_t)k + 4) * sizeof *mnt), MNT_NOWAIT);
for (int i = 0; i < k && n < max; i++) {
int dup = 0;
if (!drive_name_ok(mnt[i].f_mntonname) || !strcmp(mnt[i].f_mntonname, "/data")) continue;
for (int j = 0; j < n; j++) if (!strcmp(out[j], mnt[i].f_mntonname)) dup = 1;
if (!dup) snprintf(out[n++], 32, "%s", mnt[i].f_mntonname);
}
free(mnt);
return n;
}
/* Where new downloads go: <chosen drive>/patchdl, or the internal storage
while the chosen drive isn't there. */
static void
current_root(char *out, size_t sz) {
char drive[32];
pthread_mutex_lock(&g_drive_mtx);
snprintf(drive, sizeof drive, "%s", g_cfg.download_drive);
pthread_mutex_unlock(&g_drive_mtx);
if (!drive_name_ok(drive) || !drive_mounted(drive)) snprintf(drive, sizeof drive, "/data");
snprintf(out, sz, "%s/patchdl", drive);
}
/* The title's folder: an existing one on any drive, else one on the
current drive. title_id must already be a safe path segment. */
static void
title_dir(const char *title_id, char *out, size_t sz) {
char drives[MAX_DRIVES][32], root[64];
int n = list_drives(drives, MAX_DRIVES);
struct stat st;
for (int i = 0; i < n; i++) {
snprintf(out, sz, "%s/patchdl/%s", drives[i], title_id);
if (stat(out, &st) == 0 && S_ISDIR(st.st_mode)) return;
}
current_root(root, sizeof root);
snprintf(out, sz, "%s/%s", root, title_id);
}
/* mkdir the folder and its patchdl parent. */
static void
make_title_dir(const char *dir) {
char parent[288];
const char *slash = strrchr(dir, '/');
if (slash && (size_t)(slash - dir) < sizeof parent) {
memcpy(parent, dir, (size_t)(slash - dir));
parent[slash - dir] = '\0';
mkdir(parent, 0777);
}
mkdir(dir, 0777);
}
/* The source policy and CDN allowlist are fixed (the safety model), so they
stay constant; only the four mutable fields above are user-controlled. */
static const char config_tail_json[] =
"\"download_dir\":\"/data/patchdl (internal)\","
"\"source_policy\":{"
"\"official\":{\"allow_check\":true,\"allow_download\":true,\"allow_install\":true},"
"\"external\":{\"allow_check\":true,\"allow_download\":true,\"allow_install\":true},"
@@ -546,17 +638,24 @@ path_segment_safe(const char *s) {
}
/* Validate a local PKG path for /api/install_aip: must be exactly
<PATCHDL_DL_DIR>/<safe-title-id>/<safe-filename>. Rejects anything
outside /data/patchdl/, anything with .. or unsafe chars. */
<drive>/patchdl/<safe-title-id>/<safe-filename>. Rejects anything
outside a download drive, anything with .. or unsafe chars. */
static int
local_install_path_safe(const char *path) {
const char *prefix = PATCHDL_DL_DIR "/";
size_t plen = strlen(prefix);
char drives[MAX_DRIVES][32], prefix[64];
int nd = list_drives(drives, MAX_DRIVES), hit = -1;
size_t plen = 0;
const char *tail, *slash;
char seg[64];
size_t len;
if (!path || strncmp(path, prefix, plen) != 0) return 0;
if (!path) return 0;
for (int i = 0; i < nd && hit < 0; i++) {
snprintf(prefix, sizeof prefix, "%s/patchdl/", drives[i]);
plen = strlen(prefix);
if (!strncmp(path, prefix, plen)) hit = i;
}
if (hit < 0) return 0;
tail = path + plen;
slash = strchr(tail, '/');
if (!slash || slash == tail) return 0;
@@ -764,7 +863,11 @@ queue_pkg_file(struct MHD_Connection *conn, const char *url) {
if (!path_segment_safe(title_id) || !path_segment_safe(file))
return queue_text(conn, MHD_HTTP_FORBIDDEN, "forbidden");
snprintf(path, sizeof(path), "%s/%s/%s", PATCHDL_DL_DIR, title_id, file);
{
char dir[160];
title_dir(title_id, dir, sizeof dir);
snprintf(path, sizeof(path), "%s/%s", dir, file);
}
fd = open(path, O_RDONLY);
if (fd < 0) {
record_pkg_diag(url, NULL, MHD_HTTP_NOT_FOUND, 0, 0, 0);
@@ -852,20 +955,67 @@ queue_pkg_file(struct MHD_Connection *conn, const char *url) {
/* ---------- status JSON ------------------------------------------------- */
/* Free space on the download partition, in MB. Best-effort: 0 if unavailable. */
/* statvfs of the drive holding `path` (in this process's view). A folder
that doesn't exist yet counts as its closest existing parent. */
static int
statvfs_up(const char *path, struct statvfs *vfs) {
char p[288], *slash;
snprintf(p, sizeof p, "%s", path);
for (;;) {
if (statvfs(p, vfs) == 0) return 0;
if (!(slash = strrchr(p, '/')) || slash == p) return -1;
*slash = '\0';
}
}
/* Free space on the drive holding `path`, in MB. 0 if unavailable. */
static long long
drive_free_mb(const char *path) {
struct statvfs vfs;
if (statvfs_up(path, &vfs)) return 0;
return (long long)(((unsigned long long)vfs.f_bavail *
(unsigned long long)vfs.f_frsize) / (1024ULL * 1024ULL));
}
/* Free bytes on a game's drive, -1 if it can't be measured. Game paths
(/user/app/<id>, /mnt/extN/user/app/<id>) only resolve through the
system root the scanner keeps. */
static long long
game_free_bytes(const char *game_dir) {
struct statvfs vfs;
if (!game_dir[0] || patchdl_scan_sys_statvfs(game_dir, &vfs)) return -1;
return (long long)((unsigned long long)vfs.f_bavail * (unsigned long long)vfs.f_frsize);
}
/* The download folder and the game sit on the same drive. The two paths
live in different views of the filesystem, so compare the file system
id, and failing that its exact size. */
static int
same_drive(const char *dl_dir, const char *game_dir) {
struct statvfs a, b;
if (!game_dir[0] || statvfs_up(dl_dir, &a) || patchdl_scan_sys_statvfs(game_dir, &b))
return 0;
if (a.f_fsid && a.f_fsid == b.f_fsid) return 1;
return a.f_blocks == b.f_blocks && a.f_frsize == b.f_frsize;
}
/* Free space where new downloads go, in MB. */
static long long
data_free_mb(void) {
struct statvfs vfs;
if (statvfs(PATCHDL_DL_DIR, &vfs) == 0 || statvfs("/data", &vfs) == 0)
return (long long)(((unsigned long long)vfs.f_bavail *
(unsigned long long)vfs.f_frsize) / (1024ULL * 1024ULL));
return 0;
char root[64], *slash;
current_root(root, sizeof root);
if ((slash = strrchr(root, '/'))) *slash = '\0'; /* the drive itself */
return drive_free_mb(root);
}
/* Headroom kept free on /data so a download never fills the console's
internal storage to the last byte. */
#define PATCHDL_SPACE_MARGIN (2LL * 1024 * 1024 * 1024)
/* Extra room asked for on the game's drive before an install, on top of the
package size. A guess: the installer's exact need isn't known. */
#define INSTALL_SPACE_MARGIN (1LL * 1024 * 1024 * 1024)
/* Bytes still to be written for a package of `total` bytes at `path`: a
partial from an earlier run already occupies part of it. */
static long long
@@ -876,14 +1026,49 @@ space_needed(const char *path, long long total) {
return total > have ? total - have : 0;
}
/* 1 if `need` bytes plus the margin fit; *free_out gets the free bytes. */
/* 1 if `need` bytes plus the margin fit on the drive holding `dir`;
*free_out gets the free bytes. */
static int
space_ok(long long need, long long *free_out) {
long long free_b = data_free_mb() * 1024LL * 1024LL;
space_ok(const char *dir, long long need, long long *free_out) {
long long free_b = drive_free_mb(dir) * 1024LL * 1024LL;
if (free_out) *free_out = free_b;
return need + PATCHDL_SPACE_MARGIN <= free_b;
}
/* Is there room to download what's missing of a package and then install
it? The install needs about the package's size again on the game's drive
(game_dir); on the drive of the download folder (dl_dir) the two share
the free space. 1 if it fits. Otherwise *need_out / *free_out describe
the drive that is short and *for_install says whether the install part
made it short. */
static int
download_and_install_fit(const char *dl_dir, const char *game_dir,
long long remaining, long long total,
long long *need_out, long long *free_out, int *for_install) {
long long inst = total + INSTALL_SPACE_MARGIN, free_b = 0;
int same = same_drive(dl_dir, game_dir);
*for_install = 0;
if (!space_ok(dl_dir, remaining + (same ? inst : 0), &free_b)) {
*need_out = remaining + (same ? inst : 0);
*free_out = free_b;
*for_install = same;
return 0;
}
if (game_dir[0] && !same) {
long long gfree = game_free_bytes(game_dir);
if (gfree >= 0 && gfree < inst) {
*need_out = inst;
*free_out = gfree;
*for_install = 1;
return 0;
}
}
*need_out = remaining;
*free_out = free_b;
return 1;
}
/* Built fresh per request so free space stays live (a 60+ GB download moves
it a lot). g_fw is read-only after startup, so this is thread-safe. */
/* The console's LAN address, so a page opened on the PS5 itself (127.0.0.1)
@@ -919,7 +1104,8 @@ is_title_dir_name(const char *n) {
return 1;
}
/* Bytes in /data/patchdl/<title>/ folders, for the storage tile. */
/* Bytes in <drive>/patchdl/<title>/ folders on every drive, for the
storage tile. */
static long long
downloads_bytes(void) {
DIR *d, *sd;
@@ -928,19 +1114,25 @@ downloads_bytes(void) {
char dir[288], path[560];
long long sum = 0;
if (!(d = opendir(PATCHDL_DL_DIR))) return 0;
while ((de = readdir(d))) {
if (!is_title_dir_name(de->d_name)) continue;
snprintf(dir, sizeof dir, "%s/%s", PATCHDL_DL_DIR, de->d_name);
if (!(sd = opendir(dir))) continue;
while ((fe = readdir(sd))) {
if (fe->d_name[0] == '.') continue;
snprintf(path, sizeof path, "%s/%s", dir, fe->d_name);
if (!stat(path, &st) && S_ISREG(st.st_mode)) sum += (long long)st.st_size;
char drives[MAX_DRIVES][32], root[64];
int nd = list_drives(drives, MAX_DRIVES);
for (int k = 0; k < nd; k++) {
snprintf(root, sizeof root, "%s/patchdl", drives[k]);
if (!(d = opendir(root))) continue;
while ((de = readdir(d))) {
if (!is_title_dir_name(de->d_name)) continue;
snprintf(dir, sizeof dir, "%s/%s", root, de->d_name);
if (!(sd = opendir(dir))) continue;
while ((fe = readdir(sd))) {
if (fe->d_name[0] == '.') continue;
snprintf(path, sizeof path, "%s/%s", dir, fe->d_name);
if (!stat(path, &st) && S_ISREG(st.st_mode)) sum += (long long)st.st_size;
}
closedir(sd);
}
closedir(sd);
closedir(d);
}
closedir(d);
return sum;
}
@@ -955,17 +1147,18 @@ titles_gen(void) {
static char *
build_status_json(void) {
char ip[INET_ADDRSTRLEN];
char *out = malloc(768);
char ip[INET_ADDRSTRLEN], root[64];
char *out = malloc(896);
if (!out) return NULL;
lan_ipv4(ip, sizeof ip);
snprintf(out, 768,
current_root(root, sizeof root);
snprintf(out, 896,
"{\"firmware\":\"%s\","
"\"firmware_build\":\"0x%08x\","
"\"dns_guard\":\"Active\","
"\"resolver\":\"Internal allowlist\","
"\"free_space_mb\":%lld,"
"\"download_dir\":\"/data/patchdl (internal)\","
"\"download_dir\":\"%s\","
"\"lan_ip\":\"%s\","
"\"http_port\":%d,"
"\"version\":\"%s\","
@@ -973,7 +1166,7 @@ build_status_json(void) {
"\"downloads_bytes\":%lld,"
"\"rescan_ms\":[%ld,%ld,%ld],"
"\"rescan_step\":\"%s / %s\",\"rescan_running_ms\":%ld}",
g_fw.str, g_fw.bin, data_free_mb(), ip, PATCHDL_HTTP_PORT,
g_fw.str, g_fw.bin, data_free_mb(), root, ip, PATCHDL_HTTP_PORT,
PATCHDL_VERSION, titles_gen(), downloads_bytes(),
g_rescan_ms[0], g_rescan_ms[1], g_rescan_ms[2],
g_rescan_step, patchdl_scan_step(),
@@ -987,8 +1180,9 @@ build_status_json(void) {
are appended from config_tail_json. Caller owns the result (queue_json_owned). */
static char *
build_config_json(void) {
char head[224];
char head[384], root[64];
current_root(root, sizeof root);
pthread_mutex_lock(&g_mutex);
snprintf(head, sizeof(head),
"{\"default_policy\":\"%s\","
@@ -996,13 +1190,14 @@ build_config_json(void) {
"\"delete_pkg_after_install\":%s,"
"\"verify_downloads\":%s,"
"\"home_shortcut\":%s,"
"\"max_connections\":%d,",
"\"max_connections\":%d,"
"\"download_drive\":\"%s\",\"download_dir\":\"%s\",",
g_cfg.default_policy[0] ? g_cfg.default_policy : "deny",
g_cfg.install_after_download ? "true" : "false",
g_cfg.delete_pkg_after_install ? "true" : "false",
g_cfg.verify_downloads ? "true" : "false",
g_cfg.home_shortcut ? "true" : "false",
g_cfg.max_connections);
g_cfg.max_connections, g_cfg.download_drive, root);
pthread_mutex_unlock(&g_mutex);
char *out = malloc(strlen(head) + sizeof(config_tail_json));
@@ -1018,7 +1213,7 @@ static void
save_config(void) {
FILE *f;
mkdir(PATCHDL_DL_DIR, 0777);
mkdir(PATCHDL_DATA_DIR, 0777);
f = fopen(PATCHDL_CFG_PATH, "w");
if (!f) return;
@@ -1029,13 +1224,13 @@ save_config(void) {
"\"delete_pkg_after_install\":%s,\n"
"\"verify_downloads\":%s,\n"
"\"home_shortcut\":%s,\n"
"\"max_connections\":%d,\n\"titles\":{",
"\"max_connections\":%d,\n\"download_drive\":\"%s\",\n\"titles\":{",
g_cfg.default_policy[0] ? g_cfg.default_policy : "deny",
g_cfg.install_after_download ? "true" : "false",
g_cfg.delete_pkg_after_install ? "true" : "false",
g_cfg.verify_downloads ? "true" : "false",
g_cfg.home_shortcut ? "true" : "false",
g_cfg.max_connections);
g_cfg.max_connections, g_cfg.download_drive);
for (size_t i = 0; i < g_title_count; i++)
fprintf(f, "%s\"%s\":%s", i ? "," : "",
g_titles[i].title_id, g_titles[i].enabled ? "true" : "false");
@@ -1111,6 +1306,15 @@ load_config(void) {
json_get_int(buf, "max_connections", g_cfg.max_connections);
if (g_cfg.max_connections < 1) g_cfg.max_connections = 1;
if (g_cfg.max_connections > POOL_MAX_CONN) g_cfg.max_connections = POOL_MAX_CONN;
{
char drive[32] = "";
json_get_str(buf, "download_drive", drive, sizeof drive);
if (drive_name_ok(drive)) {
pthread_mutex_lock(&g_drive_mtx);
snprintf(g_cfg.download_drive, sizeof g_cfg.download_drive, "%s", drive);
pthread_mutex_unlock(&g_drive_mtx);
}
}
overlay_title_policy(buf, g_titles, g_title_count);
free(buf);
@@ -1320,8 +1524,9 @@ build_downloads_json(void) {
jbuf_appendf(&j, ",\"rc\":%d,\"last_curl_rc\":%d,\"last_http_code\":%ld",
job->rc, job->last_curl_rc, job->last_http_code);
if (job->rc == -3)
jbuf_appendf(&j, ",\"need_bytes\":%lld,\"free_bytes\":%lld",
job->need_bytes, job->free_bytes);
jbuf_appendf(&j, ",\"need_bytes\":%lld,\"free_bytes\":%lld%s",
job->need_bytes, job->free_bytes,
job->space_install ? ",\"space_install\":true" : "");
if (job->fail_step[0]) {
jbuf_append(&j, ",\"fail_step\":");
jbuf_append_str(&j, job->fail_step);
@@ -1508,7 +1713,11 @@ title_pkg_path(const char *title_id, const char *patch_url,
snprintf(name, sizeof(name), "%s.pkg",
path_segment_safe(title_id) ? title_id : "patch");
}
snprintf(out, out_sz, "%s/%s/%s", PATCHDL_DL_DIR, title_id, name);
{
char dir[160];
title_dir(title_id, dir, sizeof dir);
snprintf(out, out_sz, "%s/%s", dir, name);
}
}
static int
@@ -1522,7 +1731,9 @@ url_is_manifest(const char *url) {
static void
title_state_path(const char *title_id, char *out, size_t sz) {
snprintf(out, sz, "%s/%s/state.json", PATCHDL_DL_DIR, title_id);
char dir[160];
title_dir(title_id, dir, sizeof dir);
snprintf(out, sz, "%s/state.json", dir);
}
static void
@@ -1532,15 +1743,24 @@ remove_dl_state(const char *title_id) {
unlink(path);
}
/* Delete a title's internal download directory and its contents. Best-effort;
only ever touches /data/patchdl (self-protecting against a bad segment). */
/* Delete a title's download folder and its contents. Best-effort; only ever
touches <drive>/patchdl/<title> (self-protecting against a bad segment). */
static void remove_dir_files(const char *dir);
static void
remove_title_dir(const char *title_id) {
char dir[288], path[560];
char dir[288];
if (!path_segment_safe(title_id)) return;
title_dir(title_id, dir, sizeof dir);
remove_dir_files(dir);
}
/* Delete the files in one flat download folder, then the folder. */
static void
remove_dir_files(const char *dir) {
char path[560];
DIR *d;
struct dirent *e;
if (!path_segment_safe(title_id)) return;
snprintf(dir, sizeof(dir), "%s/%s", PATCHDL_DL_DIR, title_id);
if ((d = opendir(dir))) {
while ((e = readdir(d))) {
if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, ".."))
@@ -1557,7 +1777,9 @@ remove_title_dir(const char *title_id) {
static void
manual_source_path(const char *title_id, char *out, size_t sz) {
snprintf(out, sz, "%s/%s/manual.json", PATCHDL_DL_DIR, title_id);
char dir[160];
title_dir(title_id, dir, sizeof dir);
snprintf(out, sz, "%s/manual.json", dir);
}
/* Persist the pasted link next to the download so install and resume still
@@ -1566,9 +1788,8 @@ static void
save_manual_source(const char *title_id, const char *url, const char *version) {
char dir[288], path[320];
FILE *f;
snprintf(dir, sizeof dir, "%s/%s", PATCHDL_DL_DIR, title_id);
mkdir(PATCHDL_DL_DIR, 0777);
mkdir(dir, 0777);
title_dir(title_id, dir, sizeof dir);
make_title_dir(dir);
manual_source_path(title_id, path, sizeof path);
if ((f = fopen(path, "w"))) {
fprintf(f, "{\"url\":\"%s\",\"version\":\"%s\"}\n", url, version);
@@ -1595,7 +1816,7 @@ clear_manual_source(const char *title_id) {
set_manual_source(title_id, "", "");
manual_source_path(title_id, path, sizeof path);
unlink(path);
snprintf(dir, sizeof dir, "%s/%s", PATCHDL_DL_DIR, title_id);
title_dir(title_id, dir, sizeof dir);
rmdir(dir); /* fails while the pkg is still there; the cancel path removes it */
}
@@ -2025,7 +2246,8 @@ admit_next(void) {
dl_job_t *q = NULL;
patchdl_manifest_t mf;
char murl[768], dest[320], dir[288];
int is_manifest, fd = -1, ok = 0, no_space = 0;
int is_manifest, fd = -1, ok = 0, no_space = 0, space_inst = 0;
char q_title[32];
unsigned char *pre_done = NULL; /* pieces proven good by hash */
char fstep[24] = "";
int fcurl = 0;
@@ -2048,12 +2270,12 @@ admit_next(void) {
snprintf(murl, sizeof murl, "%s", q->manifest_url);
snprintf(dest, sizeof dest, "%s", q->dest);
snprintf(dir, sizeof dir, "%s", q->dir);
snprintf(q_title, sizeof q_title, "%s", q->title_id);
pthread_mutex_unlock(&g_pool.mtx);
/* ---- network + disk, NO pool lock ---- */
memset(&mf, 0, sizeof mf);
mkdir(PATCHDL_DL_DIR, 0777);
mkdir(dir, 0777);
make_title_dir(dir);
if (is_manifest) {
if (patchdl_fetch_manifest(murl, &mf) != 0 || mf.count <= 0) {
patchdl_http_last_error(fstep, sizeof fstep, &fcurl, &fhttp);
@@ -2062,8 +2284,24 @@ admit_next(void) {
total = mf.total;
adopted = adopt_by_size(dir, dest, total);
need = space_needed(dest, total);
if (!space_ok(need, &free_b)) no_space = 1;
else fd = open(dest, O_RDWR | O_CREAT, 0666);
/* The install needs about the package's size again on the
game's drive. Check that now, before downloading something
that can't be installed: on the same drive the download and
the install share the free space. */
{
char gdir[256] = "";
pthread_mutex_lock(&g_mutex);
for (size_t i = 0; i < g_title_count; i++)
if (!strcmp(g_titles[i].title_id, q_title)) {
snprintf(gdir, sizeof gdir, "%s", g_titles[i].source_path);
break;
}
pthread_mutex_unlock(&g_mutex);
if (!download_and_install_fit(dir, gdir, need, total,
&need, &free_b, &space_inst))
no_space = 1;
}
if (!no_space) fd = open(dest, O_RDWR | O_CREAT, 0666);
if (fd >= 0) {
struct stat st;
if (fstat(fd, &st) == 0) old_size = (long long)st.st_size;
@@ -2137,6 +2375,7 @@ admit_next(void) {
q->rc = no_space ? -3 : -1;
q->need_bytes = need;
q->free_bytes = free_b;
q->space_install = space_inst;
/* the manifest fetch failed: say where, so a report can tell a
blocked host from a TLS or HTTP problem */
snprintf(q->fail_step, sizeof q->fail_step, "%s", fstep);
@@ -2447,7 +2686,11 @@ do_download(struct MHD_Connection *conn, const char *title_id,
{
snprintf(job->manifest_url, sizeof job->manifest_url, "%s", patch_url);
snprintf(job->dir, sizeof job->dir, "%s/%s", PATCHDL_DL_DIR, title_id);
title_dir(title_id, job->dir, sizeof job->dir);
/* create it now: from here on every lookup (sidecar, package,
install) finds this folder, even if the drive in Settings
changes before the download starts */
make_title_dir(job->dir);
title_pkg_path(title_id, patch_url, job->dest, sizeof job->dest);
job->is_manifest = url_is_manifest(patch_url);
}
@@ -2587,7 +2830,8 @@ download_complete(const char *title_id) {
return json_get_ll(buf, "done_bytes", 0) >= json_get_ll(buf, "total", 0);
}
/* Everything PatchDL keeps under /data/patchdl, one entry per title folder,
/* Everything PatchDL keeps in <drive>/patchdl on every drive, one entry per
title folder,
so the UI can show what takes space and delete it, including folders of
games that are no longer installed. */
static char *
@@ -2598,8 +2842,13 @@ build_storage_json(void) {
long long sum = 0;
int first = 1;
char drives[MAX_DRIVES][32], root[64];
int nd = list_drives(drives, MAX_DRIVES);
jbuf_append(&j, "{\"items\":[");
if ((d = opendir(PATCHDL_DL_DIR))) {
for (int k = 0; k < nd; k++) {
snprintf(root, sizeof root, "%s/patchdl", drives[k]);
if (!(d = opendir(root))) continue;
while ((de = readdir(d))) {
char dir[288], path[560], name[128] = "", inst[16] = "";
char ver[16] = "", buf[2048], pkgname[160] = "";
@@ -2613,7 +2862,7 @@ build_storage_json(void) {
dl_job_t *job;
if (!is_title_dir_name(de->d_name)) continue;
snprintf(dir, sizeof dir, "%s/%s", PATCHDL_DL_DIR, de->d_name);
snprintf(dir, sizeof dir, "%s/%s", root, de->d_name);
if (stat(dir, &st) || !S_ISDIR(st.st_mode)) continue;
if ((sd = opendir(dir))) {
while ((fe = readdir(sd))) {
@@ -2692,6 +2941,7 @@ build_storage_json(void) {
jbuf_append(&j, ",\"version\":"); jbuf_append_ver_or_null(&j, ver);
jbuf_append(&j, ",\"installed_version\":");
jbuf_append_ver_or_null(&j, inst);
jbuf_append(&j, ",\"drive\":"); jbuf_append_str(&j, drives[k]);
jbuf_append(&j, "}");
}
closedir(d);
@@ -2700,6 +2950,33 @@ build_storage_json(void) {
return j.buf;
}
/* GET /api/drives: the drives packages can go to, with free and total
space, and which one new downloads use. */
static char *
build_drives_json(void) {
char drives[MAX_DRIVES][32], root[64];
int nd = list_drives(drives, MAX_DRIVES);
jbuf_t j = {0};
current_root(root, sizeof root);
jbuf_append(&j, "{\"current\":");
jbuf_append_str(&j, root);
jbuf_append(&j, ",\"drives\":[");
for (int i = 0; i < nd; i++) {
struct statvfs vfs;
unsigned long long fr = 0, tot = 0;
if (statvfs(drives[i], &vfs) == 0) {
fr = (unsigned long long)vfs.f_bavail * vfs.f_frsize / (1024ULL * 1024ULL);
tot = (unsigned long long)vfs.f_blocks * vfs.f_frsize / (1024ULL * 1024ULL);
}
jbuf_append(&j, i ? ",{\"path\":" : "{\"path\":");
jbuf_append_str(&j, drives[i]);
jbuf_appendf(&j, ",\"free_mb\":%llu,\"total_mb\":%llu}", fr, tot);
}
jbuf_append(&j, "]}");
return j.buf;
}
/* Host part of a URL into out ("" if none). */
static void
url_host_of(const char *url, char *out, size_t sz) {
@@ -2865,7 +3142,7 @@ static enum MHD_Result
do_install(struct MHD_Connection *conn, const char *title_id,
patchdl_source_t src, const char *patch_url, const char *delta_url,
const char *patch_title_id, const char *patch_storage_title_id,
const char *content_id, const char *version, int enabled) {
const char *content_id, const char *version, int enabled, int force) {
char dest[320], msg[256], resp[720];
int rc;
install_stream_t *st = NULL;
@@ -2898,6 +3175,32 @@ do_install(struct MHD_Connection *conn, const char *title_id,
: "{\"ok\":false,\"reason\":\"download_incomplete\"}");
}
/* The console writes the update next to the game and needs about the
package's size free there; with less, its installer reads the header
and quietly stops. Ask before starting (force = the user said go). */
if (!force) {
char gdir[256] = "";
struct stat pst;
pthread_mutex_lock(&g_mutex);
for (size_t i = 0; i < g_title_count; i++)
if (!strcmp(g_titles[i].title_id, title_id)) {
snprintf(gdir, sizeof gdir, "%s", g_titles[i].source_path);
break;
}
pthread_mutex_unlock(&g_mutex);
title_pkg_path(title_id, patch_url, dest, sizeof(dest));
if (gdir[0] && stat(dest, &pst) == 0) {
long long need = (long long)pst.st_size + INSTALL_SPACE_MARGIN;
long long free_b = game_free_bytes(gdir);
if (free_b >= 0 && free_b < need) {
snprintf(resp, sizeof(resp),
"{\"ok\":false,\"reason\":\"install_space\","
"\"need_bytes\":%lld,\"free_bytes\":%lld}", need, free_b);
return queue_json_owned(conn, MHD_HTTP_CONFLICT, strdup(resp));
}
}
}
/* GUARD (app layer): the version.xml target title id must match the game. */
if (patch_title_id[0] && strncmp(patch_title_id, title_id, 9) != 0) {
snprintf(resp, sizeof(resp),
@@ -3150,7 +3453,9 @@ handle_download_url(struct MHD_Connection *conn, const char *body) {
patchdl_manifest_free(&mf);
{
long long free_b;
if (!space_ok(found, &free_b)) {
char dir[160];
title_dir(tid, dir, sizeof dir);
if (!space_ok(dir, found, &free_b)) {
snprintf(resp, sizeof resp,
"{\"ok\":false,\"reason\":\"no_space\","
"\"need_bytes\":%lld,\"free_bytes\":%lld}", found, free_b);
@@ -3255,10 +3560,12 @@ handle_title_action(struct MHD_Connection *conn, const char *url) {
return queue_json(conn, MHD_HTTP_ACCEPTED,
"{\"ok\":true,\"queued\":true,\"action\":\"check\"}");
if (!strcmp(action, "install"))
if (!strcmp(action, "install")) {
const char *f = MHD_lookup_connection_value(conn, MHD_GET_ARGUMENT_KIND, "force");
return do_install(conn, title_id, src, patch_url, delta_url,
patch_title_id, patch_storage_title_id,
content_id, version, enabled);
content_id, version, enabled, f && !strcmp(f, "1"));
}
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
}
@@ -3323,6 +3630,16 @@ handle_config_post(struct MHD_Connection *conn, const char *body) {
json_get_int(body, "max_connections", g_cfg.max_connections);
if (g_cfg.max_connections < 1) g_cfg.max_connections = 1;
if (g_cfg.max_connections > POOL_MAX_CONN) g_cfg.max_connections = POOL_MAX_CONN;
{
/* only a drive that is mounted right now can be picked */
char drive[32] = "";
json_get_str(body, "download_drive", drive, sizeof drive);
if (drive_name_ok(drive) && drive_mounted(drive)) {
pthread_mutex_lock(&g_drive_mtx);
snprintf(g_cfg.download_drive, sizeof g_cfg.download_drive, "%s", drive);
pthread_mutex_unlock(&g_drive_mtx);
}
}
mc = g_cfg.max_connections;
pthread_mutex_unlock(&g_mutex);
@@ -3414,6 +3731,21 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
tid[tl] = '\0';
if (!is_title_dir_name(tid))
return queue_text(conn, MHD_HTTP_FORBIDDEN, "forbidden");
{
/* The same title can have a folder on two drives (one
downloaded while the other drive was unplugged). Delete
the one the list showed, not just the first one found. */
char drive[32] = "", dir[160], cur[160];
json_get_str(body, "drive", drive, sizeof drive);
if (drive[0] && drive_name_ok(drive)) {
snprintf(dir, sizeof dir, "%s/patchdl/%s", drive, tid);
title_dir(tid, cur, sizeof cur);
if (strcmp(dir, cur)) { /* not the folder downloads use */
remove_dir_files(dir);
return queue_json(conn, MHD_HTTP_OK, "{\"ok\":true,\"cancelled\":true}");
}
}
}
return do_cancel(conn, tid);
}
if (!strcmp(url, "/api/install_uri")) {
@@ -3459,7 +3791,7 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
if (!local_install_path_safe(path))
return queue_json(conn, MHD_HTTP_FORBIDDEN,
"{\"ok\":false,\"reason\":\"path_not_allowed\","
"\"hint\":\"must be " PATCHDL_DL_DIR
"\"hint\":\"must be <drive>/patchdl"
"/<title_id>/<filename>\"}");
if (tid[0] && !path_segment_safe(tid))
return queue_json(conn, MHD_HTTP_BAD_REQUEST,
@@ -3731,6 +4063,8 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
return queue_json_owned(conn, MHD_HTTP_OK, strdup(resp));
}
if (!strcmp(url, "/api/drives"))
return queue_json_owned(conn, MHD_HTTP_OK, build_drives_json());
if (!strcmp(url, "/api/versions")) {
const char *want = MHD_lookup_connection_value(conn, MHD_GET_ARGUMENT_KIND, "title");
return queue_versions(conn, want ? want : "");
+59 -13
View File
@@ -340,7 +340,7 @@ async function openDiag(titleId) {
diagRow(Boolean(mf && mf.ok), t("diag.manifest"), mfDetail),
];
if (job) rows.push(diagRow(job.state !== "error", t("diag.lastDownload"),
job.state === "error" ? (Number(job.rc) === -3 ? noSpaceText(job.need_bytes, job.free_bytes)
job.state === "error" ? (Number(job.rc) === -3 ? noSpaceText(job.need_bytes, job.free_bytes, job.space_install)
: job.fail_step ? failStepText(job) : t("diag.failedPieces"))
: tCode("diag.jobState.", job.state, job.state)));
@@ -439,7 +439,7 @@ function bindElements() {
storageTotal: document.getElementById("storageTotal"),
searchInput: document.getElementById("searchInput"),
defaultPolicy: document.getElementById("defaultPolicy"),
downloadDir: document.getElementById("downloadDir"),
downloadDrive: document.getElementById("downloadDrive"),
installAfterDownload: document.getElementById("installAfterDownload"),
deleteAfterInstall: document.getElementById("deleteAfterInstall"),
verifyDownloads: document.getElementById("verifyDownloads"),
@@ -522,7 +522,7 @@ function setView(view) {
else b.removeAttribute("aria-current");
});
document.querySelectorAll(".view").forEach((s) => s.classList.toggle("is-active", s.dataset.view === view));
if (view === "settings") loadStorage();
if (view === "settings") { loadStorage(); loadDrives(); }
}
async function loadInitialData({ quiet = false } = {}) {
@@ -653,7 +653,7 @@ function setConnections(n) {
function renderSettings() {
els.defaultPolicy.value = state.config.default_policy || "deny";
els.downloadDir.value = state.config.download_dir || "";
renderDrives();
els.installAfterDownload.checked = Boolean(state.config.install_after_download);
els.deleteAfterInstall.checked = Boolean(state.config.delete_pkg_after_install);
if (els.verifyDownloads) els.verifyDownloads.checked = Boolean(state.config.verify_downloads);
@@ -922,7 +922,7 @@ function cardNotesHtml(game) {
const notes = [];
const job = state.downloads.find((d) => d.title_id === game.title_id);
if (job && job.state === "error" && Number(job.rc) === -3)
notes.push(`<div class="callout is-warn">${icon("alert")}<span>${escapeHtml(noSpaceText(job.need_bytes, job.free_bytes))}</span></div>`);
notes.push(`<div class="callout is-warn">${icon("alert")}<span>${escapeHtml(noSpaceText(job.need_bytes, job.free_bytes, job.space_install))}</span></div>`);
else if (job && job.state === "error")
notes.push(`<div class="callout is-warn">${icon("alert")}<span>${escapeHtml(t("card.downloadFailed"))}${job.fail_step ? " " + escapeHtml(failStepText(job)) : ""}</span>
<button type="button" class="link-button" data-diag="${escapeHtml(game.title_id)}">${t("card.details")}</button></div>`);
@@ -1079,7 +1079,7 @@ function reconcileFromJobs(jobs) {
// red Cancel stays available to delete the kept partial.
const bytes = Number(j.bytes) || 0;
if (Number(j.rc) === -3) {
if (!g._spaceWarned) showToast(t("toast.nameMessage", { name: g.name, message: noSpaceText(j.need_bytes, j.free_bytes) }));
if (!g._spaceWarned) showToast(t("toast.nameMessage", { name: g.name, message: noSpaceText(j.need_bytes, j.free_bytes, j.space_install) }));
g._spaceWarned = true;
} else if (g._wasActive) {
const why = j.fail_step ? ` ${failStepText(j)}` : "";
@@ -1337,6 +1337,37 @@ const STORAGE_STATE = {
// What PatchDL keeps under /data/patchdl, one row per title folder, with a
// delete button that also works for games that are no longer installed.
/* ---------------- download drive ---------------- */
let drives = null; // from /api/drives: [{path, free_mb, total_mb}]
function driveLabel(path) {
return path === "/data" ? t("settings.driveInternal")
: t("settings.driveNamed", { name: path.replace(/^\/mnt\//, "") });
}
function renderDrives() {
const sel = els.downloadDrive;
if (!sel) return;
const current = state.config.download_drive || "/data";
const list = drives || [{ path: current }];
sel.replaceChildren(...list.map((d) => {
const opt = document.createElement("option");
opt.value = d.path;
opt.textContent = d.free_mb != null
? `${driveLabel(d.path)} · ${t("rail.free", { space: formatMb(d.free_mb) })}`
: driveLabel(d.path);
opt.selected = d.path === current;
return opt;
}));
}
async function loadDrives() {
const data = await fetchJson("/api/drives");
if (data && Array.isArray(data.drives)) drives = data.drives;
renderDrives();
}
async function loadStorage() {
if (!els.storageList) return;
let data;
@@ -1362,7 +1393,7 @@ async function loadStorage() {
row.className = "storage-row";
row.innerHTML = `
<span><strong>${escapeHtml(item.name || item.title_id)}</strong><br>
<em>${escapeHtml(item.title_id)}${item.version ? ` · ${escapeHtml(item.version)}` : ""} · ${escapeHtml(storageRelation(item))}</em></span>
<em>${escapeHtml(item.title_id)}${item.version ? ` · ${escapeHtml(item.version)}` : ""} · ${escapeHtml(storageRelation(item))}${item.drive && item.drive !== "/data" ? ` · ${escapeHtml(driveLabel(item.drive))}` : ""}</em></span>
<span class="pill ${info.className}">${t(`storage.state.${stateKey}`)}</span>
<b>${formatBytes(item.bytes)}</b>
`;
@@ -1390,7 +1421,7 @@ async function deleteStorage(item) {
const label = item.name || item.title_id;
if (!window.confirm(t("storage.confirmDelete", { name: label, size: formatBytes(item.bytes) }))) return;
try {
await postJson(API.storageDelete(item.title_id), {});
await postJson(API.storageDelete(item.title_id), { drive: item.drive || "" });
showToast(t("toast.storageDeleted", { name: label }));
} catch (error) {
showToast(t("toast.nameMessage", { name: label, message: reasonText(error) }));
@@ -1435,7 +1466,7 @@ async function saveConfig() {
const config = {
...state.config,
default_policy: els.defaultPolicy.value,
download_dir: els.downloadDir.value.trim(),
download_drive: els.downloadDrive.value || state.config.download_drive || "/data",
install_after_download: els.installAfterDownload.checked,
delete_pkg_after_install: els.deleteAfterInstall.checked,
verify_downloads: els.verifyDownloads ? els.verifyDownloads.checked : Boolean(state.config.verify_downloads),
@@ -1516,7 +1547,7 @@ async function doDownload(game) {
}
}
async function doInstall(game) {
async function doInstall(game, force = false) {
const wasDownloaded = game.downloaded;
game.installing = true;
game.installStatus = "starting";
@@ -1524,9 +1555,22 @@ async function doInstall(game) {
game.downloaded = false; // the package is being consumed by the install
renderGames();
try {
await postJson(API.action(game.title_id, "install"), {});
await postJson(API.action(game.title_id, "install") + (force ? "?force=1" : ""), {});
} catch (error) {
game.downloaded = wasDownloaded; // refused: the package is untouched
const b = (error && error.body) || {};
if (b.reason === "install_space" && !force) {
// Little room on the game's drive: the console would likely stop
// right after reading the header. Let the user decide.
game.installing = false;
renderGames();
if (window.confirm(t("install.lowSpace", {
name: game.name, need: formatBytes(b.need_bytes), free: formatBytes(b.free_bytes) })))
return doInstall(game, true);
addLog("log.installBlocked", { id: game.title_id, why: t("reason.install_space") });
renderLogs();
return false;
}
if (error.body && error.body.reason === "install_running") {
// Another tab or device started it: follow that install here.
startInstallPolling();
@@ -1650,8 +1694,10 @@ async function postJson(url, body) {
// Server refusal reasons are translated as reason.<code> in i18n.js.
// The server keeps 2 GB free on /data on top of the package itself.
function noSpaceText(need, free) {
return t("reason.noSpace", { need: formatBytes(need), free: formatBytes(free) });
// install: the download fit, but installing it afterwards wouldn't.
function noSpaceText(need, free, install) {
return t(install ? "reason.noSpaceInstall" : "reason.noSpace",
{ need: formatBytes(need), free: formatBytes(free) });
}
function reasonText(error) {
+60 -10
View File
@@ -103,6 +103,8 @@ const I18N = {
"progress.queued": "Queued — waiting for a free slot",
"progress.verifying": "Checking the package already on disk",
"progress.retrying": "Connection interrupted, trying again…",
"install.lowSpace": "{name}: the console probably needs about {need} free on the game's drive to install this update, but only {free} is free there. The install may stop right at the start. Install anyway?",
"reason.install_space": "Not enough free space to install",
"progress.eta": "ETA <b>{eta}</b>",
"progress.fetchingManifest": "Fetching manifest…",
"install.phase.waiting": "Waiting for the console",
@@ -182,7 +184,9 @@ const I18N = {
"settings.language": "Language",
"settings.languageAuto": "Automatic",
"settings.downloadFolder": "Download Folder",
"settings.downloadFolderTitle": "Patches always download to the console's internal storage",
"settings.downloadFolderHint": "New downloads go here. Packages that are already downloaded stay where they are.",
"settings.driveInternal": "Internal storage",
"settings.driveNamed": "Drive {name}",
"settings.installAfter": "Install after download",
"settings.installAfterHint": "Global default, overridable per game",
"settings.deleteAfter": "Delete PKG after install",
@@ -322,6 +326,7 @@ const I18N = {
"reason.fallback": "failed",
"reason.notOrbisLink": "That isn't a link from Orbis Patches. Press “Copy all links!” there and paste what it copies.",
"reason.noSpace": "Not enough space on the PS5: needs {need} plus 2 GB headroom, {free} free.",
"reason.noSpaceInstall": "Not enough space to download and install this update: it needs about {need}, {free} is free. Free up space, or pick another download drive in Settings.",
// ---- older patch guide ----
"guide.eyebrowOther": "Other version",
@@ -451,6 +456,8 @@ I18N["de"] = {
"progress.queued": "In Warteschlange — wartet auf einen freien Platz",
"progress.verifying": "Vorhandenes Paket auf der Konsole wird geprüft",
"progress.retrying": "Verbindung unterbrochen, neuer Versuch…",
"install.lowSpace": "{name}: Zum Installieren braucht die Konsole auf dem Laufwerk des Spiels wahrscheinlich etwa {need} frei, dort sind aber nur {free} frei. Die Installation könnte gleich am Anfang abbrechen. Trotzdem installieren?",
"reason.install_space": "Nicht genug Platz zum Installieren",
"progress.eta": "Restzeit <b>{eta}</b>",
"progress.fetchingManifest": "Paketliste wird geladen…",
"install.phase.waiting": "Warten auf die Konsole",
@@ -522,7 +529,9 @@ I18N["de"] = {
"settings.language": "Sprache",
"settings.languageAuto": "Automatisch",
"settings.downloadFolder": "Downloadordner",
"settings.downloadFolderTitle": "Patches werden immer in den internen Speicher der Konsole heruntergeladen",
"settings.downloadFolderHint": "Neue Downloads landen hier. Bereits heruntergeladene Pakete bleiben, wo sie sind.",
"settings.driveInternal": "Interner Speicher",
"settings.driveNamed": "Laufwerk {name}",
"settings.installAfter": "Nach dem Download installieren",
"settings.installAfterHint": "Globale Vorgabe, pro Spiel änderbar",
"settings.deleteAfter": "PKG nach der Installation löschen",
@@ -649,6 +658,7 @@ I18N["de"] = {
"reason.source_not_supported": "Downloads werden für diese Quelle nicht unterstützt.",
"reason.fallback": "fehlgeschlagen",
"reason.noSpace": "Nicht genug Speicher auf der PS5: benötigt {need} plus 2 GB Reserve, {free} frei.",
"reason.noSpaceInstall": "Nicht genug Platz, um dieses Update herunterzuladen und zu installieren: Es braucht etwa {need}, frei sind {free}. Schaffe Platz oder wähle in den Einstellungen ein anderes Download-Laufwerk.",
"guide.eyebrowOther": "Andere Version",
"guide.eyebrowOld": "Patch für alte FW",
"guide.title": "{version} herunterladen",
@@ -772,6 +782,8 @@ I18N["fr"] = {
"progress.queued": "En file d'attente — en attente d'un emplacement libre",
"progress.verifying": "Vérification du paquet déjà présent sur la console",
"progress.retrying": "Connexion interrompue, nouvel essai…",
"install.lowSpace": "{name} : pour installer cette mise à jour, la console a probablement besoin d'environ {need} libres sur le disque du jeu, mais il n'y a que {free} de libre. L'installation risque de s'arrêter dès le début. Installer quand même ?",
"reason.install_space": "Pas assez d'espace libre pour installer",
"progress.eta": "Temps restant <b>{eta}</b>",
"progress.fetchingManifest": "Récupération de la liste des paquets…",
"install.phase.waiting": "En attente de la console",
@@ -843,7 +855,9 @@ I18N["fr"] = {
"settings.language": "Langue",
"settings.languageAuto": "Automatique",
"settings.downloadFolder": "Dossier de téléchargement",
"settings.downloadFolderTitle": "Les patchs sont toujours téléchargés sur le stockage interne de la console",
"settings.downloadFolderHint": "Les nouveaux téléchargements sont enregistrés ici. Les paquets déjà téléchargés restent où ils sont.",
"settings.driveInternal": "Stockage interne",
"settings.driveNamed": "Disque {name}",
"settings.installAfter": "Installer après le téléchargement",
"settings.installAfterHint": "Valeur globale, modifiable par jeu",
"settings.deleteAfter": "Supprimer le PKG après l'installation",
@@ -970,6 +984,7 @@ I18N["fr"] = {
"reason.source_not_supported": "Les téléchargements ne sont pas pris en charge pour cette source.",
"reason.fallback": "échec",
"reason.noSpace": "Pas assez d'espace sur la PS5 : il faut {need} plus 2 Go de marge, {free} libres.",
"reason.noSpaceInstall": "Pas assez d'espace pour télécharger et installer cette mise à jour : il faut environ {need}, {free} sont libres. Libérez de l'espace ou choisissez un autre disque de téléchargement dans les paramètres.",
"guide.eyebrowOther": "Autre version",
"guide.eyebrowOld": "Patch pour ancien FW",
"guide.title": "Télécharger {version}",
@@ -1093,6 +1108,8 @@ I18N["es"] = {
"progress.queued": "En cola — esperando un hueco libre",
"progress.verifying": "Comprobando el paquete que ya está en la consola",
"progress.retrying": "Conexión interrumpida, reintentando…",
"install.lowSpace": "{name}: para instalar esta actualización, la consola probablemente necesita unos {need} libres en la unidad del juego, pero solo hay {free} libres. La instalación podría detenerse al principio. ¿Instalar de todos modos?",
"reason.install_space": "No hay espacio suficiente para instalar",
"progress.eta": "Tiempo restante <b>{eta}</b>",
"progress.fetchingManifest": "Obteniendo la lista de paquetes…",
"install.phase.waiting": "Esperando a la consola",
@@ -1164,7 +1181,9 @@ I18N["es"] = {
"settings.language": "Idioma",
"settings.languageAuto": "Automático",
"settings.downloadFolder": "Carpeta de descargas",
"settings.downloadFolderTitle": "Los parches siempre se descargan en el almacenamiento interno de la consola",
"settings.downloadFolderHint": "Las nuevas descargas se guardan aquí. Los paquetes ya descargados se quedan donde están.",
"settings.driveInternal": "Almacenamiento interno",
"settings.driveNamed": "Unidad {name}",
"settings.installAfter": "Instalar tras la descarga",
"settings.installAfterHint": "Valor global, se puede cambiar por juego",
"settings.deleteAfter": "Eliminar el PKG tras instalar",
@@ -1291,6 +1310,7 @@ I18N["es"] = {
"reason.source_not_supported": "No se admiten descargas para esta fuente.",
"reason.fallback": "error",
"reason.noSpace": "No hay suficiente espacio en la PS5: se necesitan {need} más 2 GB de margen, {free} libres.",
"reason.noSpaceInstall": "No hay espacio suficiente para descargar e instalar esta actualización: necesita unos {need} y hay {free} libres. Libera espacio o elige otra unidad de descarga en Ajustes.",
"guide.eyebrowOther": "Otra versión",
"guide.eyebrowOld": "Parche para FW antiguo",
"guide.title": "Descargar {version}",
@@ -1414,6 +1434,8 @@ I18N["it"] = {
"progress.queued": "In coda — in attesa di uno slot libero",
"progress.verifying": "Verifica del pacchetto già presente sul disco",
"progress.retrying": "Connessione interrotta, nuovo tentativo…",
"install.lowSpace": "{name}: per installare questo aggiornamento la console probabilmente ha bisogno di circa {need} liberi sull'unità del gioco, ma ce ne sono solo {free}. L'installazione potrebbe fermarsi subito. Installare comunque?",
"reason.install_space": "Spazio insufficiente per l'installazione",
"progress.eta": "Fine tra <b>{eta}</b>",
"progress.fetchingManifest": "Recupero elenco pacchetti…",
"install.phase.waiting": "In attesa della console",
@@ -1485,7 +1507,9 @@ I18N["it"] = {
"settings.language": "Lingua",
"settings.languageAuto": "Automatica",
"settings.downloadFolder": "Cartella download",
"settings.downloadFolderTitle": "Le patch vengono sempre scaricate nella memoria interna della console",
"settings.downloadFolderHint": "I nuovi download vengono salvati qui. I pacchetti già scaricati restano dove sono.",
"settings.driveInternal": "Memoria interna",
"settings.driveNamed": "Unità {name}",
"settings.installAfter": "Installa dopo il download",
"settings.installAfterHint": "Predefinito globale, modificabile per singolo gioco",
"settings.deleteAfter": "Elimina PKG dopo l'installazione",
@@ -1612,6 +1636,7 @@ I18N["it"] = {
"reason.source_not_supported": "I download non sono supportati per questa origine.",
"reason.fallback": "non riuscito",
"reason.noSpace": "Spazio insufficiente sulla PS5: servono {need} più 2 GB di margine, {free} liberi.",
"reason.noSpaceInstall": "Spazio insufficiente per scaricare e installare questo aggiornamento: servono circa {need}, ne sono liberi {free}. Libera spazio o scegli un'altra unità di download nelle Impostazioni.",
"guide.eyebrowOther": "Altra versione",
"guide.eyebrowOld": "Patch per FW precedente",
"guide.title": "Scarica {version}",
@@ -1735,6 +1760,8 @@ I18N["pt-BR"] = {
"progress.queued": "Na fila — aguardando uma vaga livre",
"progress.verifying": "Verificando o pacote que já está no disco",
"progress.retrying": "Conexão interrompida, tentando de novo…",
"install.lowSpace": "{name}: para instalar esta atualização, o console provavelmente precisa de cerca de {need} livres na unidade do jogo, mas só há {free} livres. A instalação pode parar logo no início. Instalar mesmo assim?",
"reason.install_space": "Espaço insuficiente para instalar",
"progress.eta": "Faltam <b>{eta}</b>",
"progress.fetchingManifest": "Obtendo lista de pacotes…",
"install.phase.waiting": "Aguardando o console",
@@ -1806,7 +1833,9 @@ I18N["pt-BR"] = {
"settings.language": "Idioma",
"settings.languageAuto": "Automático",
"settings.downloadFolder": "Pasta de downloads",
"settings.downloadFolderTitle": "Os patches sempre são baixados para o armazenamento interno do console",
"settings.downloadFolderHint": "Os novos downloads ficam aqui. Pacotes já baixados continuam onde estão.",
"settings.driveInternal": "Armazenamento interno",
"settings.driveNamed": "Unidade {name}",
"settings.installAfter": "Instalar após o download",
"settings.installAfterHint": "Padrão global, ajustável por jogo",
"settings.deleteAfter": "Excluir PKG após instalar",
@@ -1933,6 +1962,7 @@ I18N["pt-BR"] = {
"reason.source_not_supported": "Downloads não são suportados para esta origem.",
"reason.fallback": "falhou",
"reason.noSpace": "Espaço insuficiente no PS5: são necessários {need} mais 2 GB de folga, {free} livres.",
"reason.noSpaceInstall": "Espaço insuficiente para baixar e instalar esta atualização: ela precisa de cerca de {need} e há {free} livres. Libere espaço ou escolha outra unidade de download em Configurações.",
"guide.eyebrowOther": "Outra versão",
"guide.eyebrowOld": "Patch de FW antigo",
"guide.title": "Baixar {version}",
@@ -2056,6 +2086,8 @@ I18N["ja"] = {
"progress.queued": "待機中 — 空きスロットを待っています",
"progress.verifying": "ディスク上の既存パッケージを確認しています",
"progress.retrying": "接続が中断されました。再試行しています…",
"install.lowSpace": "{name}: このアップデートのインストールには、ゲームのドライブに約 {need} の空き容量が必要と思われますが、空きは {free} しかありません。インストールが最初に止まる可能性があります。それでもインストールしますか?",
"reason.install_space": "インストールに必要な空き容量が足りません",
"progress.eta": "残り <b>{eta}</b>",
"progress.fetchingManifest": "パッケージリストを取得中…",
"install.phase.waiting": "本体を待っています",
@@ -2127,7 +2159,9 @@ I18N["ja"] = {
"settings.language": "言語",
"settings.languageAuto": "自動",
"settings.downloadFolder": "ダウンロードフォルダー",
"settings.downloadFolderTitle": "パッチは常に本体ストレージにダウンロードされます",
"settings.downloadFolderHint": "新しいダウンロードはここに保存されます。ダウンロード済みのパッケージは移動しません。",
"settings.driveInternal": "本体ストレージ",
"settings.driveNamed": "ドライブ {name}",
"settings.installAfter": "ダウンロード後にインストール",
"settings.installAfterHint": "全体のデフォルト。ゲームごとに変更できます",
"settings.deleteAfter": "インストール後に PKG を削除",
@@ -2254,6 +2288,7 @@ I18N["ja"] = {
"reason.source_not_supported": "このソースのダウンロードには対応していません。",
"reason.fallback": "失敗",
"reason.noSpace": "PS5 の空き容量が不足しています: {need} と予備の 2 GB が必要ですが、空きは {free} です。",
"reason.noSpaceInstall": "このアップデートをダウンロードしてインストールするには空き容量が足りません。約 {need} 必要ですが、空きは {free} です。空き容量を増やすか、設定で別のダウンロード先ドライブを選んでください。",
"guide.eyebrowOther": "他のバージョン",
"guide.eyebrowOld": "旧 FW 用パッチ",
"guide.title": "{version} をダウンロード",
@@ -2377,6 +2412,8 @@ I18N["zh-Hans"] = {
"progress.queued": "排队中 — 正在等待空闲位置",
"progress.verifying": "正在检查磁盘上已有的安装包",
"progress.retrying": "连接中断,正在重试…",
"install.lowSpace": "{name}:安装此更新时,主机可能需要游戏所在驱动器上约 {need} 的可用空间,但那里只有 {free} 可用。安装可能会在一开始就停止。仍要安装吗?",
"reason.install_space": "可用空间不足,无法安装",
"progress.eta": "剩余 <b>{eta}</b>",
"progress.fetchingManifest": "正在获取包列表…",
"install.phase.waiting": "正在等待主机",
@@ -2448,7 +2485,9 @@ I18N["zh-Hans"] = {
"settings.language": "语言",
"settings.languageAuto": "自动",
"settings.downloadFolder": "下载文件夹",
"settings.downloadFolderTitle": "补丁始终下载到主机的内部存储",
"settings.downloadFolderHint": "新的下载会保存到这里。已下载的软件包保留在原位置。",
"settings.driveInternal": "内部存储",
"settings.driveNamed": "驱动器 {name}",
"settings.installAfter": "下载后安装",
"settings.installAfterHint": "全局默认值,可按游戏单独设置",
"settings.deleteAfter": "安装后删除 PKG",
@@ -2575,6 +2614,7 @@ I18N["zh-Hans"] = {
"reason.source_not_supported": "此来源不支持下载。",
"reason.fallback": "失败",
"reason.noSpace": "PS5 空间不足:需要 {need} 外加 2 GB 余量,当前可用 {free}。",
"reason.noSpaceInstall": "空间不足,无法下载并安装此更新:大约需要 {need},当前可用 {free}。请释放空间,或在设置中选择其他下载驱动器。",
"guide.eyebrowOther": "其他版本",
"guide.eyebrowOld": "旧 FW 补丁",
"guide.title": "下载 {version}",
@@ -2698,6 +2738,8 @@ I18N["ko"] = {
"progress.queued": "대기 중 — 빈 슬롯을 기다리는 중",
"progress.verifying": "디스크에 이미 있는 패키지를 확인하는 중",
"progress.retrying": "연결이 끊겼습니다. 다시 시도하는 중…",
"install.lowSpace": "{name}: 이 업데이트를 설치하려면 게임이 있는 드라이브에 약 {need}의 여유 공간이 필요할 것으로 보이지만, 여유 공간은 {free}뿐입니다. 설치가 시작하자마자 멈출 수 있습니다. 그래도 설치하시겠습니까?",
"reason.install_space": "설치할 여유 공간이 부족합니다",
"progress.eta": "남은 시간 <b>{eta}</b>",
"progress.fetchingManifest": "패키지 목록을 가져오는 중…",
"install.phase.waiting": "본체를 기다리는 중",
@@ -2769,7 +2811,9 @@ I18N["ko"] = {
"settings.language": "언어",
"settings.languageAuto": "자동",
"settings.downloadFolder": "다운로드 폴더",
"settings.downloadFolderTitle": "패치는 항상 본체의 내부 저장 공간에 다운로드됩니다",
"settings.downloadFolderHint": "새 다운로드는 여기에 저장됩니다. 이미 다운로드한 패키지는 그대로 유지됩니다.",
"settings.driveInternal": "내부 저장소",
"settings.driveNamed": "드라이브 {name}",
"settings.installAfter": "다운로드 후 설치",
"settings.installAfterHint": "전체 기본값이며 게임별로 변경할 수 있습니다",
"settings.deleteAfter": "설치 후 PKG 삭제",
@@ -2896,6 +2940,7 @@ I18N["ko"] = {
"reason.source_not_supported": "이 소스는 다운로드가 지원되지 않습니다.",
"reason.fallback": "실패",
"reason.noSpace": "PS5의 공간이 부족합니다: {need}에 여유분 2 GB가 더 필요하지만, 여유 공간은 {free}입니다.",
"reason.noSpaceInstall": "이 업데이트를 다운로드하고 설치할 공간이 부족합니다. 약 {need}이(가) 필요하지만 여유 공간은 {free}입니다. 공간을 확보하거나 설정에서 다른 다운로드 드라이브를 선택하세요.",
"guide.eyebrowOther": "다른 버전",
"guide.eyebrowOld": "이전 FW용 패치",
"guide.title": "{version} 다운로드",
@@ -3019,6 +3064,8 @@ I18N["ru"] = {
"progress.queued": "В очереди — ожидание свободного слота",
"progress.verifying": "Проверка пакета, который уже есть на диске",
"progress.retrying": "Соединение прервано, повторная попытка…",
"install.lowSpace": "{name}: для установки этого обновления консоли, вероятно, нужно около {need} свободного места на диске с игрой, а там свободно только {free}. Установка может остановиться в самом начале. Всё равно установить?",
"reason.install_space": "Недостаточно места для установки",
"progress.eta": "Осталось <b>{eta}</b>",
"progress.fetchingManifest": "Получение списка пакетов…",
"install.phase.waiting": "Ожидание консоли",
@@ -3090,7 +3137,9 @@ I18N["ru"] = {
"settings.language": "Язык",
"settings.languageAuto": "Автоматически",
"settings.downloadFolder": "Папка загрузок",
"settings.downloadFolderTitle": "Патчи всегда скачиваются во внутреннюю память консоли",
"settings.downloadFolderHint": "Новые загрузки сохраняются сюда. Уже загруженные пакеты остаются на месте.",
"settings.driveInternal": "Внутренняя память",
"settings.driveNamed": "Диск {name}",
"settings.installAfter": "Устанавливать после загрузки",
"settings.installAfterHint": "Общая настройка, можно изменить для каждой игры",
"settings.deleteAfter": "Удалять PKG после установки",
@@ -3217,6 +3266,7 @@ I18N["ru"] = {
"reason.source_not_supported": "Для этого источника загрузки не поддерживаются.",
"reason.fallback": "ошибка",
"reason.noSpace": "Недостаточно места на PS5: нужно {need} плюс 2 ГБ запаса, свободно {free}.",
"reason.noSpaceInstall": "Недостаточно места, чтобы загрузить и установить это обновление: нужно около {need}, свободно {free}. Освободите место или выберите другой диск для загрузок в настройках.",
"guide.eyebrowOther": "Другая версия",
"guide.eyebrowOld": "Патч для старой FW",
"guide.title": "Скачать {version}",
+2 -3
View File
@@ -193,9 +193,8 @@
</label>
<label class="field">
<span data-i18n="settings.downloadFolder">Download Folder</span>
<input id="downloadDir" type="text" spellcheck="false" readonly
title="Patches always download to the console's internal storage"
data-i18n-title="settings.downloadFolderTitle" />
<select id="downloadDrive"></select>
<em class="field-hint" data-i18n="settings.downloadFolderHint">New downloads go here. Packages that are already downloaded stay where they are.</em>
</label>
<label class="field">
<span data-i18n="settings.language">Language</span>
+1
View File
@@ -619,6 +619,7 @@ h2 { font-size: 18px; line-height: 1.2; }
.settings-grid { display: grid; gap: 12px; }
.field { display: grid; gap: 6px; }
.field span, .allowlist > span, .source-policy > span { color: var(--muted); font-size: var(--fs-xs); font-weight: 700; }
.field-hint { color: var(--muted); font-size: var(--fs-xs); font-style: normal; }
.field input, .field select {
min-height: 44px;
padding: 0 12px;