mirror of
https://github.com/knutwurst/patchdl.git
synced 2026-10-20 23:01:09 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04b63eb800 | ||
|
|
e853bbb0b4 | ||
|
|
e6e7054e95 | ||
|
|
687966bb8e | ||
|
|
4d44427737 | ||
|
|
cdde608369 | ||
|
|
85be96b02a |
No files matched your search
@@ -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,5 +1,6 @@
|
||||
{
|
||||
"titleId": "PTDL00001",
|
||||
"applicationCategoryType": 65536,
|
||||
"deeplinkUri": "http://127.0.0.1:12880/",
|
||||
"localizedParameters": {
|
||||
"defaultLanguage": "en-US",
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -9,4 +9,4 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#define PATCHDL_VERSION "0.3.1"
|
||||
#define PATCHDL_VERSION "0.3.3"
|
||||
+404
-70
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user