mirror of
https://github.com/knutwurst/patchdl.git
synced 2026-10-06 10:00:22 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec94f0578b | ||
|
|
88368e3df3 | ||
|
|
c80be921c5 | ||
|
|
a371a67521 | ||
|
|
2bc15cbaa2 | ||
|
|
81d6f0e332 | ||
|
|
fcd43b54ae | ||
|
|
d0ca22d42d | ||
|
|
fb47730d70 |
No files matched your search
@@ -23,7 +23,12 @@ SRCS := src/main.c \
|
|||||||
src/patchdl_resolve.c \
|
src/patchdl_resolve.c \
|
||||||
src/patchdl_verxml.c \
|
src/patchdl_verxml.c \
|
||||||
src/patchdl_install.c \
|
src/patchdl_install.c \
|
||||||
src/patchdl_notify.c
|
src/patchdl_notify.c \
|
||||||
|
src/patchdl_tile.c
|
||||||
|
|
||||||
|
# patchdl_tile.c uses .incbin to embed param.json + icon0.png; touching the
|
||||||
|
# assets must trigger a rebuild.
|
||||||
|
TILE_ASSETS := assets/param.json assets/icon0.png
|
||||||
|
|
||||||
WEB_ASSETS := web/index.html web/styles.css web/app.js
|
WEB_ASSETS := web/index.html web/styles.css web/app.js
|
||||||
GEN_SRCS := $(patsubst web/%,gen/web/%.c,$(WEB_ASSETS))
|
GEN_SRCS := $(patsubst web/%,gen/web/%.c,$(WEB_ASSETS))
|
||||||
@@ -56,8 +61,8 @@ gen/web/%.c: web/% scripts/gen_asset_module.py | gen/web
|
|||||||
$(SQLITE_OBJ): $(SQLITE_DIR)/sqlite3.c
|
$(SQLITE_OBJ): $(SQLITE_DIR)/sqlite3.c
|
||||||
$(CC) $(SQLITE_CFLAGS) -c -o $@ $<
|
$(CC) $(SQLITE_CFLAGS) -c -o $@ $<
|
||||||
|
|
||||||
$(BIN): $(SRCS) $(GEN_SRCS) $(SQLITE_OBJ)
|
$(BIN): $(SRCS) $(GEN_SRCS) $(SQLITE_OBJ) $(TILE_ASSETS)
|
||||||
$(CC) $(CFLAGS) -o $@ $^ $(LDADD)
|
$(CC) $(CFLAGS) -o $@ $(SRCS) $(GEN_SRCS) $(SQLITE_OBJ) $(LDADD)
|
||||||
|
|
||||||
test: $(BIN)
|
test: $(BIN)
|
||||||
$(PS5_DEPLOY) -h $(PS5_HOST) -p $(PS5_PORT) $^
|
$(PS5_DEPLOY) -h $(PS5_HOST) -p $(PS5_PORT) $^
|
||||||
|
|||||||
@@ -45,6 +45,22 @@ Downloads survive interruptions:
|
|||||||
Patches download to `/data/patchdl` on the internal SSD. Large retail updates
|
Patches download to `/data/patchdl` on the internal SSD. Large retail updates
|
||||||
run tens of GB.
|
run tens of GB.
|
||||||
|
|
||||||
|
## Web UI
|
||||||
|
|
||||||
|
The Games view groups titles into filter chips:
|
||||||
|
|
||||||
|
- **Updatable** — has an installable update; selected by default.
|
||||||
|
- **Updating** — queued, actively downloading, paused with a partial on disk,
|
||||||
|
or installing.
|
||||||
|
- **Up to date**, **Needs FW**, **Can't update** — the rest.
|
||||||
|
- **All** — flat list at the right end of the strip.
|
||||||
|
|
||||||
|
**Update all** in the top bar queues a download for every game with an
|
||||||
|
installable update in one click. Shadowmounts are skipped (they pass the
|
||||||
|
download policy but not the install policy), so a sweep doesn't burn tens of
|
||||||
|
GB on bytes AppInstUtil would refuse — pick those up by hand when the disc is
|
||||||
|
ready.
|
||||||
|
|
||||||
## Safety model
|
## Safety model
|
||||||
|
|
||||||
Deny-by-default. A patch installs only for a genuine install, and only when the
|
Deny-by-default. A patch installs only for a genuine install, and only when the
|
||||||
@@ -88,8 +104,9 @@ scripts/build_ps5.sh # produces patchdl-ps5.elf
|
|||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
This console uses the BD-JB autoloader with itsPLK's Payload Manager on port 8084
|
This console uses the BD-JB autoloader with itsPLK's Payload Manager on port 8084
|
||||||
(not a 9021 elfldr). `scripts/deploy_ps5.sh` uploads the version-named ELF and
|
(not a 9021 elfldr). `scripts/deploy_ps5.sh` uploads the version-named ELF
|
||||||
launches it; the payload replaces any running instance.
|
(`patchdl_<version>.elf`, so Payload Manager picks the version out of the
|
||||||
|
filename) and launches it; the payload replaces any running instance.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
PS5_HOST=<console-ip> scripts/deploy_ps5.sh
|
PS5_HOST=<console-ip> scripts/deploy_ps5.sh
|
||||||
@@ -105,23 +122,19 @@ http://<console-ip>:12880/
|
|||||||
|
|
||||||
Verified on firmware 11.60: title scan, source classification, version
|
Verified on firmware 11.60: title scan, source classification, version
|
||||||
resolution past the nanoDNS block, firmware-compatibility filtering, the parallel
|
resolution past the nanoDNS block, firmware-compatibility filtering, the parallel
|
||||||
download pool, reboot-safe resume, and on-device SHA-256 verification. A full
|
download pool, reboot-safe resume, and on-device SHA-256 verification.
|
||||||
Dead Island 2 update (61.6 GB) downloaded and verified byte-perfect across
|
|
||||||
several reboots.
|
|
||||||
|
|
||||||
Install works for same-region patches, where Sony stores the patch bytes under
|
Install works through Sony's AppInstUtil. `sceAppInstUtilInstallByPackage` is
|
||||||
the installed game's own title id. PatchDL now mirrors etaHEN's native
|
unavailable from the homebrew payload context (rejected with `0x80B21163`
|
||||||
DirectPKGInstaller call shape for that path: it passes an empty
|
outside the system process), so PatchDL routes through
|
||||||
`MetaInfo.content_id`, lets AppInstUtil bind the signed package metadata, keeps
|
`sceAppInstUtilAppInstallPkg` — the simpler API that reads the PKG's embedded
|
||||||
the returned content id, and exposes `/api/installstatus` for installer progress
|
`content_id` and binds the install to the right title slot. Verified
|
||||||
when `sceAppInstUtilGetInstallStatus` is exported.
|
end-to-end: Dead Island 2 (`PPSA03099`) updated from 01.000.001 to 01.000.011
|
||||||
|
on 11.60, including the cross-region shared-storage case where Sony serves the
|
||||||
|
patch under a master title id.
|
||||||
|
|
||||||
Cross-region patches are a known limitation. Sony sometimes packages a regional
|
`/api/installstatus` reports installer progress once
|
||||||
patch under a different (master) storage title and ships it as a debug-magic
|
`sceAppInstUtilGetInstallStatus` finishes the queued task.
|
||||||
container. PatchDL downloads such a patch and verifies it against Sony's hashes,
|
|
||||||
but refuses to install it from the standalone ELF because `InstallByPackage`
|
Disc games still need the disc inserted for their patch to apply, which is a
|
||||||
does not retarget signed package metadata on 11.60, and the homebrew alternative
|
normal Sony requirement; that's why shadowmounts are download-only by policy.
|
||||||
(BGFT register) returns "not supported" outside the system process. Installing
|
|
||||||
that class of patch needs Sony's authenticated updater, which nanoDNS blocks.
|
|
||||||
Disc games need the disc inserted for their patch to apply, which is a normal
|
|
||||||
Sony requirement.
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,20 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#0f2a1e"/>
|
||||||
|
<stop offset="1" stop-color="#0a1612"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="badge" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#21d07a"/>
|
||||||
|
<stop offset="1" stop-color="#179a59"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="72" fill="url(#bg)"/>
|
||||||
|
<rect x="56" y="56" width="160" height="160" rx="36" fill="url(#badge)"/>
|
||||||
|
<text x="136" y="178" font-family="Helvetica,Arial,sans-serif" font-size="116"
|
||||||
|
font-weight="800" fill="#0a1612" text-anchor="middle">PD</text>
|
||||||
|
<text x="256" y="346" font-family="Helvetica,Arial,sans-serif" font-size="80"
|
||||||
|
font-weight="700" fill="#e7ecea" text-anchor="middle">PatchDL</text>
|
||||||
|
<text x="256" y="404" font-family="Helvetica,Arial,sans-serif" font-size="32"
|
||||||
|
font-weight="500" fill="#7a8f86" text-anchor="middle">PS5 Patch Tool</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"titleId": "PTDL00001",
|
||||||
|
"deeplinkUri": "http://127.0.0.1:12880/",
|
||||||
|
"localizedParameters": {
|
||||||
|
"defaultLanguage": "en-US",
|
||||||
|
"en-US": {
|
||||||
|
"titleName": "PatchDL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ VERSION=$(sed -n 's/.*PATCHDL_VERSION[^"]*"\([^"]*\)".*/\1/p' "$ROOT_DIR/src/pat
|
|||||||
SRC_ELF="$ROOT_DIR/patchdl-ps5.elf"
|
SRC_ELF="$ROOT_DIR/patchdl-ps5.elf"
|
||||||
[ -f "$SRC_ELF" ] || { echo "build first: $SRC_ELF missing" >&2; exit 1; }
|
[ -f "$SRC_ELF" ] || { echo "build first: $SRC_ELF missing" >&2; exit 1; }
|
||||||
|
|
||||||
UP_NAME="patchdl-ps5-v${VERSION}.elf"
|
UP_NAME="patchdl_${VERSION}.elf"
|
||||||
TMP_ELF="$ROOT_DIR/$UP_NAME"
|
TMP_ELF="$ROOT_DIR/$UP_NAME"
|
||||||
cp "$SRC_ELF" "$TMP_ELF"
|
cp "$SRC_ELF" "$TMP_ELF"
|
||||||
|
|
||||||
|
|||||||
+20
-11
@@ -475,7 +475,10 @@ patchdl_install_status_json(char *out, size_t out_sz) {
|
|||||||
char tid[32];
|
char tid[32];
|
||||||
char method[32];
|
char method[32];
|
||||||
int start_rc;
|
int start_rc;
|
||||||
ai_install_status_t st;
|
/* ai_install_status_t carries a 2 KB safety pad; live on the heap so
|
||||||
|
a frequently-polled /api/installstatus doesn't keep committing pages
|
||||||
|
on every MHD worker's stack. */
|
||||||
|
ai_install_status_t *st = NULL;
|
||||||
char status[17], src_type[9];
|
char status[17], src_type[9];
|
||||||
int rc;
|
int rc;
|
||||||
int progress = 0;
|
int progress = 0;
|
||||||
@@ -514,20 +517,25 @@ patchdl_install_status_json(char *out, size_t out_sz) {
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
st = calloc(1, sizeof(*st));
|
||||||
|
if (!st) {
|
||||||
|
snprintf(out, out_sz, "{\"error\":\"oom\"}");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
/* sceAppInstUtilGetInstallStatus(char *content_id_out, status_t *status):
|
/* sceAppInstUtilGetInstallStatus(char *content_id_out, status_t *status):
|
||||||
first arg is an OUTPUT buffer that receives the current install's content_id.
|
first arg is an OUTPUT buffer that receives the current install's content_id.
|
||||||
Do NOT pass `cid` there — it would be overwritten. The visible id fits in
|
Do NOT pass `cid` there — it would be overwritten. The visible id fits in
|
||||||
0x30 bytes but Sony's NUL-pad length is unknown; use a padded buffer. */
|
0x30 bytes but Sony's NUL-pad length is unknown; use a padded buffer. */
|
||||||
{
|
{
|
||||||
char ai_cid_out[AI_CONTENTID_OUT_SIZE] = {0};
|
char ai_cid_out[AI_CONTENTID_OUT_SIZE] = {0};
|
||||||
memset(&st, 0, sizeof(st));
|
rc = ai_get_status(ai_cid_out, st);
|
||||||
rc = ai_get_status(ai_cid_out, &st);
|
|
||||||
(void)ai_cid_out; /* returned content_id for future use */
|
(void)ai_cid_out; /* returned content_id for future use */
|
||||||
}
|
}
|
||||||
copy_bounded(status, sizeof(status), st.status, sizeof(st.status));
|
copy_bounded(status, sizeof(status), st->status, sizeof(st->status));
|
||||||
copy_bounded(src_type, sizeof(src_type), st.src_type, sizeof(st.src_type));
|
copy_bounded(src_type, sizeof(src_type), st->src_type, sizeof(st->src_type));
|
||||||
if (st.total_size > 0)
|
if (st->total_size > 0)
|
||||||
progress = (int)((st.downloaded_size * 100) / st.total_size);
|
progress = (int)((st->downloaded_size * 100) / st->total_size);
|
||||||
if (progress < 0) progress = 0;
|
if (progress < 0) progress = 0;
|
||||||
if (progress > 100) progress = 100;
|
if (progress > 100) progress = 100;
|
||||||
terminal = (!strcmp(status, "playable") ||
|
terminal = (!strcmp(status, "playable") ||
|
||||||
@@ -542,10 +550,11 @@ patchdl_install_status_json(char *out, size_t out_sz) {
|
|||||||
"\"promote_progress\":%u,\"error_code\":%d}",
|
"\"promote_progress\":%u,\"error_code\":%d}",
|
||||||
terminal ? "true" : "false", cid, tid, method, start_rc, rc,
|
terminal ? "true" : "false", cid, tid, method, start_rc, rc,
|
||||||
status, src_type, progress,
|
status, src_type, progress,
|
||||||
(unsigned long long)st.downloaded_size,
|
(unsigned long long)st->downloaded_size,
|
||||||
(unsigned long long)st.total_size,
|
(unsigned long long)st->total_size,
|
||||||
(unsigned)st.promote_progress,
|
(unsigned)st->promote_progress,
|
||||||
(int)st.error_info.error_code);
|
(int)st->error_info.error_code);
|
||||||
|
free(st);
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-8
@@ -31,9 +31,11 @@
|
|||||||
#define PATCHDL_MAX_TOTAL_BYTES (200ULL * 1024 * 1024 * 1024) /* 200 GiB */
|
#define PATCHDL_MAX_TOTAL_BYTES (200ULL * 1024 * 1024 * 1024) /* 200 GiB */
|
||||||
|
|
||||||
/* In-RAM buffer caps for full HTTP body fetches. version.xml is a few KB,
|
/* In-RAM buffer caps for full HTTP body fetches. version.xml is a few KB,
|
||||||
manifest JSON is a few MB at most — fail-closed beyond that. */
|
manifest JSON is a few MB at most — fail-closed beyond that. The
|
||||||
#define PATCHDL_BUF_MAX_VERXML (16 * 1024 * 1024)
|
manifest cap is sized for ~4096 pieces × ~3 KB JSON each with plenty of
|
||||||
#define PATCHDL_BUF_MAX_MANIFEST (64 * 1024 * 1024)
|
headroom; real PS5 manifests are 1-2 MB. */
|
||||||
|
#define PATCHDL_BUF_MAX_VERXML (4 * 1024 * 1024)
|
||||||
|
#define PATCHDL_BUF_MAX_MANIFEST (16 * 1024 * 1024)
|
||||||
|
|
||||||
#ifdef PATCHDL_HAVE_CURL
|
#ifdef PATCHDL_HAVE_CURL
|
||||||
/* Replacement for fopen("wb"/"r+b") that refuses to follow a symlink at the
|
/* Replacement for fopen("wb"/"r+b") that refuses to follow a symlink at the
|
||||||
@@ -452,8 +454,12 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
|
|||||||
curl_easy_setopt(curl, CURLOPT_SSL_CIPHER_LIST, "DEFAULT@SECLEVEL=0");
|
curl_easy_setopt(curl, CURLOPT_SSL_CIPHER_LIST, "DEFAULT@SECLEVEL=0");
|
||||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);
|
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);
|
||||||
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");
|
/* HTTPS pin removed on the streaming download path: the Sony CDN sometimes
|
||||||
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https");
|
302s a piece URL to a signed http:// edge inside its own infrastructure,
|
||||||
|
and refusing those redirects was breaking real-world downloads.
|
||||||
|
host_allowed + TLS-against-pinned-root on every leg already gate the
|
||||||
|
hosts we'll talk to. NOSIGNAL stays — it protects the worker from a
|
||||||
|
SIGPIPE on connection RST. */
|
||||||
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
|
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
|
||||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 20L);
|
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 20L);
|
||||||
/* No total timeout (patches can be large); abort only on a long stall. */
|
/* No total timeout (patches can be large); abort only on a long stall. */
|
||||||
@@ -822,7 +828,8 @@ int
|
|||||||
patchdl_http_download_piece(const char *url, int fd,
|
patchdl_http_download_piece(const char *url, int fd,
|
||||||
long long file_offset, long long file_size,
|
long long file_offset, long long file_size,
|
||||||
const char *expected_sha256_or_null,
|
const char *expected_sha256_or_null,
|
||||||
patchdl_piece_ctx_t *ctx) {
|
patchdl_piece_ctx_t *ctx,
|
||||||
|
int *curl_rc_out, long *http_code_out) {
|
||||||
CURL *curl;
|
CURL *curl;
|
||||||
CURLcode res;
|
CURLcode res;
|
||||||
long http_code = 0;
|
long http_code = 0;
|
||||||
@@ -832,6 +839,9 @@ patchdl_http_download_piece(const char *url, int fd,
|
|||||||
piece_sink_t sink;
|
piece_sink_t sink;
|
||||||
int verify = (expected_sha256_or_null && expected_sha256_or_null[0]);
|
int verify = (expected_sha256_or_null && expected_sha256_or_null[0]);
|
||||||
|
|
||||||
|
if (curl_rc_out) *curl_rc_out = 0;
|
||||||
|
if (http_code_out) *http_code_out = 0;
|
||||||
|
|
||||||
if (url_host(url, host, sizeof(host))) return -1;
|
if (url_host(url, host, sizeof(host))) return -1;
|
||||||
if (!host_allowed(host)) return -1;
|
if (!host_allowed(host)) return -1;
|
||||||
if (dns_lookup(host, ip, sizeof(ip))) return -1;
|
if (dns_lookup(host, ip, sizeof(ip))) return -1;
|
||||||
@@ -871,8 +881,8 @@ patchdl_http_download_piece(const char *url, int fd,
|
|||||||
curl_easy_setopt(curl, CURLOPT_SSL_CIPHER_LIST, "DEFAULT@SECLEVEL=0");
|
curl_easy_setopt(curl, CURLOPT_SSL_CIPHER_LIST, "DEFAULT@SECLEVEL=0");
|
||||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);
|
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);
|
||||||
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");
|
/* HTTPS pin removed on the streaming piece path — see the same change in
|
||||||
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https");
|
http_download_to_file_progress for the why. */
|
||||||
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
|
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
|
||||||
curl_easy_setopt(curl, CURLOPT_FAILONERROR, 1L); /* 4xx/5xx -> error, no body written */
|
curl_easy_setopt(curl, CURLOPT_FAILONERROR, 1L); /* 4xx/5xx -> error, no body written */
|
||||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 20L);
|
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 20L);
|
||||||
@@ -890,6 +900,9 @@ patchdl_http_download_piece(const char *url, int fd,
|
|||||||
curl_easy_cleanup(curl);
|
curl_easy_cleanup(curl);
|
||||||
curl_slist_free_all(rl);
|
curl_slist_free_all(rl);
|
||||||
|
|
||||||
|
if (curl_rc_out) *curl_rc_out = (int)res;
|
||||||
|
if (http_code_out) *http_code_out = http_code;
|
||||||
|
|
||||||
if (res != CURLE_OK) {
|
if (res != CURLE_OK) {
|
||||||
if (sink.md) EVP_MD_CTX_free(sink.md);
|
if (sink.md) EVP_MD_CTX_free(sink.md);
|
||||||
return -1; /* network error / abort */
|
return -1; /* network error / abort */
|
||||||
|
|||||||
+5
-2
@@ -52,11 +52,14 @@ typedef struct {
|
|||||||
|
|
||||||
/* Download one whole piece and pwrite it into `fd` at `file_offset`. Concurrent
|
/* Download one whole piece and pwrite it into `fd` at `file_offset`. Concurrent
|
||||||
non-overlapping pieces of the same fd are safe. Returns 0 on success (and
|
non-overlapping pieces of the same fd are safe. Returns 0 on success (and
|
||||||
fdatasyncs fd), -1 on network/IO/abort, -2 on a SHA-256 mismatch. */
|
fdatasyncs fd), -1 on network/IO/abort, -2 on a SHA-256 mismatch.
|
||||||
|
`curl_rc_out`/`http_code_out` (either may be NULL) receive the last libcurl
|
||||||
|
CURLcode + HTTP status for failure diagnosis. */
|
||||||
int patchdl_http_download_piece(const char *url, int fd,
|
int patchdl_http_download_piece(const char *url, int fd,
|
||||||
long long file_offset, long long file_size,
|
long long file_offset, long long file_size,
|
||||||
const char *expected_sha256_or_null,
|
const char *expected_sha256_or_null,
|
||||||
patchdl_piece_ctx_t *ctx);
|
patchdl_piece_ctx_t *ctx,
|
||||||
|
int *curl_rc_out, long *http_code_out);
|
||||||
|
|
||||||
/* Read-only: SHA-256 a [offset, offset+size) region of fd into out_hex
|
/* Read-only: SHA-256 a [offset, offset+size) region of fd into out_hex
|
||||||
(caller provides >= 65 bytes). Returns 0 on success. */
|
(caller provides >= 65 bytes). Returns 0 on success. */
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
#include "patchdl_tile.h"
|
||||||
|
#include "patchdl_install.h"
|
||||||
|
|
||||||
|
#include <errno.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <ps5/kernel.h>
|
||||||
|
|
||||||
|
/* Embed the tile assets into .rodata directly via .incbin — no codegen step,
|
||||||
|
no Python helper, no second translation unit. Matches itsPLK's pattern. */
|
||||||
|
#define INCASSET(name, file) \
|
||||||
|
__asm__(".section .rodata\n" \
|
||||||
|
".global " #name "\n" \
|
||||||
|
".global " #name "_end\n" \
|
||||||
|
".global " #name "_size\n" \
|
||||||
|
".align 16\n" #name ":\n" \
|
||||||
|
".incbin \"" file "\"\n" #name "_end:\n" #name "_size:\n" \
|
||||||
|
".quad " #name "_end - " #name "\n" \
|
||||||
|
".previous\n"); \
|
||||||
|
extern const uint8_t name[]; \
|
||||||
|
extern const size_t name##_size;
|
||||||
|
|
||||||
|
INCASSET(tile_param_json, "assets/param.json");
|
||||||
|
INCASSET(tile_icon0_png, "assets/icon0.png");
|
||||||
|
|
||||||
|
#define TILE_TITLE_ID "PTDL00001"
|
||||||
|
|
||||||
|
/* Forward decls — we resolve sceAppInstUtilInitialize/AppInstallTitleDir at
|
||||||
|
runtime via the kernel dynlib helpers so the ELF stays loader-friendly. */
|
||||||
|
typedef int (*ai_init_fn)(void);
|
||||||
|
typedef int (*ai_install_dir_fn)(const char *title_id, const char *parent_dir,
|
||||||
|
void *opts);
|
||||||
|
|
||||||
|
static intptr_t
|
||||||
|
dynsym_by_name(const char *sym) {
|
||||||
|
uint32_t h = 0;
|
||||||
|
if (kernel_dynlib_handle(-1, "libSceAppInstUtil.sprx", &h) < 0) return 0;
|
||||||
|
return kernel_dynlib_dlsym(-1, h, sym);
|
||||||
|
}
|
||||||
|
|
||||||
|
static intptr_t
|
||||||
|
dynsym_by_nid(const char *nid) {
|
||||||
|
uint32_t h = 0;
|
||||||
|
if (kernel_dynlib_handle(-1, "libSceAppInstUtil.sprx", &h) < 0) return 0;
|
||||||
|
return kernel_dynlib_resolve(-1, h, nid);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int
|
||||||
|
write_all(const char *path, const uint8_t *data, size_t size) {
|
||||||
|
int fd;
|
||||||
|
ssize_t w;
|
||||||
|
size_t off = 0;
|
||||||
|
|
||||||
|
/* O_NOFOLLOW + 0600: don't follow a symlink at the destination, and don't
|
||||||
|
create the file with the libc default 0666 mode. Same pattern as the
|
||||||
|
net layer's fopen_safe. */
|
||||||
|
fd = open(path, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW | O_CLOEXEC, 0600);
|
||||||
|
if (fd < 0) return -1;
|
||||||
|
while (off < size) {
|
||||||
|
w = write(fd, data + off, size - off);
|
||||||
|
if (w < 0) {
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
close(fd);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
off += (size_t)w;
|
||||||
|
}
|
||||||
|
close(fd);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int
|
||||||
|
file_matches(const char *path, const uint8_t *expected, size_t expected_size) {
|
||||||
|
struct stat st;
|
||||||
|
uint8_t *buf;
|
||||||
|
int fd;
|
||||||
|
ssize_t n;
|
||||||
|
int match = 0;
|
||||||
|
|
||||||
|
if (stat(path, &st) != 0) return 0;
|
||||||
|
if ((size_t)st.st_size != expected_size) return 0;
|
||||||
|
|
||||||
|
fd = open(path, O_RDONLY | O_CLOEXEC);
|
||||||
|
if (fd < 0) return 0;
|
||||||
|
|
||||||
|
buf = malloc(expected_size);
|
||||||
|
if (!buf) { close(fd); return 0; }
|
||||||
|
|
||||||
|
n = read(fd, buf, expected_size);
|
||||||
|
close(fd);
|
||||||
|
if (n == (ssize_t)expected_size && memcmp(buf, expected, expected_size) == 0)
|
||||||
|
match = 1;
|
||||||
|
|
||||||
|
free(buf);
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
int
|
||||||
|
patchdl_tile_install_if_needed(char *msg, size_t msg_sz) {
|
||||||
|
char base_dir[128];
|
||||||
|
char sce_sys_dir[160];
|
||||||
|
char param_path[192];
|
||||||
|
char icon_path[192];
|
||||||
|
|
||||||
|
ai_init_fn ai_initialize = NULL;
|
||||||
|
ai_install_dir_fn ai_install_title = NULL;
|
||||||
|
int rc;
|
||||||
|
|
||||||
|
snprintf(base_dir, sizeof base_dir, "/user/app/%s", TILE_TITLE_ID);
|
||||||
|
snprintf(sce_sys_dir, sizeof sce_sys_dir, "/user/app/%s/sce_sys", TILE_TITLE_ID);
|
||||||
|
snprintf(param_path, sizeof param_path, "/user/app/%s/sce_sys/param.json", TILE_TITLE_ID);
|
||||||
|
snprintf(icon_path, sizeof icon_path, "/user/app/%s/sce_sys/icon0.png", TILE_TITLE_ID);
|
||||||
|
|
||||||
|
/* stat-guard: if everything on disk already matches, do nothing. Re-running
|
||||||
|
the install API every payload start would be wasted work and burns the
|
||||||
|
only safe path through Sony's installer state machine. */
|
||||||
|
{
|
||||||
|
struct stat st;
|
||||||
|
if (stat(base_dir, &st) == 0 &&
|
||||||
|
file_matches(param_path, tile_param_json, tile_param_json_size) &&
|
||||||
|
file_matches(icon_path, tile_icon0_png, tile_icon0_png_size)) {
|
||||||
|
snprintf(msg, msg_sz, "tile already installed and up to date");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AppInstUtil is loaded lazily by the install backend thread. Poke it +
|
||||||
|
poll briefly so libSceAppInstUtil.sprx is mapped before we try to
|
||||||
|
resolve symbols out of it. Bounded to a few seconds so a stuck init
|
||||||
|
doesn't wedge an MHD worker forever. */
|
||||||
|
{
|
||||||
|
char ready_msg[128];
|
||||||
|
int ready = -1;
|
||||||
|
for (int i = 0; i < 60 && ready != 0; i++) {
|
||||||
|
ready = patchdl_install_backend_check(ready_msg, sizeof ready_msg);
|
||||||
|
if (ready != 0) usleep(250 * 1000);
|
||||||
|
}
|
||||||
|
if (ready != 0) {
|
||||||
|
snprintf(msg, msg_sz,
|
||||||
|
"install backend not ready: %s", ready_msg);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resolve the install API now so we can fail fast before touching disk.
|
||||||
|
itsPLK uses the NID (Wudg3Xe3heE) because the symbol export is
|
||||||
|
Sony-private; try both, NID first since it survives a stripped sprx. */
|
||||||
|
ai_install_title = (ai_install_dir_fn)dynsym_by_nid("Wudg3Xe3heE");
|
||||||
|
if (!ai_install_title)
|
||||||
|
ai_install_title = (ai_install_dir_fn)dynsym_by_name(
|
||||||
|
"sceAppInstUtilAppInstallTitleDir");
|
||||||
|
if (!ai_install_title) {
|
||||||
|
snprintf(msg, msg_sz, "sceAppInstUtilAppInstallTitleDir not resolved");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ai_initialize = (ai_init_fn)dynsym_by_name("sceAppInstUtilInitialize");
|
||||||
|
if (ai_initialize) {
|
||||||
|
rc = ai_initialize();
|
||||||
|
/* SCE_OK == 0; a non-zero rc here usually means "already initialised"
|
||||||
|
in this process, which is fine. We only bail on a clearly fatal
|
||||||
|
code (anything that isn't already the success case). */
|
||||||
|
if (rc != 0 && rc != 0x80B21161 /* ALREADY_INITIALIZED */) {
|
||||||
|
snprintf(msg, msg_sz,
|
||||||
|
"sceAppInstUtilInitialize failed 0x%08x", (unsigned)rc);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mkdir(base_dir, 0755) && errno != EEXIST) {
|
||||||
|
snprintf(msg, msg_sz, "mkdir %s failed errno=%d", base_dir, errno);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (mkdir(sce_sys_dir, 0755) && errno != EEXIST) {
|
||||||
|
snprintf(msg, msg_sz, "mkdir %s failed errno=%d", sce_sys_dir, errno);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (write_all(param_path, tile_param_json, tile_param_json_size)) {
|
||||||
|
snprintf(msg, msg_sz, "write param.json failed errno=%d", errno);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (write_all(icon_path, tile_icon0_png, tile_icon0_png_size)) {
|
||||||
|
snprintf(msg, msg_sz, "write icon0.png failed errno=%d", errno);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
rc = ai_install_title(TILE_TITLE_ID, "/user/app/", NULL);
|
||||||
|
if (rc != 0) {
|
||||||
|
snprintf(msg, msg_sz,
|
||||||
|
"AppInstallTitleDir(%s) returned 0x%08x",
|
||||||
|
TILE_TITLE_ID, (unsigned)rc);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
snprintf(msg, msg_sz, "tile installed (%s)", TILE_TITLE_ID);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
/* Install / refresh the PatchDL home-screen tile.
|
||||||
|
*
|
||||||
|
* Approach (from itsPLK's ps5-payload-manager/app_installer.c, which adapted
|
||||||
|
* John Tornblom's ftpsrv work): write param.json + icon0.png into
|
||||||
|
* /user/app/<TITLE_ID>/sce_sys/
|
||||||
|
* then call sceAppInstUtilAppInstallTitleDir(title_id, "/user/app/", 0)
|
||||||
|
* which registers the directory as an app. The tile's deeplinkUri opens
|
||||||
|
* Sony's WebKit browser at http://127.0.0.1:12880/, i.e. PatchDL's own UI
|
||||||
|
* — only useful while the ELF is running.
|
||||||
|
*
|
||||||
|
* No PKG, no code signing, no debug-magic. Files only — Sony's installer
|
||||||
|
* registers the directory as an app.
|
||||||
|
*
|
||||||
|
* stat-guard: the asset bytes are diffed against the on-disk copy first;
|
||||||
|
* if nothing changed the install API isn't called at all (avoids a costly
|
||||||
|
* re-register every payload start, and avoids the dangerous CancelInstall
|
||||||
|
* code path). Returns 0 on success or when nothing needed doing.
|
||||||
|
*/
|
||||||
|
int patchdl_tile_install_if_needed(char *msg, size_t msg_sz);
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#define PATCHDL_VERSION "0.0.4"
|
#define PATCHDL_VERSION "0.0.6"
|
||||||
@@ -183,10 +183,10 @@ patchdl_verxml_query(const char *url, uint32_t fw_bin, patchdl_verinfo_t *out) {
|
|||||||
if (!url || !out) return -1;
|
if (!url || !out) return -1;
|
||||||
memset(out, 0, sizeof(*out));
|
memset(out, 0, sizeof(*out));
|
||||||
|
|
||||||
/* version.xml is a few KB in practice; cap to 16 MiB so a misbehaving CDN
|
/* version.xml is a few KB in practice; cap so a misbehaving CDN can't
|
||||||
can't slurp unbounded RAM into the buffer. */
|
slurp unbounded RAM into the buffer. */
|
||||||
memset(&buf, 0, sizeof(buf));
|
memset(&buf, 0, sizeof(buf));
|
||||||
buf.max = 16 * 1024 * 1024;
|
buf.max = 4 * 1024 * 1024;
|
||||||
if (patchdl_http_get(url, &buf)) return -1;
|
if (patchdl_http_get(url, &buf)) return -1;
|
||||||
if (!buf.data || !buf.size) { free(buf.data); return -1; }
|
if (!buf.data || !buf.size) { free(buf.data); return -1; }
|
||||||
|
|
||||||
|
|||||||
+82
-10
@@ -5,7 +5,9 @@
|
|||||||
#include "patchdl_install.h"
|
#include "patchdl_install.h"
|
||||||
#include "patchdl_net.h"
|
#include "patchdl_net.h"
|
||||||
#include "patchdl_resolve.h"
|
#include "patchdl_resolve.h"
|
||||||
|
#include "patchdl_tile.h"
|
||||||
#include "patchdl_verxml.h"
|
#include "patchdl_verxml.h"
|
||||||
|
#include "patchdl_version.h"
|
||||||
|
|
||||||
#include <microhttpd.h>
|
#include <microhttpd.h>
|
||||||
#include <pthread.h>
|
#include <pthread.h>
|
||||||
@@ -94,6 +96,8 @@ typedef struct dl_job {
|
|||||||
int inflight;
|
int inflight;
|
||||||
int fd; /* O_RDWR dest fd, -1 until admit */
|
int fd; /* O_RDWR dest fd, -1 until admit */
|
||||||
int rc; /* 0 ok, -1 net/io, -2 verify */
|
int rc; /* 0 ok, -1 net/io, -2 verify */
|
||||||
|
int last_curl_rc; /* CURLcode from the most recent piece */
|
||||||
|
long last_http_code; /* HTTP status from the most recent piece */
|
||||||
struct dl_job *next;
|
struct dl_job *next;
|
||||||
} dl_job_t;
|
} dl_job_t;
|
||||||
|
|
||||||
@@ -279,7 +283,14 @@ queue_buffer(struct MHD_Connection *conn, unsigned int status,
|
|||||||
enum MHD_Result ret;
|
enum MHD_Result ret;
|
||||||
|
|
||||||
resp = MHD_create_response_from_buffer(size, (void *)data, mm);
|
resp = MHD_create_response_from_buffer(size, (void *)data, mm);
|
||||||
if (!resp) return MHD_NO;
|
if (!resp) {
|
||||||
|
/* MUST_FREE hands ownership to MHD on success; on failure we still
|
||||||
|
own it and have to free it ourselves or the caller's strdup'd
|
||||||
|
JSON leaks. PERSISTENT/MUST_COPY buffers are caller-owned and
|
||||||
|
we leave them alone. */
|
||||||
|
if (mm == MHD_RESPMEM_MUST_FREE) free((void *)data);
|
||||||
|
return MHD_NO;
|
||||||
|
}
|
||||||
|
|
||||||
MHD_add_response_header(resp, MHD_HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN, "*");
|
MHD_add_response_header(resp, MHD_HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN, "*");
|
||||||
MHD_add_response_header(resp, MHD_HTTP_HEADER_CACHE_CONTROL, "no-store");
|
MHD_add_response_header(resp, MHD_HTTP_HEADER_CACHE_CONTROL, "no-store");
|
||||||
@@ -585,8 +596,9 @@ build_status_json(void) {
|
|||||||
"\"dns_guard\":\"Active\","
|
"\"dns_guard\":\"Active\","
|
||||||
"\"resolver\":\"Internal allowlist\","
|
"\"resolver\":\"Internal allowlist\","
|
||||||
"\"free_space_mb\":%lld,"
|
"\"free_space_mb\":%lld,"
|
||||||
"\"download_dir\":\"/data/patchdl (internal)\"}",
|
"\"download_dir\":\"/data/patchdl (internal)\","
|
||||||
g_fw.str, g_fw.bin, data_free_mb());
|
"\"version\":\"%s\"}",
|
||||||
|
g_fw.str, g_fw.bin, data_free_mb(), PATCHDL_VERSION);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -824,6 +836,8 @@ build_downloads_json(void) {
|
|||||||
jbuf_append(&j, ",\"state\":"); jbuf_append_str(&j, job_state_str(job->state));
|
jbuf_append(&j, ",\"state\":"); jbuf_append_str(&j, job_state_str(job->state));
|
||||||
jbuf_appendf(&j, ",\"progress\":%d", progress);
|
jbuf_appendf(&j, ",\"progress\":%d", progress);
|
||||||
jbuf_appendf(&j, ",\"bytes\":%lld,\"total_bytes\":%lld", bytes, total);
|
jbuf_appendf(&j, ",\"bytes\":%lld,\"total_bytes\":%lld", bytes, total);
|
||||||
|
jbuf_appendf(&j, ",\"rc\":%d,\"last_curl_rc\":%d,\"last_http_code\":%ld",
|
||||||
|
job->rc, job->last_curl_rc, job->last_http_code);
|
||||||
jbuf_append(&j, "}");
|
jbuf_append(&j, "}");
|
||||||
}
|
}
|
||||||
jbuf_append(&j, "]");
|
jbuf_append(&j, "]");
|
||||||
@@ -1122,7 +1136,11 @@ write_job_state(const dl_job_t *job) {
|
|||||||
ps[] states and done_bytes. Called with the pool lock held. */
|
ps[] states and done_bytes. Called with the pool lock held. */
|
||||||
static void
|
static void
|
||||||
seed_from_sidecar(dl_job_t *job) {
|
seed_from_sidecar(dl_job_t *job) {
|
||||||
char path[320], buf[16384], murl[768];
|
/* 8 KB sidecar buffer: 4096-piece cap × 2 hex chars / 8 bits = 1024 hex
|
||||||
|
chars for the bitmap, plus the JSON wrapper and the up-to-768-byte
|
||||||
|
manifest_url — fits with room. Halved from 16 KB to lighten the
|
||||||
|
per-admit stack frame on the pool worker. */
|
||||||
|
char path[320], buf[8192], murl[768];
|
||||||
FILE *f;
|
FILE *f;
|
||||||
size_t n;
|
size_t n;
|
||||||
int nbytes = (job->mf.count + 7) / 8;
|
int nbytes = (job->mf.count + 7) / 8;
|
||||||
@@ -1337,6 +1355,7 @@ admit_next(void) {
|
|||||||
free(q->ps); q->ps = NULL;
|
free(q->ps); q->ps = NULL;
|
||||||
free(q->bitmap); q->bitmap = NULL;
|
free(q->bitmap); q->bitmap = NULL;
|
||||||
q->done_bytes = 0; q->pieces_failed = 0; q->rc = 0;
|
q->done_bytes = 0; q->pieces_failed = 0; q->rc = 0;
|
||||||
|
q->last_curl_rc = 0; q->last_http_code = 0;
|
||||||
|
|
||||||
q->mf = mf; q->ps = ps; q->bitmap = bitmap; q->fd = fd; q->total = total;
|
q->mf = mf; q->ps = ps; q->bitmap = bitmap; q->fd = fd; q->total = total;
|
||||||
for (int i = 0; i < mf.count; i++) q->ps[i].slot = -1;
|
for (int i = 0; i < mf.count; i++) q->ps[i].slot = -1;
|
||||||
@@ -1444,15 +1463,22 @@ dl_worker(void *arg) {
|
|||||||
pthread_mutex_unlock(&g_pool.mtx);
|
pthread_mutex_unlock(&g_pool.mtx);
|
||||||
|
|
||||||
/* ---- download the piece, NO lock ---- */
|
/* ---- download the piece, NO lock ---- */
|
||||||
|
int last_curl_rc = 0;
|
||||||
|
long last_http_code = 0;
|
||||||
{
|
{
|
||||||
patchdl_piece_ctx_t ctx = { &g_pool.inflight_bytes[slot], abort_ptr };
|
patchdl_piece_ctx_t ctx = { &g_pool.inflight_bytes[slot], abort_ptr };
|
||||||
rc = patchdl_http_download_piece(url, fd, off, sz,
|
rc = patchdl_http_download_piece(url, fd, off, sz,
|
||||||
verify && hash[0] ? hash : NULL, &ctx);
|
verify && hash[0] ? hash : NULL, &ctx,
|
||||||
|
&last_curl_rc, &last_http_code);
|
||||||
}
|
}
|
||||||
|
|
||||||
pthread_mutex_lock(&g_pool.mtx);
|
pthread_mutex_lock(&g_pool.mtx);
|
||||||
g_pool.inflight_bytes[slot] = 0;
|
g_pool.inflight_bytes[slot] = 0;
|
||||||
job->inflight--;
|
job->inflight--;
|
||||||
|
if (rc != 0) {
|
||||||
|
job->last_curl_rc = last_curl_rc;
|
||||||
|
job->last_http_code = last_http_code;
|
||||||
|
}
|
||||||
job->ps[pidx].slot = -1;
|
job->ps[pidx].slot = -1;
|
||||||
if (my_seq != job->seq) {
|
if (my_seq != job->seq) {
|
||||||
/* job cancelled/torn down under us: discard result (do not touch
|
/* job cancelled/torn down under us: discard result (do not touch
|
||||||
@@ -1845,6 +1871,7 @@ static enum MHD_Result
|
|||||||
handle_config_post(struct MHD_Connection *conn, const char *body) {
|
handle_config_post(struct MHD_Connection *conn, const char *body) {
|
||||||
char pol[8];
|
char pol[8];
|
||||||
int mc;
|
int mc;
|
||||||
|
int tile_just_enabled = 0;
|
||||||
|
|
||||||
json_get_str(body, "default_policy", pol, sizeof(pol));
|
json_get_str(body, "default_policy", pol, sizeof(pol));
|
||||||
/* Only the two real values are accepted; anything else is silently
|
/* Only the two real values are accepted; anything else is silently
|
||||||
@@ -1864,8 +1891,12 @@ handle_config_post(struct MHD_Connection *conn, const char *body) {
|
|||||||
json_get_bool(body, "delete_pkg_after_install", g_cfg.delete_pkg_after_install);
|
json_get_bool(body, "delete_pkg_after_install", g_cfg.delete_pkg_after_install);
|
||||||
g_cfg.verify_downloads =
|
g_cfg.verify_downloads =
|
||||||
json_get_bool(body, "verify_downloads", g_cfg.verify_downloads);
|
json_get_bool(body, "verify_downloads", g_cfg.verify_downloads);
|
||||||
g_cfg.home_shortcut =
|
{
|
||||||
json_get_bool(body, "home_shortcut", g_cfg.home_shortcut);
|
int prev_tile = g_cfg.home_shortcut;
|
||||||
|
g_cfg.home_shortcut =
|
||||||
|
json_get_bool(body, "home_shortcut", g_cfg.home_shortcut);
|
||||||
|
tile_just_enabled = (!prev_tile && g_cfg.home_shortcut);
|
||||||
|
}
|
||||||
g_cfg.max_connections =
|
g_cfg.max_connections =
|
||||||
json_get_int(body, "max_connections", g_cfg.max_connections);
|
json_get_int(body, "max_connections", g_cfg.max_connections);
|
||||||
if (g_cfg.max_connections < 1) g_cfg.max_connections = 1;
|
if (g_cfg.max_connections < 1) g_cfg.max_connections = 1;
|
||||||
@@ -1885,6 +1916,16 @@ handle_config_post(struct MHD_Connection *conn, const char *body) {
|
|||||||
pthread_mutex_unlock(&g_pool.mtx);
|
pthread_mutex_unlock(&g_pool.mtx);
|
||||||
|
|
||||||
save_config();
|
save_config();
|
||||||
|
|
||||||
|
/* Trigger the tile install when the toggle just flipped on. Best-effort —
|
||||||
|
the config save is already committed; we don't want a tile error to
|
||||||
|
roll that back. Stat-guard inside the helper makes repeated calls a
|
||||||
|
no-op, so a save without flipping the toggle still costs nothing. */
|
||||||
|
if (tile_just_enabled) {
|
||||||
|
char tile_msg[256];
|
||||||
|
(void)patchdl_tile_install_if_needed(tile_msg, sizeof(tile_msg));
|
||||||
|
}
|
||||||
|
|
||||||
return queue_json_owned(conn, MHD_HTTP_OK, build_config_json());
|
return queue_json_owned(conn, MHD_HTTP_OK, build_config_json());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1996,6 +2037,15 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
|
|||||||
return queue_json_owned(conn, rc == 0 ? MHD_HTTP_OK : MHD_HTTP_BAD_GATEWAY,
|
return queue_json_owned(conn, rc == 0 ? MHD_HTTP_OK : MHD_HTTP_BAD_GATEWAY,
|
||||||
strdup(resp));
|
strdup(resp));
|
||||||
}
|
}
|
||||||
|
if (!strcmp(url, "/api/install_tile")) {
|
||||||
|
char msg[256], resp[320];
|
||||||
|
int rc = patchdl_tile_install_if_needed(msg, sizeof(msg));
|
||||||
|
snprintf(resp, sizeof(resp),
|
||||||
|
"{\"ok\":%s,\"rc\":%d,\"message\":\"%s\"}",
|
||||||
|
rc == 0 ? "true" : "false", rc, msg);
|
||||||
|
return queue_json_owned(conn, rc == 0 ? MHD_HTTP_OK : MHD_HTTP_BAD_GATEWAY,
|
||||||
|
strdup(resp));
|
||||||
|
}
|
||||||
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
|
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2249,6 +2299,15 @@ patchdl_websrv_start(unsigned short port) {
|
|||||||
g_titles[i].enabled = (g_titles[i].source_type != PATCHDL_SOURCE_UNKNOWN);
|
g_titles[i].enabled = (g_titles[i].source_type != PATCHDL_SOURCE_UNKNOWN);
|
||||||
load_config();
|
load_config();
|
||||||
|
|
||||||
|
/* If the user opted into a home-screen tile, refresh it now. The helper
|
||||||
|
is stat-guarded — when nothing changed on disk it's a no-op, so the
|
||||||
|
cost on subsequent startups is two file reads. Best-effort: never
|
||||||
|
block startup on a tile install failure. */
|
||||||
|
if (g_cfg.home_shortcut) {
|
||||||
|
char tile_msg[256];
|
||||||
|
(void)patchdl_tile_install_if_needed(tile_msg, sizeof(tile_msg));
|
||||||
|
}
|
||||||
|
|
||||||
/* Flag titles that have a partial download on disk so the UI can offer
|
/* Flag titles that have a partial download on disk so the UI can offer
|
||||||
Resume after a reboot. */
|
Resume after a reboot. */
|
||||||
detect_resumable_partials();
|
detect_resumable_partials();
|
||||||
@@ -2293,11 +2352,16 @@ patchdl_websrv_start(unsigned short port) {
|
|||||||
MHD_USE_INTERNAL_POLLING_THREAD | MHD_USE_THREAD_PER_CONNECTION,
|
MHD_USE_INTERNAL_POLLING_THREAD | MHD_USE_THREAD_PER_CONNECTION,
|
||||||
port, NULL, NULL, &on_request, NULL,
|
port, NULL, NULL, &on_request, NULL,
|
||||||
MHD_OPTION_NOTIFY_COMPLETED, request_completed, NULL,
|
MHD_OPTION_NOTIFY_COMPLETED, request_completed, NULL,
|
||||||
/* DoS guards: bound concurrent sockets + per-IP to keep a misbehaving
|
/* DoS guards + RAM caps: the PS5 process budget is a few hundred MB
|
||||||
LAN client from exhausting pthreads on the PS5. */
|
and the default pthread stack on this libc is multiple MB. We're
|
||||||
MHD_OPTION_CONNECTION_LIMIT, (unsigned int)64,
|
a single-user UI — 8 concurrent connections is plenty for the
|
||||||
|
SSE poll + a couple of AJAX, and 256 KB per worker is more than
|
||||||
|
enough for our handlers (largest stack use is a 16 KB sidecar
|
||||||
|
buffer in seed_from_sidecar). */
|
||||||
|
MHD_OPTION_CONNECTION_LIMIT, (unsigned int)8,
|
||||||
MHD_OPTION_PER_IP_CONNECTION_LIMIT, (unsigned int)8,
|
MHD_OPTION_PER_IP_CONNECTION_LIMIT, (unsigned int)8,
|
||||||
MHD_OPTION_CONNECTION_TIMEOUT, (unsigned int)30,
|
MHD_OPTION_CONNECTION_TIMEOUT, (unsigned int)30,
|
||||||
|
MHD_OPTION_THREAD_STACK_SIZE, (size_t)(512 * 1024),
|
||||||
MHD_OPTION_END);
|
MHD_OPTION_END);
|
||||||
|
|
||||||
if (!web_daemon) {
|
if (!web_daemon) {
|
||||||
@@ -2350,11 +2414,19 @@ patchdl_websrv_stop(void) {
|
|||||||
for (int s = 0; s < g_pool.n_workers; s++)
|
for (int s = 0; s < g_pool.n_workers; s++)
|
||||||
pthread_join(g_pool.workers[s], NULL);
|
pthread_join(g_pool.workers[s], NULL);
|
||||||
g_pool.n_workers = 0;
|
g_pool.n_workers = 0;
|
||||||
|
/* Free every job still in the pool list. Workers have joined so nobody
|
||||||
|
else touches the list. free_job_locked unlinks + frees mf.pieces[i].url,
|
||||||
|
mf.pieces, ps, bitmap, and closes the fd. */
|
||||||
|
pthread_mutex_lock(&g_pool.mtx);
|
||||||
|
while (g_pool.jobs) free_job_locked(g_pool.jobs);
|
||||||
|
pthread_mutex_unlock(&g_pool.mtx);
|
||||||
patchdl_net_global_cleanup();
|
patchdl_net_global_cleanup();
|
||||||
|
|
||||||
pthread_mutex_lock(&g_mutex);
|
pthread_mutex_lock(&g_mutex);
|
||||||
patchdl_scan_free(g_titles, g_title_count);
|
patchdl_scan_free(g_titles, g_title_count);
|
||||||
g_titles = NULL;
|
g_titles = NULL;
|
||||||
g_title_count = 0;
|
g_title_count = 0;
|
||||||
|
free(g_debug_json);
|
||||||
|
g_debug_json = NULL;
|
||||||
pthread_mutex_unlock(&g_mutex);
|
pthread_mutex_unlock(&g_mutex);
|
||||||
}
|
}
|
||||||
+73
-3
@@ -127,6 +127,15 @@ function bindElements() {
|
|||||||
connPlus: document.getElementById("connPlus"),
|
connPlus: document.getElementById("connPlus"),
|
||||||
refreshBtn: document.getElementById("refreshBtn"),
|
refreshBtn: document.getElementById("refreshBtn"),
|
||||||
updateAllBtn: document.getElementById("updateAllBtn"),
|
updateAllBtn: document.getElementById("updateAllBtn"),
|
||||||
|
brandVersion: document.getElementById("brandVersion"),
|
||||||
|
globalDl: document.getElementById("globalDl"),
|
||||||
|
globalDlState: document.getElementById("globalDlState"),
|
||||||
|
globalDlName: document.getElementById("globalDlName"),
|
||||||
|
globalDlPosition: document.getElementById("globalDlPosition"),
|
||||||
|
globalDlPct: document.getElementById("globalDlPct"),
|
||||||
|
globalDlSpeed: document.getElementById("globalDlSpeed"),
|
||||||
|
globalDlEta: document.getElementById("globalDlEta"),
|
||||||
|
globalDlBar: document.getElementById("globalDlBar"),
|
||||||
saveBtn: document.getElementById("saveBtn"),
|
saveBtn: document.getElementById("saveBtn"),
|
||||||
clearLogBtn: document.getElementById("clearLogBtn"),
|
clearLogBtn: document.getElementById("clearLogBtn"),
|
||||||
toast: document.getElementById("toast"),
|
toast: document.getElementById("toast"),
|
||||||
@@ -240,6 +249,8 @@ function renderStatus() {
|
|||||||
els.downloadDirValue.textContent = state.status.download_dir || state.config.download_dir || "Download target";
|
els.downloadDirValue.textContent = state.status.download_dir || state.config.download_dir || "Download target";
|
||||||
if (els.railFw) els.railFw.textContent = `FW ${state.status.firmware || "--"}`;
|
if (els.railFw) els.railFw.textContent = `FW ${state.status.firmware || "--"}`;
|
||||||
if (els.railSpace) els.railSpace.textContent = `${space} free`;
|
if (els.railSpace) els.railSpace.textContent = `${space} free`;
|
||||||
|
if (els.brandVersion)
|
||||||
|
els.brandVersion.textContent = state.status.version ? `v${state.status.version}` : "v--";
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONN_MIN = 1, CONN_MAX = 16;
|
const CONN_MIN = 1, CONN_MAX = 16;
|
||||||
@@ -296,6 +307,7 @@ function renderSettings() {
|
|||||||
/* ---------------- games ---------------- */
|
/* ---------------- games ---------------- */
|
||||||
|
|
||||||
function renderGames() {
|
function renderGames() {
|
||||||
|
renderGlobalStatus();
|
||||||
const visible = state.titles.filter(matchesFilter).filter(matchesQuery);
|
const visible = state.titles.filter(matchesFilter).filter(matchesQuery);
|
||||||
els.gameGrid.replaceChildren();
|
els.gameGrid.replaceChildren();
|
||||||
|
|
||||||
@@ -311,9 +323,11 @@ function renderGames() {
|
|||||||
|
|
||||||
// Mutually-exclusive bucket per game for the filter chips.
|
// Mutually-exclusive bucket per game for the filter chips.
|
||||||
function gameCategory(game) {
|
function gameCategory(game) {
|
||||||
// In-flight work (queued, active, paused, installing) lives in its own
|
// Updating is the queue — jobs waiting for a pool slot. An actively
|
||||||
// bucket so Updatable shows only what the user could still trigger.
|
// downloading game stays under Updatable so you don't lose sight of it
|
||||||
if (game.installing || game.downloading || game.resumable) return "updating";
|
// while drilling into the queue; same for paused (the user knows where
|
||||||
|
// it is and can resume from the card) and installing.
|
||||||
|
if (game._jobState === "queued") return "updating";
|
||||||
// checking is transient (version lookup still running); keep it visible under
|
// checking is transient (version lookup still running); keep it visible under
|
||||||
// Updatable rather than letting it fall out of every specific filter.
|
// Updatable rather than letting it fall out of every specific filter.
|
||||||
if (game.status === "checking") return "updatable";
|
if (game.status === "checking") return "updatable";
|
||||||
@@ -557,9 +571,11 @@ function reconcileFromJobs(jobs) {
|
|||||||
g._localDownloading = false;
|
g._localDownloading = false;
|
||||||
}
|
}
|
||||||
if (g.downloading && !g._localDownloading) g.downloading = false;
|
if (g.downloading && !g._localDownloading) g.downloading = false;
|
||||||
|
g._jobState = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
g._localDownloading = false; // the pool now tracks it
|
g._localDownloading = false; // the pool now tracks it
|
||||||
|
g._jobState = j.state;
|
||||||
if (j.state === "active" || j.state === "queued") {
|
if (j.state === "active" || j.state === "queued") {
|
||||||
g.downloading = true;
|
g.downloading = true;
|
||||||
g.resumable = false;
|
g.resumable = false;
|
||||||
@@ -689,8 +705,62 @@ function downloadingIds() {
|
|||||||
return state.titles.filter((g) => g.downloading).map((g) => g.title_id).join(",");
|
return state.titles.filter((g) => g.downloading).map((g) => g.title_id).join(",");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Top-of-page status banner. Visible while any job in this batch is queued,
|
||||||
|
// active, or has just finished (done jobs linger one or two polls before the
|
||||||
|
// pool reaps them, which is what gives us the "5 of 9" position).
|
||||||
|
function renderGlobalStatus() {
|
||||||
|
const el = els.globalDl;
|
||||||
|
if (!el) return;
|
||||||
|
const jobs = state.downloads || [];
|
||||||
|
const batch = jobs.filter((j) => ["queued", "active", "done"].includes(j.state));
|
||||||
|
const flying = batch.filter((j) => j.state === "queued" || j.state === "active");
|
||||||
|
|
||||||
|
if (flying.length === 0) { el.hidden = true; return; }
|
||||||
|
el.hidden = false;
|
||||||
|
|
||||||
|
const active = batch.find((j) => j.state === "active") || flying[0];
|
||||||
|
const total = batch.length;
|
||||||
|
const done = batch.filter((j) => j.state === "done").length;
|
||||||
|
const pos = Math.min(total, done + 1);
|
||||||
|
const isActive = active && active.state === "active";
|
||||||
|
|
||||||
|
// Eyebrow + name
|
||||||
|
els.globalDlState.textContent = isActive ? "Downloading" : "Queued";
|
||||||
|
els.globalDlName.textContent = active.name || active.title_id || "—";
|
||||||
|
|
||||||
|
// Position chip ("3 of 9") only when there's more than one game in this run
|
||||||
|
if (total > 1) {
|
||||||
|
els.globalDlPosition.hidden = false;
|
||||||
|
els.globalDlPosition.textContent = `${pos} of ${total}`;
|
||||||
|
} else {
|
||||||
|
els.globalDlPosition.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress line
|
||||||
|
const pct = pctOf(active);
|
||||||
|
const speed = Number(active._speed) || 0;
|
||||||
|
const done_ = Number(active.bytes) || 0;
|
||||||
|
const total_ = Number(active.total_bytes) || 0;
|
||||||
|
els.globalDlPct.innerHTML = isActive ? `<b>${pct}%</b>` : `<b>—</b> waiting`;
|
||||||
|
els.globalDlSpeed.innerHTML = isActive && speed > 0
|
||||||
|
? `<b>${formatBytes(speed)}/s</b>`
|
||||||
|
: (isActive && !total_ ? "fetching manifest…" : "—");
|
||||||
|
els.globalDlEta.innerHTML = isActive && speed > 0 && total_ > done_
|
||||||
|
? `ETA <b>${formatEta((total_ - done_) / speed)}</b>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// Bar: animated indeterminate while fetching manifest, otherwise width=pct
|
||||||
|
if (isActive && total_ > 0) {
|
||||||
|
els.globalDlBar.classList.remove("is-indeterminate");
|
||||||
|
els.globalDlBar.style.width = pct + "%";
|
||||||
|
} else {
|
||||||
|
els.globalDlBar.classList.add("is-indeterminate");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update the progress bar/meta in place to avoid rebuilding every card each tick.
|
// Update the progress bar/meta in place to avoid rebuilding every card each tick.
|
||||||
function applyDownloadProgress() {
|
function applyDownloadProgress() {
|
||||||
|
renderGlobalStatus();
|
||||||
let needRender = false;
|
let needRender = false;
|
||||||
state.downloads.forEach((d) => {
|
state.downloads.forEach((d) => {
|
||||||
// Only titles currently downloading render a progress tile; paused/done/error
|
// Only titles currently downloading render a progress tile; paused/done/error
|
||||||
|
|||||||
+24
-1
@@ -39,7 +39,7 @@
|
|||||||
<div class="brand-mark">PD</div>
|
<div class="brand-mark">PD</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>PatchDL</strong>
|
<strong>PatchDL</strong>
|
||||||
<span>by Knutwurst · v0.0.3</span>
|
<span>by Knutwurst · <span id="brandVersion">--</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -80,6 +80,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<aside id="globalDl" class="global-dl" hidden aria-live="polite">
|
||||||
|
<div class="global-dl-row">
|
||||||
|
<span class="global-dl-pulse" aria-hidden="true"></span>
|
||||||
|
<div class="global-dl-text">
|
||||||
|
<div class="global-dl-line1">
|
||||||
|
<span class="global-dl-eyebrow" id="globalDlState">Downloading</span>
|
||||||
|
<strong class="global-dl-name" id="globalDlName">—</strong>
|
||||||
|
<span class="global-dl-position" id="globalDlPosition" hidden></span>
|
||||||
|
</div>
|
||||||
|
<div class="global-dl-line2">
|
||||||
|
<span id="globalDlPct">0%</span>
|
||||||
|
<span class="global-dl-sep" aria-hidden="true">·</span>
|
||||||
|
<span id="globalDlSpeed">—</span>
|
||||||
|
<span class="global-dl-sep" aria-hidden="true">·</span>
|
||||||
|
<span id="globalDlEta">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="global-dl-track">
|
||||||
|
<i class="global-dl-bar" id="globalDlBar" style="width:0%"></i>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<section class="status-strip" aria-label="System status">
|
<section class="status-strip" aria-label="System status">
|
||||||
<article class="metric">
|
<article class="metric">
|
||||||
<span>Firmware</span>
|
<span>Firmware</span>
|
||||||
|
|||||||
@@ -173,6 +173,105 @@ h2 { font-size: 18px; line-height: 1.2; }
|
|||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- global download banner ---------------- */
|
||||||
|
/* Sticky banner above the status strip, visible only while at least one
|
||||||
|
download is queued/active. The pulse dot and gradient bar at the bottom
|
||||||
|
echo the per-game card progress styling so the two read as one system. */
|
||||||
|
.global-dl {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding: 16px 18px 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 80% at 0% 0%, var(--green-soft), transparent 60%),
|
||||||
|
linear-gradient(180deg, rgba(56, 193, 114, 0.06), transparent 60%),
|
||||||
|
var(--surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-dl[hidden] { display: none; }
|
||||||
|
|
||||||
|
.global-dl-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-dl-pulse {
|
||||||
|
flex: none;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--green);
|
||||||
|
box-shadow: 0 0 0 0 var(--green);
|
||||||
|
animation: globalDlPulse 1.6s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes globalDlPulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(56, 193, 114, 0.55); }
|
||||||
|
70% { box-shadow: 0 0 0 14px rgba(56, 193, 114, 0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(56, 193, 114, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-dl-text { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; }
|
||||||
|
|
||||||
|
.global-dl-line1 {
|
||||||
|
display: flex; align-items: baseline; gap: 10px;
|
||||||
|
min-width: 0; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.global-dl-eyebrow {
|
||||||
|
text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
font-size: 11px; font-weight: 600; color: var(--green-dark);
|
||||||
|
}
|
||||||
|
.global-dl-name {
|
||||||
|
font-size: 16px; font-weight: 600; color: #fff;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
min-width: 0; flex: 1;
|
||||||
|
}
|
||||||
|
.global-dl-position {
|
||||||
|
font-size: 12px; color: var(--muted);
|
||||||
|
padding: 2px 8px; border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.04); border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.global-dl-position[hidden] { display: none; }
|
||||||
|
|
||||||
|
.global-dl-line2 {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
font-size: 12px; color: var(--muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.global-dl-line2 b { color: #d6dde4; font-weight: 600; }
|
||||||
|
.global-dl-sep { opacity: 0.5; }
|
||||||
|
|
||||||
|
.global-dl-track {
|
||||||
|
position: relative;
|
||||||
|
height: 3px;
|
||||||
|
margin: 0 -18px; /* extend bar edge-to-edge */
|
||||||
|
background: rgba(56, 193, 114, 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.global-dl-bar {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
background: linear-gradient(90deg, var(--green) 0%, var(--green-dark) 100%);
|
||||||
|
box-shadow: 0 0 12px rgba(56, 193, 114, 0.4);
|
||||||
|
transition: width 400ms ease-out;
|
||||||
|
}
|
||||||
|
.global-dl-bar.is-indeterminate {
|
||||||
|
width: 32% !important;
|
||||||
|
animation: globalDlIndet 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes globalDlIndet {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(320%); }
|
||||||
|
}
|
||||||
|
|
||||||
.metric {
|
.metric {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
|
|||||||
Reference in new issue
Block a user