console page: F8 review fixes

- cheats: "attached to the game" (busy) only for EBUSY; other attach
  failures stay a plain "pt_attach failed (errno=N)".
- Continue on your phone shows only in the console's own browser
  (127.0.0.1/localhost); a gap above the dialog buttons.
- The token read gives up after 4 s (offline screen); a small splash
  instead of a blank page while it is asked.
- No silent reload: an appcache update, or a helper version newer than
  the page's build, shows a one-press "updated: Reload" banner (Later
  hides it). cw_update_* in 21 locales.
- own_addrs reports a getifaddrs failure and the list is kept (no
  flapping); EMFILE/ENFILE/ENOBUFS/ENOMEM never count toward a broken
  listener.
- backHistory header: Escape/○ at Home closes the browser on the PS5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
This commit is contained in:
phantomptrandClaude Opus 5.5 committed 2026-10-17 15:34:03 -07:00
1 parent f77c33e579
commit 831018b04d
58 files changed
+353 -72

No files matched your search

+16
View File
@@ -118,4 +118,20 @@ describe("probeHelper (is the helper there, or is this the offline copy?)", () =
globalThis.fetch = (async () => jsonResponse(500, { error: "x" })) as typeof fetch; globalThis.fetch = (async () => jsonResponse(500, { error: "x" })) as typeof fetch;
expect(await api.probeHelper()).toBe(true); expect(await api.probeHelper()).toBe(true);
}); });
it("a token read that never answers gives up after TOKEN_TIMEOUT_MS (offline screen, not a blank page)", async () => {
vi.useFakeTimers();
try {
globalThis.fetch = ((_u: unknown, init?: RequestInit) =>
new Promise<Response>((_res, rej) => {
init?.signal?.addEventListener("abort", () => rej(new Error("aborted")));
})) as typeof fetch;
const p = api.probeHelper();
await vi.advanceTimersByTimeAsync(api.TOKEN_TIMEOUT_MS);
expect(await p).toBe(false);
} finally {
vi.useRealTimers();
}
});
}); });
+8 -1
View File
@@ -39,12 +39,19 @@ export function onTokenExpired(fn: () => void): () => void {
let tokenPromise: Promise<string> | null = null; let tokenPromise: Promise<string> | null = null;
/** A token read that has not answered by then counts as "nothing answered" (the offline screen). */
export const TOKEN_TIMEOUT_MS = 4000;
async function fetchToken(): Promise<string> { async function fetchToken(): Promise<string> {
let res: Response; let res: Response;
const stop = typeof AbortController === "function" ? new AbortController() : null;
const timer = setTimeout(() => stop?.abort(), TOKEN_TIMEOUT_MS);
try { try {
res = await fetch("/api/token", { method: "GET", credentials: "same-origin", cache: "no-store" }); res = await fetch("/api/token", { method: "GET", credentials: "same-origin", cache: "no-store", signal: stop?.signal });
} catch { } catch {
throw new ApiError(0, "offline"); throw new ApiError(0, "offline");
} finally {
clearTimeout(timer);
} }
if (!res.ok) throw new ApiError(res.status, res.status === 503 ? "stopping" : "token_unavailable"); if (!res.ok) throw new ApiError(res.status, res.status === 503 ? "stopping" : "token_unavailable");
// Anything but JSON (another server on the port, a captive page) is not the helper. // Anything but JSON (another server on the port, a captive page) is not the helper.
+3 -1
View File
@@ -21,7 +21,9 @@
* *
* On the PS5 (opts.onRoot) the guard stays up on the first screen too, and ○ * On the PS5 (opts.onRoot) the guard stays up on the first screen too, and ○
* there calls onRoot (window.close(): the browser goes back to the PS5's home * there calls onRoot (window.close(): the browser goes back to the PS5's home
* screen, as Payload Manager does). If the page is still open after that, the * screen, as Payload Manager does). Escape, Backspace and a gamepad ○ take
* the same path (requestBack → history.back()), so on the PS5 they too close
* the browser from Home with nothing open. If the page is still open after that, the
* guard is left down so the next ○ does what the browser does by itself. * guard is left down so the next ○ does what the browser does by itself.
* *
* WebKit can fire a popstate as the page loads that pops nothing. Such a pop * WebKit can fire a popstate as the page loads that pops nothing. Such a pop
+12 -1
View File
@@ -1497,7 +1497,7 @@ button {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.9rem; gap: 0.9rem;
margin: 0.9rem 0 0; margin: 0.9rem 0 1rem;
padding: 0.6rem; padding: 0.6rem;
border-radius: 12px; border-radius: 12px;
background: var(--color-surface-3); background: var(--color-surface-3);
@@ -1522,3 +1522,14 @@ button {
font-size: 0.95rem; font-size: 0.95rem;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
/* ---- first moment while the helper is asked (main.tsx) ---- */
.cw-splash {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-canvas);
color: var(--color-muted);
font-size: 1.4rem;
}
+2
View File
@@ -2,3 +2,5 @@
declare module "virtual:pc-tokens.css"; declare module "virtual:pc-tokens.css";
/** The strings' build hash, defined by vite.console.config.ts (consoleStrings); absent under vitest. */ /** The strings' build hash, defined by vite.console.config.ts (consoleStrings); absent under vitest. */
declare const __CW_STRINGS_V__: string | undefined; declare const __CW_STRINGS_V__: string | undefined;
/** The app version the page was built from (vite.console.config.ts); absent under vitest. */
declare const __CW_APP_V__: string | undefined;
+3 -1
View File
@@ -414,5 +414,7 @@
"cw_tour_label": "نصائح", "cw_tour_label": "نصائح",
"cw_tour_next": "التالي", "cw_tour_next": "التالي",
"cw_tour_skip": "تخطٍّ", "cw_tour_skip": "تخطٍّ",
"cw_tour_step": "{n} من {total}" "cw_tour_step": "{n} من {total}",
"cw_update_later": "لاحقًا",
"cw_update_ready": "تم تحديث PS5Upload Files. أعد التحميل لاستخدام الإصدار الجديد."
} }
+3 -1
View File
@@ -414,5 +414,7 @@
"cw_tour_label": "টিপস", "cw_tour_label": "টিপস",
"cw_tour_next": "পরের", "cw_tour_next": "পরের",
"cw_tour_skip": "এড়িয়ে যান", "cw_tour_skip": "এড়িয়ে যান",
"cw_tour_step": "{total}-এর মধ্যে {n}" "cw_tour_step": "{total}-এর মধ্যে {n}",
"cw_update_later": "পরে",
"cw_update_ready": "PS5Upload Files আপডেট হয়েছে। নতুন সংস্করণ ব্যবহার করতে আবার লোড করুন।"
} }
+3 -1
View File
@@ -414,5 +414,7 @@
"cw_tour_label": "Tipps", "cw_tour_label": "Tipps",
"cw_tour_next": "Weiter", "cw_tour_next": "Weiter",
"cw_tour_skip": "Überspringen", "cw_tour_skip": "Überspringen",
"cw_tour_step": "{n} von {total}" "cw_tour_step": "{n} von {total}",
"cw_update_later": "Später",
"cw_update_ready": "PS5Upload Files wurde aktualisiert. Lade neu, um die neue Version zu verwenden."
} }
+3 -1
View File
@@ -414,5 +414,7 @@
"cw_tour_label": "Tips", "cw_tour_label": "Tips",
"cw_tour_next": "Next", "cw_tour_next": "Next",
"cw_tour_skip": "Skip", "cw_tour_skip": "Skip",
"cw_tour_step": "{n} of {total}" "cw_tour_step": "{n} of {total}",
"cw_update_later": "Later",
"cw_update_ready": "PS5Upload Files was updated. Reload to use the new version."
} }
+3 -1
View File
@@ -414,5 +414,7 @@
"cw_tour_label": "Consejos", "cw_tour_label": "Consejos",
"cw_tour_next": "Siguiente", "cw_tour_next": "Siguiente",
"cw_tour_skip": "Omitir", "cw_tour_skip": "Omitir",
"cw_tour_step": "{n} de {total}" "cw_tour_step": "{n} de {total}",
"cw_update_later": "Más tarde",
"cw_update_ready": "PS5Upload Files se actualizó. Recarga para usar la nueva versión."
} }
+3 -1
View File
@@ -414,5 +414,7 @@
"cw_tour_label": "نکته‌ها", "cw_tour_label": "نکته‌ها",
"cw_tour_next": "بعدی", "cw_tour_next": "بعدی",
"cw_tour_skip": "رد شدن", "cw_tour_skip": "رد شدن",
"cw_tour_step": "{n} از {total}" "cw_tour_step": "{n} از {total}",
"cw_update_later": "بعداً",
"cw_update_ready": "PS5Upload Files به‌روزرسانی شد. برای استفاده از نسخهٔ جدید، دوباره بارگیری کنید."
} }
+3 -1
View File
@@ -414,5 +414,7 @@
"cw_tour_label": "Astuces", "cw_tour_label": "Astuces",
"cw_tour_next": "Suivant", "cw_tour_next": "Suivant",
"cw_tour_skip": "Passer", "cw_tour_skip": "Passer",
"cw_tour_step": "{n} sur {total}" "cw_tour_step": "{n} sur {total}",
"cw_update_later": "Plus tard",
"cw_update_ready": "PS5Upload Files a été mis à jour. Rechargez pour utiliser la nouvelle version."
} }
+3 -1
View File
@@ -414,5 +414,7 @@
"cw_tour_label": "सुझाव", "cw_tour_label": "सुझाव",
"cw_tour_next": "आगे", "cw_tour_next": "आगे",
"cw_tour_skip": "छोड़ें", "cw_tour_skip": "छोड़ें",
"cw_tour_step": "{total} में से {n}" "cw_tour_step": "{total} में से {n}",
"cw_update_later": "बाद में",
"cw_update_ready": "PS5Upload Files अपडेट हो गया है। नया संस्करण इस्तेमाल करने के लिए फिर से लोड करें।"
} }
+3 -1
View File
@@ -414,5 +414,7 @@
"cw_tour_label": "Tippek", "cw_tour_label": "Tippek",
"cw_tour_next": "Tovább", "cw_tour_next": "Tovább",
"cw_tour_skip": "Kihagyás", "cw_tour_skip": "Kihagyás",
"cw_tour_step": "{n} / {total}" "cw_tour_step": "{n} / {total}",
"cw_update_later": "Később",
"cw_update_ready": "A PS5Upload Files frissült. Töltsd újra az új verzió használatához."
} }
+3 -1
View File
@@ -414,5 +414,7 @@
"cw_tour_label": "Tips", "cw_tour_label": "Tips",
"cw_tour_next": "Berikutnya", "cw_tour_next": "Berikutnya",
"cw_tour_skip": "Lewati", "cw_tour_skip": "Lewati",
"cw_tour_step": "{n} dari {total}" "cw_tour_step": "{n} dari {total}",
"cw_update_later": "Nanti",
"cw_update_ready": "PS5Upload Files telah diperbarui. Muat ulang untuk memakai versi baru."
} }
+3 -1
View File
@@ -414,5 +414,7 @@
"cw_tour_label": "Suggerimenti", "cw_tour_label": "Suggerimenti",
"cw_tour_next": "Avanti", "cw_tour_next": "Avanti",
"cw_tour_skip": "Salta", "cw_tour_skip": "Salta",
"cw_tour_step": "{n} di {total}" "cw_tour_step": "{n} di {total}",
"cw_update_later": "Più tardi",
"cw_update_ready": "PS5Upload Files è stato aggiornato. Ricarica per usare la nuova versione."
} }
+3 -1
View File
@@ -414,5 +414,7 @@
"cw_tour_label": "ヒント", "cw_tour_label": "ヒント",
"cw_tour_next": "次へ", "cw_tour_next": "次へ",
"cw_tour_skip": "スキップ", "cw_tour_skip": "スキップ",
"cw_tour_step": "{n} / {total}" "cw_tour_step": "{n} / {total}",
"cw_update_later": "後で",
"cw_update_ready": "PS5Upload Files が更新されました。新しいバージョンを使うには再読み込みしてください。"
} }
+3 -1
View File
@@ -414,5 +414,7 @@
"cw_tour_label": "팁", "cw_tour_label": "팁",
"cw_tour_next": "다음", "cw_tour_next": "다음",
"cw_tour_skip": "건너뛰기", "cw_tour_skip": "건너뛰기",
"cw_tour_step": "{n} / {total}" "cw_tour_step": "{n} / {total}",
"cw_update_later": "나중에",
"cw_update_ready": "PS5Upload Files가 업데이트되었습니다. 새 버전을 사용하려면 다시 불러오세요."
} }
+3 -1
View File
@@ -414,5 +414,7 @@
"cw_tour_label": "Wskazówki", "cw_tour_label": "Wskazówki",
"cw_tour_next": "Dalej", "cw_tour_next": "Dalej",
"cw_tour_skip": "Pomiń", "cw_tour_skip": "Pomiń",
"cw_tour_step": "{n} z {total}" "cw_tour_step": "{n} z {total}",
"cw_update_later": "Później",
"cw_update_ready": "PS5Upload Files został zaktualizowany. Wczytaj ponownie, aby użyć nowej wersji."
} }
+3 -1
View File
@@ -414,5 +414,7 @@
"cw_tour_label": "Dicas", "cw_tour_label": "Dicas",
"cw_tour_next": "Próxima", "cw_tour_next": "Próxima",
"cw_tour_skip": "Pular", "cw_tour_skip": "Pular",
"cw_tour_step": "{n} de {total}" "cw_tour_step": "{n} de {total}",
"cw_update_later": "Depois",
"cw_update_ready": "O PS5Upload Files foi atualizado. Recarregue para usar a nova versão."
} }
+3 -1
View File
@@ -414,5 +414,7 @@
"cw_tour_label": "Советы", "cw_tour_label": "Советы",
"cw_tour_next": "Далее", "cw_tour_next": "Далее",
"cw_tour_skip": "Пропустить", "cw_tour_skip": "Пропустить",
"cw_tour_step": "{n} из {total}" "cw_tour_step": "{n} из {total}",
"cw_update_later": "Позже",
"cw_update_ready": "PS5Upload Files обновлён. Перезагрузите страницу, чтобы использовать новую версию."
} }
+3 -1
View File
@@ -414,5 +414,7 @@
"cw_tour_label": "เคล็ดลับ", "cw_tour_label": "เคล็ดลับ",
"cw_tour_next": "ถัดไป", "cw_tour_next": "ถัดไป",
"cw_tour_skip": "ข้าม", "cw_tour_skip": "ข้าม",
"cw_tour_step": "{n} จาก {total}" "cw_tour_step": "{n} จาก {total}",
"cw_update_later": "ภายหลัง",
"cw_update_ready": "PS5Upload Files ได้รับการอัปเดตแล้ว โหลดใหม่เพื่อใช้เวอร์ชันใหม่"
} }
+3 -1
View File
@@ -414,5 +414,7 @@
"cw_tour_label": "İpuçları", "cw_tour_label": "İpuçları",
"cw_tour_next": "İleri", "cw_tour_next": "İleri",
"cw_tour_skip": "Atla", "cw_tour_skip": "Atla",
"cw_tour_step": "{n} / {total}" "cw_tour_step": "{n} / {total}",
"cw_update_later": "Sonra",
"cw_update_ready": "PS5Upload Files güncellendi. Yeni sürümü kullanmak için yeniden yükleyin."
} }
+3 -1
View File
@@ -414,5 +414,7 @@
"cw_tour_label": "Mẹo", "cw_tour_label": "Mẹo",
"cw_tour_next": "Tiếp", "cw_tour_next": "Tiếp",
"cw_tour_skip": "Bỏ qua", "cw_tour_skip": "Bỏ qua",
"cw_tour_step": "{n} / {total}" "cw_tour_step": "{n} / {total}",
"cw_update_later": "Để sau",
"cw_update_ready": "PS5Upload Files đã được cập nhật. Hãy tải lại để dùng phiên bản mới."
} }
+3 -1
View File
@@ -414,5 +414,7 @@
"cw_tour_label": "提示", "cw_tour_label": "提示",
"cw_tour_next": "下一步", "cw_tour_next": "下一步",
"cw_tour_skip": "跳过", "cw_tour_skip": "跳过",
"cw_tour_step": "{n} / {total}" "cw_tour_step": "{n} / {total}",
"cw_update_later": "稍后",
"cw_update_ready": "PS5Upload Files 已更新。请重新加载以使用新版本。"
} }
+3 -1
View File
@@ -414,5 +414,7 @@
"cw_tour_label": "提示", "cw_tour_label": "提示",
"cw_tour_next": "下一步", "cw_tour_next": "下一步",
"cw_tour_skip": "略過", "cw_tour_skip": "略過",
"cw_tour_step": "{n} / {total}" "cw_tour_step": "{n} / {total}",
"cw_update_later": "稍後",
"cw_update_ready": "PS5Upload Files 已更新。請重新載入以使用新版本。"
} }
+27 -5
View File
@@ -3,16 +3,18 @@ import { createRoot } from "react-dom/client";
import "virtual:pc-tokens.css"; import "virtual:pc-tokens.css";
import "./console.css"; import "./console.css";
import { App } from "./App"; import { App } from "./App";
import { probeHelper } from "./api"; import { call, probeHelper } from "./api";
import { installBackHistory } from "./backHistory"; import { installBackHistory } from "./backHistory";
import { installFullscreen } from "./fullscreen"; import { installFullscreen } from "./fullscreen";
import { installMarquee } from "./ui/marquee"; import { installMarquee } from "./ui/marquee";
import { DEFAULT_INPUT_MAP, dispatchNav, installNavigation } from "./nav"; import { DEFAULT_INPUT_MAP, dispatchNav, installNavigation } from "./nav";
import { installOfflineCopy } from "./offline/offlineCopy"; import { installOfflineCopy } from "./offline/offlineCopy";
import { OfflinePage } from "./offline/OfflinePage"; import { OfflinePage } from "./offline/OfflinePage";
import { UpdateBanner } from "./offline/UpdateBanner";
import { isNewerHelper, markUpdateReady } from "./offline/update";
import { ErrorBoundary } from "./ui/ErrorBoundary"; import { ErrorBoundary } from "./ui/ErrorBoundary";
import { applyStoredTheme } from "./theme"; import { applyStoredTheme } from "./theme";
import { getLanguage, onLanguageChange, setLanguage, stringsVersion } from "./strings"; import { getLanguage, onLanguageChange, setLanguage, stringsVersion, t } from "./strings";
applyStoredTheme(); // before the first paint: no flash of the wrong theme applyStoredTheme(); // before the first paint: no flash of the wrong theme
// English is in the page; the detected (or stored) language loads from the helper and the page // English is in the page; the detected (or stored) language loads from the helper and the page
@@ -40,15 +42,35 @@ function Root() {
useEffect(() => { useEffect(() => {
let live = true; let live = true;
void probeHelper().then((ok) => { void probeHelper().then((ok) => {
if (live) setUp(ok); if (!live) return;
setUp(ok);
// An older page than the helper (the browser's offline copy, or the helper updated
// underneath): offer the one-press reload. An older helper's reply is just not compared.
if (ok) {
call<{ version?: unknown }>("/api/settings").then(
(s) => {
if (isNewerHelper(s?.version)) markUpdateReady();
},
() => undefined,
);
}
}); });
return () => { return () => {
live = false; live = false;
}; };
}, []); }, []);
if (up === null) return null; // A moment at most (the token read gives up after 4 s): the name, not a blank screen.
if (up === null)
return (
<div className="cw-splash" aria-busy="true">
{t("cw_settings_app_name")}
</div>
);
return ( return (
<ErrorBoundary>{up ? <App inputMap={DEFAULT_INPUT_MAP} /> : <OfflinePage />}</ErrorBoundary> <ErrorBoundary>
{up ? <App inputMap={DEFAULT_INPUT_MAP} /> : <OfflinePage />}
<UpdateBanner />
</ErrorBoundary>
); );
} }
@@ -0,0 +1,28 @@
import { useEffect, useRef } from "react";
import { RotateCw } from "lucide-react";
import { t } from "../strings";
import { dismissUpdate, useUpdateReady } from "./update";
/** A newer page is waiting (update.ts): one press reloads into it; Later keeps working on this one. */
export function UpdateBanner({ reload = () => window.location.reload() }: { reload?: () => void }) {
const ready = useUpdateReady();
const button = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (ready) button.current?.focus();
}, [ready]);
if (!ready) return null;
return (
<div className="cw-banner" role="alertdialog" aria-labelledby="cw-update-title" data-nav-modal>
<h2 id="cw-update-title">{t("cw_update_ready")}</h2>
<div className="cw-dialog-buttons">
<button type="button" className="cw-focusable cw-button" onClick={dismissUpdate}>
<span>{t("cw_update_later")}</span>
</button>
<button ref={button} type="button" className="cw-focusable cw-button is-primary" onClick={reload}>
<RotateCw aria-hidden />
<span>{t("cw_error_reload")}</span>
</button>
</div>
</div>
);
}
+8 -10
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { APPCACHE_RELOAD_WINDOW_MS, appcacheManifest, installOfflineCopy, serviceWorkerSource } from "./offlineCopy"; import { appcacheManifest, installOfflineCopy, serviceWorkerSource } from "./offlineCopy";
/** The service worker run against a fake worker scope: caches in a Map, a scripted network. */ /** The service worker run against a fake worker scope: caches in a Map, a scripted network. */
function worker(network: (url: string) => Promise<Response>) { function worker(network: (url: string) => Promise<Response>) {
@@ -134,22 +134,20 @@ describe("offline copy: appcache and registration", () => {
it("registers the worker at / in a secure context (the console's 127.0.0.1) only", () => { it("registers the worker at / in a secure context (the console's 127.0.0.1) only", () => {
const a = fakeWindow(true); const a = fakeWindow(true);
installOfflineCopy(a.win as unknown as Window, Date.now()); installOfflineCopy(a.win as unknown as Window, vi.fn());
expect(a.win.navigator.serviceWorker.register).toHaveBeenCalledWith("/sw.js", { scope: "/" }); expect(a.win.navigator.serviceWorker.register).toHaveBeenCalledWith("/sw.js", { scope: "/" });
const b = fakeWindow(false); // a phone at the console's LAN address const b = fakeWindow(false); // a phone at the console's LAN address
installOfflineCopy(b.win as unknown as Window, Date.now()); installOfflineCopy(b.win as unknown as Window, vi.fn());
expect(b.win.navigator.serviceWorker.register).not.toHaveBeenCalled(); expect(b.win.navigator.serviceWorker.register).not.toHaveBeenCalled();
}); });
it("a newer appcache copy reloads right after opening, never later", () => { it("a newer appcache copy offers the reload banner instead of reloading by itself", () => {
const a = fakeWindow(true); const a = fakeWindow(true);
installOfflineCopy(a.win as unknown as Window, Date.now()); const onUpdate = vi.fn();
installOfflineCopy(a.win as unknown as Window, onUpdate);
a.fns.updateready(); a.fns.updateready();
expect(a.win.applicationCache.swapCache).toHaveBeenCalled(); expect(a.win.applicationCache.swapCache).toHaveBeenCalled();
expect(a.win.location.reload).toHaveBeenCalledTimes(1); expect(onUpdate).toHaveBeenCalledTimes(1);
const b = fakeWindow(true); expect(a.win.location.reload).not.toHaveBeenCalled();
installOfflineCopy(b.win as unknown as Window, Date.now() - APPCACHE_RELOAD_WINDOW_MS - 1);
b.fns.updateready();
expect(b.win.location.reload).not.toHaveBeenCalled();
}); });
}); });
+6 -8
View File
@@ -11,8 +11,8 @@
* - the appcache (<html manifest="/cache.appcache">), the fallback the PS5 * - the appcache (<html manifest="/cache.appcache">), the fallback the PS5
* browser honours for Payload Manager: the page is its only cached entry * browser honours for Payload Manager: the page is its only cached entry
* (NETWORK: * lets everything else through). An appcache serves its copy * (NETWORK: * lets everything else through). An appcache serves its copy
* first and updates behind it, so a newer page reloads once on * first and updates behind it, so on "updateready" the page offers a
* "updateready" (only right after the page opened, never mid-task). * one-press reload (UpdateBanner, update.ts), never reloading by itself.
* *
* vite.console.config.ts writes both files next to the page with the page's * vite.console.config.ts writes both files next to the page with the page's
* hash in them (a new build = a new cache), and payload/Makefile embeds them in * hash in them (a new build = a new cache), and payload/Makefile embeds them in
@@ -20,6 +20,8 @@
* main.tsx shows the offline screen (OfflinePage) and reloads once it answers. * main.tsx shows the offline screen (OfflinePage) and reloads once it answers.
*/ */
import { markUpdateReady } from "./update";
/** The service worker. `__CW_SW_V__` is replaced with the page's build hash. */ /** The service worker. `__CW_SW_V__` is replaced with the page's build hash. */
export const SW_SOURCE = `/* PS5Upload Files: offline copy of the page (generated; see offlineCopy.ts) */ export const SW_SOURCE = `/* PS5Upload Files: offline copy of the page (generated; see offlineCopy.ts) */
"use strict"; "use strict";
@@ -82,9 +84,6 @@ export function serviceWorkerSource(version: string): string {
return SW_SOURCE.split("__CW_SW_V__").join(version); return SW_SOURCE.split("__CW_SW_V__").join(version);
} }
/** An appcache update this soon after the page opened reloads into it; later it waits for the next open. */
export const APPCACHE_RELOAD_WINDOW_MS = 20_000;
interface AppCacheLike { interface AppCacheLike {
addEventListener(type: "updateready", fn: () => void): void; addEventListener(type: "updateready", fn: () => void): void;
swapCache?: () => void; swapCache?: () => void;
@@ -95,7 +94,7 @@ interface AppCacheLike {
* console's own browser at 127.0.0.1, not a phone at the console's LAN address) and follow the * console's own browser at 127.0.0.1, not a phone at the console's LAN address) and follow the
* appcache's updates. Never throws; nothing here is needed for the page to work. * appcache's updates. Never throws; nothing here is needed for the page to work.
*/ */
export function installOfflineCopy(win: Window = window, startedAt: number = Date.now()): void { export function installOfflineCopy(win: Window = window, onUpdate: () => void = markUpdateReady): void {
try { try {
const nav = win.navigator as Navigator & { serviceWorker?: ServiceWorkerContainer }; const nav = win.navigator as Navigator & { serviceWorker?: ServiceWorkerContainer };
if (win.isSecureContext && nav.serviceWorker && typeof nav.serviceWorker.register === "function") { if (win.isSecureContext && nav.serviceWorker && typeof nav.serviceWorker.register === "function") {
@@ -108,13 +107,12 @@ export function installOfflineCopy(win: Window = window, startedAt: number = Dat
const ac = (win as unknown as { applicationCache?: AppCacheLike }).applicationCache; const ac = (win as unknown as { applicationCache?: AppCacheLike }).applicationCache;
if (ac && typeof ac.addEventListener === "function") { if (ac && typeof ac.addEventListener === "function") {
ac.addEventListener("updateready", () => { ac.addEventListener("updateready", () => {
if (Date.now() - startedAt > APPCACHE_RELOAD_WINDOW_MS) return;
try { try {
ac.swapCache?.(); ac.swapCache?.();
} catch { } catch {
/* already swapped */ /* already swapped */
} }
win.location.reload(); onUpdate(); // the banner: one press reloads into the new page
}); });
} }
} catch { } catch {
@@ -0,0 +1,28 @@
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it } from "vitest";
import { t } from "../strings";
import { UpdateBanner } from "./UpdateBanner";
import { _resetUpdateForTests, dismissUpdate, isNewerHelper, markUpdateReady } from "./update";
beforeEach(() => _resetUpdateForTests());
describe("updated page banner", () => {
it("a helper version other than the page's means a newer page; unknowns are never compared", () => {
expect(isNewerHelper("7.1.0", "7.0.34")).toBe(true);
expect(isNewerHelper("7.0.34", "7.0.34")).toBe(false);
expect(isNewerHelper(undefined, "7.0.34")).toBe(false); // an older helper's reply
expect(isNewerHelper("", "7.0.34")).toBe(false);
expect(isNewerHelper("7.1.0", "")).toBe(false); // tests / dev build
});
it("shows a one-press Reload (and Later) only once marked, and Later hides it", () => {
expect(renderToStaticMarkup(<UpdateBanner />)).toBe("");
markUpdateReady();
const html = renderToStaticMarkup(<UpdateBanner />);
expect(html).toContain(t("cw_update_ready"));
expect(html).toContain(t("cw_error_reload"));
expect(html).toContain(t("cw_update_later"));
dismissUpdate();
expect(renderToStaticMarkup(<UpdateBanner />)).toBe("");
});
});
+47
View File
@@ -0,0 +1,47 @@
/**
* "PS5Upload Files was updated": the page shown is older than the helper's.
* That happens when the browser served its offline copy (the appcache serves
* its copy first and fetches the new page behind it: "updateready"), or when
* the helper was updated under an open page. Either way the page offers a
* one-press Reload (UpdateBanner) instead of reloading by itself mid-task.
*/
import { useSyncExternalStore } from "react";
/** The version this page was built from ("" under tests: never compared). */
export const PAGE_VERSION: string = typeof __CW_APP_V__ === "string" ? __CW_APP_V__ : "";
let ready = false;
const listeners = new Set<() => void>();
/** A newer page is waiting: show the banner (once). */
export function markUpdateReady(): void {
if (ready) return;
ready = true;
listeners.forEach((fn) => fn());
}
/** The helper's version (/api/settings) against this page's; a difference means a newer page. */
export function isNewerHelper(helperVersion: unknown, page: string = PAGE_VERSION): boolean {
return !!page && typeof helperVersion === "string" && !!helperVersion && helperVersion !== page;
}
/** Later: hide the banner for this page's life. */
export function dismissUpdate(): void {
if (!ready) return;
ready = false;
listeners.forEach((fn) => fn());
}
function subscribe(fn: () => void): () => void {
listeners.add(fn);
return () => {
listeners.delete(fn);
};
}
export const useUpdateReady = (): boolean => useSyncExternalStore(subscribe, () => ready, () => ready);
/** Tests. */
export function _resetUpdateForTests(): void {
ready = false;
}
+8 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { phoneUrl } from "./PhoneHandoff"; import { onConsoleBrowser, phoneUrl } from "./PhoneHandoff";
describe("continue on your phone", () => { describe("continue on your phone", () => {
it("is this page on the console's LAN address while LAN access is on", () => { it("is this page on the console's LAN address while LAN access is on", () => {
@@ -15,4 +15,11 @@ describe("continue on your phone", () => {
expect(phoneUrl({ lan: true, addr: "evil.example/x?" }, "9180")).toBeNull(); expect(phoneUrl({ lan: true, addr: "evil.example/x?" }, "9180")).toBeNull();
expect(phoneUrl(null, "9180")).toBeNull(); expect(phoneUrl(null, "9180")).toBeNull();
}); });
it("is offered only in the console's own browser, not on a phone or PC already", () => {
expect(onConsoleBrowser("127.0.0.1")).toBe(true);
expect(onConsoleBrowser("localhost")).toBe(true);
expect(onConsoleBrowser("192.168.86.99")).toBe(false);
expect(onConsoleBrowser("")).toBe(false);
});
}); });
+13 -5
View File
@@ -17,6 +17,11 @@ export function phoneUrl(info: LanInfo | null, port: string): string | null {
return `http://${info.addr}:${p}/`; return `http://${info.addr}:${p}/`;
} }
/** The console's own browser (the page at 127.0.0.1): a phone or PC already has a keyboard. */
export function onConsoleBrowser(host: string = typeof location !== "undefined" ? location.hostname : ""): boolean {
return host === "127.0.0.1" || host === "localhost";
}
/** Read once per page and kept briefly: every text dialog asks, the answer rarely changes. */ /** Read once per page and kept briefly: every text dialog asks, the answer rarely changes. */
const KEEP_MS = 30_000; const KEEP_MS = 30_000;
let cached: { at: number; url: string | null } | null = null; let cached: { at: number; url: string | null } | null = null;
@@ -53,11 +58,14 @@ export function forgetPhoneUrl(): void {
* "Continue on your phone": in dialogs where the user types (rename, bulk * "Continue on your phone": in dialogs where the user types (rename, bulk
* rename, go to path), when LAN access is on, a QR code and the address of this * rename, go to path), when LAN access is on, a QR code and the address of this
* page on the console's IP, so the typing can happen on a phone. Nothing shows * page on the console's IP, so the typing can happen on a phone. Nothing shows
* while LAN access is off (the address would not answer). * while LAN access is off (the address would not answer), nor when the page is
* already open on a phone or PC (not at 127.0.0.1).
*/ */
export function PhoneHandoff() { export function PhoneHandoff({ host }: { host?: string } = {}) {
const [url, setUrl] = useState<string | null>(cached?.url ?? null); const here = onConsoleBrowser(host);
const [url, setUrl] = useState<string | null>(here ? (cached?.url ?? null) : null);
useEffect(() => { useEffect(() => {
if (!here) return;
let live = true; let live = true;
void loadPhoneUrl().then((u) => { void loadPhoneUrl().then((u) => {
if (live) setUrl(u); if (live) setUrl(u);
@@ -65,7 +73,7 @@ export function PhoneHandoff() {
return () => { return () => {
live = false; live = false;
}; };
}, []); }, [here]);
const path = useMemo(() => { const path = useMemo(() => {
if (!url) return null; if (!url) return null;
try { try {
@@ -75,7 +83,7 @@ export function PhoneHandoff() {
return null; return null;
} }
}, [url]); }, [url]);
if (!url || !path) return null; if (!here || !url || !path) return null;
return ( return (
<div className="cw-phone" data-phone=""> <div className="cw-phone" data-phone="">
<svg viewBox={`0 0 ${path.n} ${path.n}`} role="img" aria-label={t("cw_phone_qr_label")} shapeRendering="crispEdges"> <svg viewBox={`0 0 ${path.n} ${path.n}`} role="img" aria-label={t("cw_phone_qr_label")} shapeRendering="crispEdges">
+2
View File
@@ -4074,6 +4074,8 @@ c2c_test_deep_bad: "لم يصل الإرسال التجريبي بحجم 4 KiB:
"cw_phone_qr_label": "رمز QR لعنوان هذه الصفحة", "cw_phone_qr_label": "رمز QR لعنوان هذه الصفحة",
"cw_files_fixperm_many": "إصلاح أذونات {n} عناصر", "cw_files_fixperm_many": "إصلاح أذونات {n} عناصر",
"cw_cheats_err_busy": "يغيّر تطبيق آخر غشّ هذه اللعبة الآن. حاول مرة أخرى بعد قليل.", "cw_cheats_err_busy": "يغيّر تطبيق آخر غشّ هذه اللعبة الآن. حاول مرة أخرى بعد قليل.",
"cw_update_ready": "تم تحديث PS5Upload Files. أعد التحميل لاستخدام الإصدار الجديد.",
"cw_update_later": "لاحقًا",
"cw_hints_label": "أزرار وحدة التحكم", "cw_hints_label": "أزرار وحدة التحكم",
"cw_task_from_console": "من هذا الجهاز", "cw_task_from_console": "من هذا الجهاز",
"cw_task_from_pc": "من حاسوب", "cw_task_from_pc": "من حاسوب",
+2
View File
@@ -4074,6 +4074,8 @@ c2c_test_deep_bad: "4 KiB-এর পরীক্ষামূলক পাঠা
"cw_phone_qr_label": "এই পাতার ঠিকানার QR কোড", "cw_phone_qr_label": "এই পাতার ঠিকানার QR কোড",
"cw_files_fixperm_many": "{n}টি আইটেমের অনুমতি ঠিক করুন", "cw_files_fixperm_many": "{n}টি আইটেমের অনুমতি ঠিক করুন",
"cw_cheats_err_busy": "অন্য একটি অ্যাপ এখন এই গেমের চিট বদলাচ্ছে। একটু পরে আবার চেষ্টা করুন।", "cw_cheats_err_busy": "অন্য একটি অ্যাপ এখন এই গেমের চিট বদলাচ্ছে। একটু পরে আবার চেষ্টা করুন।",
"cw_update_ready": "PS5Upload Files আপডেট হয়েছে। নতুন সংস্করণ ব্যবহার করতে আবার লোড করুন।",
"cw_update_later": "পরে",
"cw_hints_label": "কন্ট্রোলারের বোতাম", "cw_hints_label": "কন্ট্রোলারের বোতাম",
"cw_task_from_console": "এই কনসোল থেকে", "cw_task_from_console": "এই কনসোল থেকে",
"cw_task_from_pc": "PC থেকে", "cw_task_from_pc": "PC থেকে",
+2
View File
@@ -4082,6 +4082,8 @@ c2c_test_deep_bad: "Die 4-KiB-Testsendung ist nicht angekommen: {detail}",
"cw_phone_qr_label": "QR-Code der Adresse dieser Seite", "cw_phone_qr_label": "QR-Code der Adresse dieser Seite",
"cw_files_fixperm_many": "Berechtigungen von {n} Elementen reparieren", "cw_files_fixperm_many": "Berechtigungen von {n} Elementen reparieren",
"cw_cheats_err_busy": "Eine andere App ändert gerade die Cheats dieses Spiels. Versuche es gleich noch einmal.", "cw_cheats_err_busy": "Eine andere App ändert gerade die Cheats dieses Spiels. Versuche es gleich noch einmal.",
"cw_update_ready": "PS5Upload Files wurde aktualisiert. Lade neu, um die neue Version zu verwenden.",
"cw_update_later": "Später",
"cw_hints_label": "Controller-Tasten", "cw_hints_label": "Controller-Tasten",
"cw_task_from_console": "Von dieser Konsole", "cw_task_from_console": "Von dieser Konsole",
"cw_task_from_pc": "Von einem PC", "cw_task_from_pc": "Von einem PC",
+2
View File
@@ -4397,6 +4397,8 @@ c2c_test_deep_bad: "The 4 KiB test send did not arrive: {detail}",
"cw_phone_qr_label": "QR code of this page's address", "cw_phone_qr_label": "QR code of this page's address",
"cw_files_fixperm_many": "Fix permissions of {n} items", "cw_files_fixperm_many": "Fix permissions of {n} items",
"cw_cheats_err_busy": "Another app is changing this game's cheats right now. Try again in a moment.", "cw_cheats_err_busy": "Another app is changing this game's cheats right now. Try again in a moment.",
"cw_update_ready": "PS5Upload Files was updated. Reload to use the new version.",
"cw_update_later": "Later",
"cw_hints_label": "Controller buttons", "cw_hints_label": "Controller buttons",
"cw_task_from_console": "From this console", "cw_task_from_console": "From this console",
"cw_task_from_pc": "From a PC", "cw_task_from_pc": "From a PC",
+2
View File
@@ -4024,6 +4024,8 @@ c2c_test_deep_bad: "El envío de prueba de 4 KiB no llegó: {detail}",
"cw_phone_qr_label": "Código QR de la dirección de esta página", "cw_phone_qr_label": "Código QR de la dirección de esta página",
"cw_files_fixperm_many": "Corregir permisos de {n} elementos", "cw_files_fixperm_many": "Corregir permisos de {n} elementos",
"cw_cheats_err_busy": "Otra app está cambiando los trucos de este juego ahora mismo. Inténtalo de nuevo en un momento.", "cw_cheats_err_busy": "Otra app está cambiando los trucos de este juego ahora mismo. Inténtalo de nuevo en un momento.",
"cw_update_ready": "PS5Upload Files se actualizó. Recarga para usar la nueva versión.",
"cw_update_later": "Más tarde",
"cw_hints_label": "Botones del mando", "cw_hints_label": "Botones del mando",
"cw_task_from_console": "Desde esta consola", "cw_task_from_console": "Desde esta consola",
"cw_task_from_pc": "Desde un PC", "cw_task_from_pc": "Desde un PC",
+2
View File
@@ -4375,6 +4375,8 @@ c2c_test_deep_bad: "ارسال آزمایشی ۴ KiB نرسید: {detail}",
"cw_phone_qr_label": "کد QR نشانی این صفحه", "cw_phone_qr_label": "کد QR نشانی این صفحه",
"cw_files_fixperm_many": "اصلاح مجوزهای {n} مورد", "cw_files_fixperm_many": "اصلاح مجوزهای {n} مورد",
"cw_cheats_err_busy": "برنامهٔ دیگری همین حالا تقلب‌های این بازی را تغییر می‌دهد. کمی بعد دوباره امتحان کنید.", "cw_cheats_err_busy": "برنامهٔ دیگری همین حالا تقلب‌های این بازی را تغییر می‌دهد. کمی بعد دوباره امتحان کنید.",
"cw_update_ready": "PS5Upload Files به‌روزرسانی شد. برای استفاده از نسخهٔ جدید، دوباره بارگیری کنید.",
"cw_update_later": "بعداً",
"cw_hints_label": "دکمه‌های دسته", "cw_hints_label": "دکمه‌های دسته",
"cw_task_from_console": "از این کنسول", "cw_task_from_console": "از این کنسول",
"cw_task_from_pc": "از رایانه", "cw_task_from_pc": "از رایانه",
+2
View File
@@ -4092,6 +4092,8 @@ c2c_test_deep_bad: "L’envoi test de 4 Kio n’est pas arrivé : {detail}",
"cw_phone_qr_label": "Code QR de l'adresse de cette page", "cw_phone_qr_label": "Code QR de l'adresse de cette page",
"cw_files_fixperm_many": "Corriger les autorisations de {n} éléments", "cw_files_fixperm_many": "Corriger les autorisations de {n} éléments",
"cw_cheats_err_busy": "Une autre app modifie les codes de triche de ce jeu en ce moment. Réessayez dans un instant.", "cw_cheats_err_busy": "Une autre app modifie les codes de triche de ce jeu en ce moment. Réessayez dans un instant.",
"cw_update_ready": "PS5Upload Files a été mis à jour. Rechargez pour utiliser la nouvelle version.",
"cw_update_later": "Plus tard",
"cw_hints_label": "Boutons de la manette", "cw_hints_label": "Boutons de la manette",
"cw_task_from_console": "Depuis cette console", "cw_task_from_console": "Depuis cette console",
"cw_task_from_pc": "Depuis un PC", "cw_task_from_pc": "Depuis un PC",
+2
View File
@@ -4090,6 +4090,8 @@ c2c_test_deep_bad: "4 KiB का परीक्षण नहीं पहुँ
"cw_phone_qr_label": "इस पेज के पते का QR कोड", "cw_phone_qr_label": "इस पेज के पते का QR कोड",
"cw_files_fixperm_many": "{n} आइटम की अनुमतियाँ ठीक करें", "cw_files_fixperm_many": "{n} आइटम की अनुमतियाँ ठीक करें",
"cw_cheats_err_busy": "कोई दूसरा ऐप अभी इस गेम के चीट बदल रहा है। थोड़ी देर में फिर से कोशिश करें।", "cw_cheats_err_busy": "कोई दूसरा ऐप अभी इस गेम के चीट बदल रहा है। थोड़ी देर में फिर से कोशिश करें।",
"cw_update_ready": "PS5Upload Files अपडेट हो गया है। नया संस्करण इस्तेमाल करने के लिए फिर से लोड करें।",
"cw_update_later": "बाद में",
"cw_hints_label": "कंट्रोलर के बटन", "cw_hints_label": "कंट्रोलर के बटन",
"cw_task_from_console": "इस कंसोल से", "cw_task_from_console": "इस कंसोल से",
"cw_task_from_pc": "PC से", "cw_task_from_pc": "PC से",
+2
View File
@@ -4389,6 +4389,8 @@ c2c_test_deep_bad: "A 4 KiB-os tesztküldés nem érkezett meg: {detail}",
"cw_phone_qr_label": "Az oldal címének QR-kódja", "cw_phone_qr_label": "Az oldal címének QR-kódja",
"cw_files_fixperm_many": "{n} elem jogosultságainak javítása", "cw_files_fixperm_many": "{n} elem jogosultságainak javítása",
"cw_cheats_err_busy": "Egy másik alkalmazás éppen most módosítja a játék csaláskódjait. Próbáld újra egy kicsit később.", "cw_cheats_err_busy": "Egy másik alkalmazás éppen most módosítja a játék csaláskódjait. Próbáld újra egy kicsit később.",
"cw_update_ready": "A PS5Upload Files frissült. Töltsd újra az új verzió használatához.",
"cw_update_later": "Később",
"cw_hints_label": "Kontroller gombjai", "cw_hints_label": "Kontroller gombjai",
"cw_task_from_console": "Erről a konzolról", "cw_task_from_console": "Erről a konzolról",
"cw_task_from_pc": "Egy PC-ről", "cw_task_from_pc": "Egy PC-ről",
+2
View File
@@ -4083,6 +4083,8 @@ c2c_test_deep_bad: "Kiriman uji 4 KiB tidak tiba: {detail}",
"cw_phone_qr_label": "Kode QR alamat halaman ini", "cw_phone_qr_label": "Kode QR alamat halaman ini",
"cw_files_fixperm_many": "Perbaiki izin {n} item", "cw_files_fixperm_many": "Perbaiki izin {n} item",
"cw_cheats_err_busy": "Aplikasi lain sedang mengubah cheat game ini sekarang. Coba lagi sebentar lagi.", "cw_cheats_err_busy": "Aplikasi lain sedang mengubah cheat game ini sekarang. Coba lagi sebentar lagi.",
"cw_update_ready": "PS5Upload Files telah diperbarui. Muat ulang untuk memakai versi baru.",
"cw_update_later": "Nanti",
"cw_hints_label": "Tombol kontroler", "cw_hints_label": "Tombol kontroler",
"cw_task_from_console": "Dari konsol ini", "cw_task_from_console": "Dari konsol ini",
"cw_task_from_pc": "Dari PC", "cw_task_from_pc": "Dari PC",
+2
View File
@@ -4024,6 +4024,8 @@ c2c_test_deep_bad: "L’invio di prova di 4 KiB non è arrivato: {detail}",
"cw_phone_qr_label": "Codice QR dell'indirizzo di questa pagina", "cw_phone_qr_label": "Codice QR dell'indirizzo di questa pagina",
"cw_files_fixperm_many": "Correggi i permessi di {n} elementi", "cw_files_fixperm_many": "Correggi i permessi di {n} elementi",
"cw_cheats_err_busy": "Un'altra app sta cambiando i trucchi di questo gioco proprio ora. Riprova tra un attimo.", "cw_cheats_err_busy": "Un'altra app sta cambiando i trucchi di questo gioco proprio ora. Riprova tra un attimo.",
"cw_update_ready": "PS5Upload Files è stato aggiornato. Ricarica per usare la nuova versione.",
"cw_update_later": "Più tardi",
"cw_hints_label": "Tasti del controller", "cw_hints_label": "Tasti del controller",
"cw_task_from_console": "Da questa console", "cw_task_from_console": "Da questa console",
"cw_task_from_pc": "Da un PC", "cw_task_from_pc": "Da un PC",
+2
View File
@@ -4095,6 +4095,8 @@ c2c_test_deep_bad: "4 KiB のテスト送信が届きませんでした: {detail
"cw_phone_qr_label": "このページのアドレスの QR コード", "cw_phone_qr_label": "このページのアドレスの QR コード",
"cw_files_fixperm_many": "{n} 件の権限を修正", "cw_files_fixperm_many": "{n} 件の権限を修正",
"cw_cheats_err_busy": "別のアプリが今このゲームのチートを変更しています。少し待ってからもう一度お試しください。", "cw_cheats_err_busy": "別のアプリが今このゲームのチートを変更しています。少し待ってからもう一度お試しください。",
"cw_update_ready": "PS5Upload Files が更新されました。新しいバージョンを使うには再読み込みしてください。",
"cw_update_later": "後で",
"cw_hints_label": "コントローラーのボタン", "cw_hints_label": "コントローラーのボタン",
"cw_task_from_console": "このコンソールから", "cw_task_from_console": "このコンソールから",
"cw_task_from_pc": "PCから", "cw_task_from_pc": "PCから",
+2
View File
@@ -4093,6 +4093,8 @@ c2c_test_deep_bad: "4 KiB 테스트 전송이 도착하지 않았습니다: {det
"cw_phone_qr_label": "이 페이지 주소의 QR 코드", "cw_phone_qr_label": "이 페이지 주소의 QR 코드",
"cw_files_fixperm_many": "항목 {n}개의 권한 수정", "cw_files_fixperm_many": "항목 {n}개의 권한 수정",
"cw_cheats_err_busy": "다른 앱이 지금 이 게임의 치트를 바꾸고 있습니다. 잠시 후 다시 시도하세요.", "cw_cheats_err_busy": "다른 앱이 지금 이 게임의 치트를 바꾸고 있습니다. 잠시 후 다시 시도하세요.",
"cw_update_ready": "PS5Upload Files가 업데이트되었습니다. 새 버전을 사용하려면 다시 불러오세요.",
"cw_update_later": "나중에",
"cw_hints_label": "컨트롤러 버튼", "cw_hints_label": "컨트롤러 버튼",
"cw_task_from_console": "이 콘솔에서", "cw_task_from_console": "이 콘솔에서",
"cw_task_from_pc": "PC에서", "cw_task_from_pc": "PC에서",
+2
View File
@@ -4329,6 +4329,8 @@ c2c_test_deep_bad: "Testowa wysyłka 4 KiB nie dotarła: {detail}",
"cw_phone_qr_label": "Kod QR adresu tej strony", "cw_phone_qr_label": "Kod QR adresu tej strony",
"cw_files_fixperm_many": "Napraw uprawnienia {n} elementów", "cw_files_fixperm_many": "Napraw uprawnienia {n} elementów",
"cw_cheats_err_busy": "Inna aplikacja właśnie zmienia kody tej gry. Spróbuj ponownie za chwilę.", "cw_cheats_err_busy": "Inna aplikacja właśnie zmienia kody tej gry. Spróbuj ponownie za chwilę.",
"cw_update_ready": "PS5Upload Files został zaktualizowany. Wczytaj ponownie, aby użyć nowej wersji.",
"cw_update_later": "Później",
"cw_hints_label": "Przyciski kontrolera", "cw_hints_label": "Przyciski kontrolera",
"cw_task_from_console": "Z tej konsoli", "cw_task_from_console": "Z tej konsoli",
"cw_task_from_pc": "Z komputera", "cw_task_from_pc": "Z komputera",
+2
View File
@@ -4091,6 +4091,8 @@ c2c_test_deep_bad: "O envio de teste de 4 KiB não chegou: {detail}",
"cw_phone_qr_label": "Código QR do endereço desta página", "cw_phone_qr_label": "Código QR do endereço desta página",
"cw_files_fixperm_many": "Corrigir permissões de {n} itens", "cw_files_fixperm_many": "Corrigir permissões de {n} itens",
"cw_cheats_err_busy": "Outro app está alterando as trapaças deste jogo agora. Tente de novo em um instante.", "cw_cheats_err_busy": "Outro app está alterando as trapaças deste jogo agora. Tente de novo em um instante.",
"cw_update_ready": "O PS5Upload Files foi atualizado. Recarregue para usar a nova versão.",
"cw_update_later": "Depois",
"cw_hints_label": "Botões do controle", "cw_hints_label": "Botões do controle",
"cw_task_from_console": "Deste console", "cw_task_from_console": "Deste console",
"cw_task_from_pc": "De um PC", "cw_task_from_pc": "De um PC",
+2
View File
@@ -4024,6 +4024,8 @@ c2c_test_deep_bad: "Тестовая отправка 4 КиБ не дошла:
"cw_phone_qr_label": "QR-код адреса этой страницы", "cw_phone_qr_label": "QR-код адреса этой страницы",
"cw_files_fixperm_many": "Исправить права у {n} элементов", "cw_files_fixperm_many": "Исправить права у {n} элементов",
"cw_cheats_err_busy": "Другое приложение прямо сейчас меняет читы этой игры. Попробуйте ещё раз через пару секунд.", "cw_cheats_err_busy": "Другое приложение прямо сейчас меняет читы этой игры. Попробуйте ещё раз через пару секунд.",
"cw_update_ready": "PS5Upload Files обновлён. Перезагрузите страницу, чтобы использовать новую версию.",
"cw_update_later": "Позже",
"cw_hints_label": "Кнопки геймпада", "cw_hints_label": "Кнопки геймпада",
"cw_task_from_console": "С этой консоли", "cw_task_from_console": "С этой консоли",
"cw_task_from_pc": "С ПК", "cw_task_from_pc": "С ПК",
+2
View File
@@ -4083,6 +4083,8 @@ c2c_test_deep_bad: "การส่งทดสอบ 4 KiB ไปไม่ถ
"cw_phone_qr_label": "คิวอาร์โค้ดของที่อยู่หน้านี้", "cw_phone_qr_label": "คิวอาร์โค้ดของที่อยู่หน้านี้",
"cw_files_fixperm_many": "แก้ไขสิทธิ์ของ {n} รายการ", "cw_files_fixperm_many": "แก้ไขสิทธิ์ของ {n} รายการ",
"cw_cheats_err_busy": "มีแอปอื่นกำลังเปลี่ยนสูตรโกงของเกมนี้อยู่ ลองอีกครั้งในอีกสักครู่", "cw_cheats_err_busy": "มีแอปอื่นกำลังเปลี่ยนสูตรโกงของเกมนี้อยู่ ลองอีกครั้งในอีกสักครู่",
"cw_update_ready": "PS5Upload Files ได้รับการอัปเดตแล้ว โหลดใหม่เพื่อใช้เวอร์ชันใหม่",
"cw_update_later": "ภายหลัง",
"cw_hints_label": "ปุ่มคอนโทรลเลอร์", "cw_hints_label": "ปุ่มคอนโทรลเลอร์",
"cw_task_from_console": "จากคอนโซลนี้", "cw_task_from_console": "จากคอนโซลนี้",
"cw_task_from_pc": "จาก PC", "cw_task_from_pc": "จาก PC",
+2
View File
@@ -4081,6 +4081,8 @@ c2c_test_deep_bad: "4 KiB'lik test gönderimi ulaşmadı: {detail}",
"cw_phone_qr_label": "Bu sayfanın adresinin QR kodu", "cw_phone_qr_label": "Bu sayfanın adresinin QR kodu",
"cw_files_fixperm_many": "{n} öğenin izinlerini düzelt", "cw_files_fixperm_many": "{n} öğenin izinlerini düzelt",
"cw_cheats_err_busy": "Başka bir uygulama şu anda bu oyunun hilelerini değiştiriyor. Birazdan yeniden deneyin.", "cw_cheats_err_busy": "Başka bir uygulama şu anda bu oyunun hilelerini değiştiriyor. Birazdan yeniden deneyin.",
"cw_update_ready": "PS5Upload Files güncellendi. Yeni sürümü kullanmak için yeniden yükleyin.",
"cw_update_later": "Sonra",
"cw_hints_label": "Kumanda düğmeleri", "cw_hints_label": "Kumanda düğmeleri",
"cw_task_from_console": "Bu konsoldan", "cw_task_from_console": "Bu konsoldan",
"cw_task_from_pc": "Bir PC'den", "cw_task_from_pc": "Bir PC'den",
+2
View File
@@ -4076,6 +4076,8 @@ c2c_test_deep_bad: "Lượt gửi thử 4 KiB không tới: {detail}",
"cw_phone_qr_label": "Mã QR địa chỉ của trang này", "cw_phone_qr_label": "Mã QR địa chỉ của trang này",
"cw_files_fixperm_many": "Sửa quyền của {n} mục", "cw_files_fixperm_many": "Sửa quyền của {n} mục",
"cw_cheats_err_busy": "Một ứng dụng khác đang thay đổi cheat của trò chơi này. Hãy thử lại sau giây lát.", "cw_cheats_err_busy": "Một ứng dụng khác đang thay đổi cheat của trò chơi này. Hãy thử lại sau giây lát.",
"cw_update_ready": "PS5Upload Files đã được cập nhật. Hãy tải lại để dùng phiên bản mới.",
"cw_update_later": "Để sau",
"cw_hints_label": "Nút tay cầm", "cw_hints_label": "Nút tay cầm",
"cw_task_from_console": "Từ máy này", "cw_task_from_console": "Từ máy này",
"cw_task_from_pc": "Từ PC", "cw_task_from_pc": "Từ PC",
+2
View File
@@ -4030,6 +4030,8 @@ c2c_test_deep_bad: "4 KiB 测试发送未到达:{detail}",
"cw_phone_qr_label": "此页面地址的二维码", "cw_phone_qr_label": "此页面地址的二维码",
"cw_files_fixperm_many": "修复 {n} 项的权限", "cw_files_fixperm_many": "修复 {n} 项的权限",
"cw_cheats_err_busy": "另一个应用正在更改此游戏的金手指。请稍后再试。", "cw_cheats_err_busy": "另一个应用正在更改此游戏的金手指。请稍后再试。",
"cw_update_ready": "PS5Upload Files 已更新。请重新加载以使用新版本。",
"cw_update_later": "稍后",
"cw_hints_label": "手柄按键", "cw_hints_label": "手柄按键",
"cw_task_from_console": "来自本主机", "cw_task_from_console": "来自本主机",
"cw_task_from_pc": "来自电脑", "cw_task_from_pc": "来自电脑",
+2
View File
@@ -4024,6 +4024,8 @@ c2c_test_deep_bad: "4 KiB 測試傳送未抵達:{detail}",
"cw_phone_qr_label": "此頁面位址的 QR 碼", "cw_phone_qr_label": "此頁面位址的 QR 碼",
"cw_files_fixperm_many": "修正 {n} 個項目的權限", "cw_files_fixperm_many": "修正 {n} 個項目的權限",
"cw_cheats_err_busy": "另一個應用程式正在變更此遊戲的金手指。請稍後再試。", "cw_cheats_err_busy": "另一個應用程式正在變更此遊戲的金手指。請稍後再試。",
"cw_update_ready": "PS5Upload Files 已更新。請重新載入以使用新版本。",
"cw_update_later": "稍後",
"cw_hints_label": "控制器按鍵", "cw_hints_label": "控制器按鍵",
"cw_task_from_console": "來自本主機", "cw_task_from_console": "來自本主機",
"cw_task_from_pc": "來自電腦", "cw_task_from_pc": "來自電腦",
+4
View File
@@ -202,7 +202,11 @@ function escapeRe(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
/** The app version the page was built with (package.json; the helper's /api/settings version is the same). */
const APP_VERSION = (JSON.parse(readFileSync(fileURLToPath(new URL("./package.json", import.meta.url)), "utf8")) as { version: string }).version;
export default defineConfig({ export default defineConfig({
define: { __CW_APP_V__: JSON.stringify(APP_VERSION) },
root: fileURLToPath(new URL("./src/console", import.meta.url)), root: fileURLToPath(new URL("./src/console", import.meta.url)),
base: "./", base: "./",
publicDir: false, publicDir: false,
+6 -3
View File
@@ -1597,16 +1597,19 @@ int cheats_toggle(const char *title_id, int mod_index, int turn_on,
* webui_cheats_toggle_error matches these texts; keep them in step) says each differently: * webui_cheats_toggle_error matches these texts; keep them in step) says each differently:
* - the game exited a moment ago (the 2 s running-game cache still had its pid): the same * - the game exited a moment ago (the 2 s running-game cache still had its pid): the same
* text as no game at all, and the cache is dropped so the next status read sees it gone; * text as no game at all, and the cache is dropped so the next status read sees it gone;
* - the game is alive but another debugger is attached (the PC app toggling at the same * - the game is alive but another debugger is attached (EBUSY: the PC app toggling at the
* moment, or another cheat tool): "is attached to the game". */ * same moment, or another cheat tool): "is attached to the game";
* - anything else: the plain "pt_attach failed (errno=N)". */
if (pt_attach(pid) != 0) { if (pt_attach(pid) != 0) {
int e = errno; int e = errno;
free(target_cf); free(target_cf);
invalidate_rg_cache(); invalidate_rg_cache();
if (kill(pid, 0) != 0 && errno == ESRCH) { if (kill(pid, 0) != 0 && errno == ESRCH) {
if (err) snprintf(err, err_cap, "no game is currently running"); if (err) snprintf(err, err_cap, "no game is currently running");
} else if (e == EBUSY) {
if (err) snprintf(err, err_cap, "pt_attach failed: another debugger or cheat tool is attached to the game (errno=%d)", e);
} else if (err) { } else if (err) {
snprintf(err, err_cap, "pt_attach failed: another debugger or cheat tool is attached to the game (errno=%d)", e); snprintf(err, err_cap, "pt_attach failed (errno=%d)", e);
} }
return -1; return -1;
} }
+32 -16
View File
@@ -138,11 +138,13 @@ static int thread_start(pthread_t *t, void *(*fn)(void *), void *arg) {
return rc == 0 ? 0 : -1; return rc == 0 ? 0 : -1;
} }
/* The console's IPv4 addresses, for the LAN Host check. */ /* The console's IPv4 addresses, for the LAN Host check; -1 when they could not be read
static size_t own_addrs(char out[WEBUI_MAX_ADDRS][INET_ADDRSTRLEN]) { * (getifaddrs failed: the caller keeps the list it has, so a passing failure never looks like an
* address change). */
static int own_addrs(char out[WEBUI_MAX_ADDRS][INET_ADDRSTRLEN]) {
struct ifaddrs *ifa = NULL, *p; struct ifaddrs *ifa = NULL, *p;
size_t n = 0; int n = 0;
if (getifaddrs(&ifa) != 0) return 0; if (getifaddrs(&ifa) != 0) return -1;
for (p = ifa; p && n < WEBUI_MAX_ADDRS; p = p->ifa_next) { for (p = ifa; p && n < WEBUI_MAX_ADDRS; p = p->ifa_next) {
if (!p->ifa_addr || p->ifa_addr->sa_family != AF_INET) continue; if (!p->ifa_addr || p->ifa_addr->sa_family != AF_INET) continue;
if (!inet_ntop(AF_INET, &((struct sockaddr_in *)(void *)p->ifa_addr)->sin_addr, if (!inet_ntop(AF_INET, &((struct sockaddr_in *)(void *)p->ifa_addr)->sin_addr,
@@ -484,7 +486,7 @@ static void watch_listener(srv_t *s, uint64_t *mono_prev, int64_t *real_prev) {
int64_t real = real_ms(); int64_t real = real_ms();
int resumed, lan, changed = 0; int resumed, lan, changed = 0;
char addrs[WEBUI_MAX_ADDRS][INET_ADDRSTRLEN]; char addrs[WEBUI_MAX_ADDRS][INET_ADDRSTRLEN];
size_t n = 0; int n = -1;
resumed = webui_watch_resumed(*mono_prev, mono, *real_prev, real); resumed = webui_watch_resumed(*mono_prev, mono, *real_prev, real);
*mono_prev = mono; *mono_prev = mono;
*real_prev = real; *real_prev = real;
@@ -493,16 +495,17 @@ static void watch_listener(srv_t *s, uint64_t *mono_prev, int64_t *real_prev) {
pthread_mutex_unlock(&s->mu); pthread_mutex_unlock(&s->mu);
if (lan) n = own_addrs(addrs); if (lan) n = own_addrs(addrs);
pthread_mutex_lock(&s->mu); pthread_mutex_lock(&s->mu);
if (lan && s->lan && webui_addrs_differ((const char (*)[INET_ADDRSTRLEN])s->addrs, s->n_addrs, if (lan && s->lan && n >= 0 &&
(const char (*)[INET_ADDRSTRLEN])addrs, n)) { webui_addrs_differ((const char (*)[INET_ADDRSTRLEN])s->addrs, s->n_addrs,
(const char (*)[INET_ADDRSTRLEN])addrs, (size_t)n)) {
memcpy(s->addrs, addrs, sizeof addrs); memcpy(s->addrs, addrs, sizeof addrs);
s->n_addrs = n; s->n_addrs = (size_t)n;
changed = 1; changed = 1;
} }
if ((resumed || changed) && s->listen_fd >= 0) drop_listener_locked(s); if ((resumed || changed) && s->listen_fd >= 0) drop_listener_locked(s);
pthread_mutex_unlock(&s->mu); pthread_mutex_unlock(&s->mu);
if (resumed) fprintf(stderr, "webui: resumed (clock gap); rebinding port %u\n", (unsigned)s->port); if (resumed) fprintf(stderr, "webui: resumed (clock gap); rebinding port %u\n", (unsigned)s->port);
if (changed) fprintf(stderr, "webui: console addresses changed (%u now); rebinding port %u\n", (unsigned)n, (unsigned)s->port); if (changed) fprintf(stderr, "webui: console addresses changed (%d now); rebinding port %u\n", n, (unsigned)s->port);
} }
static void *accept_main(void *arg) { static void *accept_main(void *arg) {
@@ -592,11 +595,16 @@ static void *accept_main(void *arg) {
/* Never leave the loop on an accept error (a stray errno once killed a helper's /* Never leave the loop on an accept error (a stray errno once killed a helper's
* server); back off briefly on the ones that would otherwise spin, and rebind when they * server); back off briefly on the ones that would otherwise spin, and rebind when they
* go on for WEBUI_BROKEN_MS. */ * go on for WEBUI_BROKEN_MS. */
if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR && errno != ECONNABORTED) { int e = errno;
if (e == EMFILE || e == ENFILE || e == ENOBUFS || e == ENOMEM) {
/* Out of descriptors or buffers: the listener is fine, the process or system is
* busy. Back off; a rebind would not help and must not be counted toward one. */
nap_ms(50);
} else if (e != EAGAIN && e != EWOULDBLOCK && e != EINTR && e != ECONNABORTED) {
if (!err_since) err_since = mono_ms(); if (!err_since) err_since = mono_ms();
else if (webui_watch_broken(err_since, mono_ms())) { else if (webui_watch_broken(err_since, mono_ms())) {
fprintf(stderr, "webui: accept on port %u failing for %u ms (errno %d); rebinding\n", fprintf(stderr, "webui: accept on port %u failing for %u ms (errno %d); rebinding\n",
(unsigned)s->port, (unsigned)WEBUI_BROKEN_MS, errno); (unsigned)s->port, (unsigned)WEBUI_BROKEN_MS, e);
pthread_mutex_lock(&s->mu); pthread_mutex_lock(&s->mu);
if (s->listen_fd == fd) drop_listener_locked(s); if (s->listen_fd == fd) drop_listener_locked(s);
pthread_mutex_unlock(&s->mu); pthread_mutex_unlock(&s->mu);
@@ -714,7 +722,10 @@ int webui_start(const webui_cfg_t *cfg) {
s->route = cfg->route; s->route = cfg->route;
s->lan = cfg->lan ? 1 : 0; s->lan = cfg->lan ? 1 : 0;
webui_token_new(s->token); webui_token_new(s->token);
if (s->lan) s->n_addrs = own_addrs(s->addrs); if (s->lan) {
int got = own_addrs(s->addrs);
s->n_addrs = got > 0 ? (size_t)got : 0;
}
/* First bind here so the server answers as soon as start returns; on failure the accept /* First bind here so the server answers as soon as start returns; on failure the accept
* thread retries (a takeover's old instance may still hold the port for a moment). */ * thread retries (a takeover's old instance may still hold the port for a moment). */
@@ -788,9 +799,12 @@ int webui_restart(int lan) {
size_t n = 0; size_t n = 0;
unsigned gen; unsigned gen;
uint64_t end; uint64_t end;
int bound = 0; int bound = 0, got = -1;
if (lan) n = own_addrs(addrs); if (lan) {
got = own_addrs(addrs);
n = got > 0 ? (size_t)got : 0;
}
pthread_mutex_lock(&g_life); pthread_mutex_lock(&g_life);
s = g_srv; s = g_srv;
if (!s) { if (!s) {
@@ -799,8 +813,10 @@ int webui_restart(int lan) {
} }
pthread_mutex_lock(&s->mu); pthread_mutex_lock(&s->mu);
s->lan = lan ? 1 : 0; s->lan = lan ? 1 : 0;
s->n_addrs = n; if (!lan || got >= 0) { /* a failed read keeps the list it had */
if (n) memcpy(s->addrs, addrs, sizeof addrs); s->n_addrs = n;
if (n) memcpy(s->addrs, addrs, sizeof addrs);
}
s->rebind_req = 1; s->rebind_req = 1;
gen = s->bind_gen; gen = s->bind_gen;
s->refs++; s->refs++;