From f77c33e5791de6dc064e1de4f89fd5fc62e203c8 Mon Sep 17 00:00:00 2001
From: phantomptr
Date: Sat, 17 Oct 2026 15:18:01 -0700
Subject: [PATCH] console page: rebind, marquee, continue on phone, deferred
fixes
- Helper listener heals itself on the accept thread (no new thread): every
5 s it rebinds after a resume (10 s clock gap) or, with LAN access on,
when the console's addresses change (Host allowlist refreshed); a
listener whose poll/accept keeps failing for 5 s is rebound too.
- Long file names slide while their row is focused (text-indent marquee,
prefers-reduced-motion respected; one focusin/focusout pair).
- Continue on your phone: /api/settings carries the console's LAN address;
rename, new folder, go to path and bulk rename show a QR code and
http://:9180/ while LAN access is on. Tiny dependency-free QR
encoder (byte mode, v1-10, L/M), checked against segno symbols.
- Deferred: FS stat ids from stat() (a symlink to the destination is
itself); clipboard header; language switch keeps the shown language
until the new one loads, strings fetch times out after 10 s; own
Cache-Control detected in any case; cheats toggle right after the game
exits says closed at once (helper drops its 2 s cache, dead pid reads as
no game), a racing PC/page toggle says "another app is changing";
failed batch items stay selected; Fix permissions title counts items.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
---
Makefile | 2 +-
client/src/console/cheats/Cheats.test.tsx | 30 ++
client/src/console/cheats/model.ts | 12 +-
client/src/console/console.css | 48 +++
client/src/console/files/BulkRenameDialog.tsx | 2 +
client/src/console/files/Files.test.tsx | 21 +-
client/src/console/files/Files.tsx | 37 +-
client/src/console/files/TextPrompt.tsx | 5 +-
client/src/console/files/clipboard.ts | 12 +-
client/src/console/main.tsx | 3 +
client/src/console/offline/OfflinePage.tsx | 4 +-
client/src/console/settings/Settings.tsx | 2 +
client/src/console/strings.test.ts | 46 ++-
client/src/console/strings.ts | 31 +-
client/src/console/ui/PhoneHandoff.test.tsx | 18 +
client/src/console/ui/PhoneHandoff.tsx | 98 +++++
client/src/console/ui/marquee.test.ts | 58 +++
client/src/console/ui/marquee.ts | 62 ++++
client/src/console/ui/qr.test.ts | 195 ++++++++++
client/src/console/ui/qr.ts | 343 ++++++++++++++++++
payload/include/webui_codec.h | 3 +-
payload/include/webui_server.h | 22 ++
payload/src/cheats.c | 16 +-
payload/src/webui_api.c | 24 +-
payload/src/webui_codec.c | 1 +
payload/src/webui_http.c | 20 +-
payload/src/webui_server.c | 168 ++++++++-
payload/tests/webui_codec_selftest.c | 2 +
payload/tests/webui_http_selftest.c | 8 +
payload/tests/webui_server_selftest.c | 46 +++
30 files changed, 1297 insertions(+), 42 deletions(-)
create mode 100644 client/src/console/ui/PhoneHandoff.test.tsx
create mode 100644 client/src/console/ui/PhoneHandoff.tsx
create mode 100644 client/src/console/ui/marquee.test.ts
create mode 100644 client/src/console/ui/marquee.ts
create mode 100644 client/src/console/ui/qr.test.ts
create mode 100644 client/src/console/ui/qr.ts
diff --git a/Makefile b/Makefile
index 651c61fe..ae733561 100644
--- a/Makefile
+++ b/Makefile
@@ -907,7 +907,7 @@ test-payload: payload
-o /tmp/ps5upload-webui-auth-selftest \
$(PAYLOAD_DIR)/tests/webui_auth_selftest.c $(PAYLOAD_DIR)/src/webui_auth.c
@/tmp/ps5upload-webui-auth-selftest
- @echo "Running webui server self-test (host build, ~20 s)..."
+ @echo "Running webui server self-test (host build, ~25 s)..."
@cc -O2 -Wall -Wextra -Werror -pthread -I$(PAYLOAD_DIR)/include \
-o /tmp/ps5upload-webui-server-selftest \
$(PAYLOAD_DIR)/tests/webui_server_selftest.c $(PAYLOAD_DIR)/src/webui_server.c \
diff --git a/client/src/console/cheats/Cheats.test.tsx b/client/src/console/cheats/Cheats.test.tsx
index 2deb3b3f..b934f315 100644
--- a/client/src/console/cheats/Cheats.test.tsx
+++ b/client/src/console/cheats/Cheats.test.tsx
@@ -171,6 +171,36 @@ describe("cheats section", () => {
expect(cheatsPhase(c.get()).kind).toBe("exited");
});
+ it("toggle_right_after_the_game_exited_says_closed_at_once", async () => {
+ const h = helper({ game: GAME, mods: [mod()] });
+ const states: CheatsState[] = [];
+ const c = createCheatsController((st) => states.push(st));
+ await c.loadStatus();
+ h.game = null;
+ h.toggleError = new ApiError(409, "game_not_running");
+ let statusAsked = -1;
+ const real = mockCall.getMockImplementation()!;
+ mockCall.mockImplementation(async (route: string, body?: object) => {
+ if (route === "/api/cheats/status" && statusAsked < 0) statusAsked = states.length;
+ return real(route, body);
+ });
+ await c.toggle(c.get().list!.mods[0]);
+ // "Closed" was on screen before the status was read again (no wait for the next poll).
+ expect(statusAsked).toBeGreaterThan(0);
+ expect(states.slice(0, statusAsked).some((st) => cheatsPhase(st).kind === "exited")).toBe(true);
+ expect(cheatsPhase(c.get()).kind).toBe("exited");
+ });
+
+ it("a_toggle_racing_the_pc_app_says_try_again", async () => {
+ const h = helper({ game: GAME, mods: [mod()] });
+ const c = createCheatsController(() => {});
+ await c.loadStatus();
+ h.toggleError = new ApiError(409, "game_busy");
+ await c.toggle(c.get().list!.mods[0]);
+ expect(c.get().message).toBe(t("cw_cheats_err_busy"));
+ expect(cheatsPhase(c.get()).kind).toBe("list");
+ });
+
it("another_game_never_shows_the_old_games_cheats", async () => {
const h = helper({ game: GAME, mods: [mod({ name: "Old" })] });
const c = createCheatsController(() => {});
diff --git a/client/src/console/cheats/model.ts b/client/src/console/cheats/model.ts
index 28e6f431..7dd5e2df 100644
--- a/client/src/console/cheats/model.ts
+++ b/client/src/console/cheats/model.ts
@@ -97,6 +97,7 @@ export function toggleErrorText(err: unknown): string | null {
if (err.status === 401 || err.code === "game_not_running") return null;
if (isOffline(err)) return t("cw_home_err_offline");
if (err.code === "module_missing") return t("cw_cheats_err_module");
+ if (err.code === "game_busy") return t("cw_cheats_err_busy");
if (err.code === "bad_index" || err.code === "no_cheats") return t("cw_cheats_err_changed");
return t("cw_cheats_err_toggle", { code: err.code });
}
@@ -159,7 +160,8 @@ export function createCheatsController(onChange: (s: CheatsState) => void): Chea
const seq = ++listSeq;
try {
const r = await call("/api/cheats/list", { title_id: tid });
- if (seq !== listSeq || title() !== tid) return;
+ // A switch running now re-reads when it ends: this older answer would flash its old state.
+ if (seq !== listSeq || title() !== tid || s.busy !== null) return;
set({ list: readList(tid, r), listError: null });
} catch (e) {
if (seq !== listSeq || title() !== tid) return;
@@ -205,6 +207,14 @@ export function createCheatsController(onChange: (s: CheatsState) => void): Chea
gone = e instanceof ApiError && e.code === "game_not_running";
set({ message: toggleErrorText(e) });
}
+ if (gone && s.status && title() === tid) {
+ // Said at once (the "closed" screen), not after the next status read: the helper dropped its
+ // running-game cache with this answer, so the read below confirms it (or brings the game back).
+ listSeq++;
+ set({ busy: null, status: { ...s.status, game: null }, seen: tid, list: null, listError: null });
+ await loadStatus();
+ return;
+ }
set({ busy: null });
if (gone) await loadStatus();
else await loadList();
diff --git a/client/src/console/console.css b/client/src/console/console.css
index f6a5a796..ccee5a31 100644
--- a/client/src/console/console.css
+++ b/client/src/console/console.css
@@ -1474,3 +1474,51 @@ button {
overflow-wrap: anywhere;
}
+/* ---- long names scroll while focused (ui/marquee.ts) ---- */
+@keyframes cw-marquee {
+ 0%,
+ 15% {
+ text-indent: 0;
+ }
+ 85%,
+ 100% {
+ text-indent: calc(-1 * var(--cw-marquee, 0px));
+ }
+}
+@media (prefers-reduced-motion: no-preference) {
+ .cw-file-name.cw-marquee {
+ text-overflow: clip;
+ animation: cw-marquee var(--cw-marquee-s, 6s) ease-in-out infinite alternate;
+ }
+}
+
+/* ---- continue on your phone (ui/PhoneHandoff.tsx) ---- */
+.cw-phone {
+ display: flex;
+ align-items: center;
+ gap: 0.9rem;
+ margin: 0.9rem 0 0;
+ padding: 0.6rem;
+ border-radius: 12px;
+ background: var(--color-surface-3);
+ text-align: start;
+}
+.cw-phone svg {
+ flex: none;
+ width: 6.5rem;
+ height: 6.5rem;
+ border-radius: 4px;
+}
+.cw-phone strong {
+ display: block;
+ margin-bottom: 0.2rem;
+}
+.cw-phone p {
+ margin: 0 0 0.3rem;
+ color: var(--color-muted);
+ font-size: 0.85rem;
+}
+.cw-phone code {
+ font-size: 0.95rem;
+ overflow-wrap: anywhere;
+}
diff --git a/client/src/console/files/BulkRenameDialog.tsx b/client/src/console/files/BulkRenameDialog.tsx
index 83e6ce6a..72f48425 100644
--- a/client/src/console/files/BulkRenameDialog.tsx
+++ b/client/src/console/files/BulkRenameDialog.tsx
@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
import { ArrowRight, Square, SquareCheck } from "lucide-react";
import { t } from "../strings";
import { Modal } from "../ui/Modal";
+import { PhoneHandoff } from "../ui/PhoneHandoff";
import { planRenames, type RenameItem, type RenameMode, type RenamePlan, type RenameRow } from "./bulkRename";
type Kind = RenameMode["kind"];
@@ -232,6 +233,7 @@ export function BulkRenameDialog({
) : null}
)}
+
{!wait && plan.first ? (
diff --git a/client/src/console/files/Files.test.tsx b/client/src/console/files/Files.test.tsx
index 41119aba..2e96d5d0 100644
--- a/client/src/console/files/Files.test.tsx
+++ b/client/src/console/files/Files.test.tsx
@@ -13,7 +13,7 @@ import { t } from "../strings";
import { ConfirmDialog } from "../ui/ConfirmDialog";
import { ActionsSheet } from "./ActionsSheet";
import { DirList } from "./DirList";
-import { batchMessage, filesErrorText, FilesView, pasteMessage, upFrom, viewKind, type FilesViewProps } from "./Files";
+import { batchMessage, failedNamesIn, filesErrorText, FilesView, pasteMessage, upFrom, viewKind, type FilesViewProps } from "./Files";
import { clearClipboard, getClipboard, setClipboard } from "./clipboard";
import { ConflictDialog } from "./ConflictDialog";
import { IntoItselfError, renameEntry } from "./move";
@@ -131,6 +131,7 @@ describe("Files", () => {
const q = props({ ask: { kind: "fixperm", names: ["b", "c"] } });
const d = elements(FilesView(q)).find((e) => e.type === ConfirmDialog)!;
expect(d.props.body).toBe(t("cw_files_fixperm_body"));
+ expect(d.props.title).toBe(t("cw_files_fixperm_many", { n: 2 })); // the count, as Delete says it
expect(d.props.danger).toBeUndefined();
(d.props.onConfirm as () => void)();
expect(q.onConfirmFixPerm).toHaveBeenCalledWith(["b", "c"]);
@@ -399,3 +400,21 @@ describe("Files listing controls", () => {
);
});
});
+
+describe("failed items stay selected after a batch", () => {
+ it("keeps the folder's refused and failed items, once each, and nothing from elsewhere", () => {
+ const r = {
+ started: ["/data/a"],
+ moved: [],
+ failed: [
+ { item: "/data/b", error: new Error("x") },
+ { item: "/mnt/usb0/src/c", error: new Error("x") },
+ ],
+ jobFailed: ["/data/d", "/data/b"],
+ skipped: ["/data/e"],
+ };
+ expect(failedNamesIn(r, "/data")).toEqual(["b", "d"]);
+ expect(failedNamesIn(r, "/mnt/usb0")).toEqual([]);
+ expect(failedNamesIn({ started: [], moved: [], failed: [], jobFailed: ["/x"], skipped: [] }, "/")).toEqual(["x"]);
+ });
+});
diff --git a/client/src/console/files/Files.tsx b/client/src/console/files/Files.tsx
index abd79abf..18fb3d31 100644
--- a/client/src/console/files/Files.tsx
+++ b/client/src/console/files/Files.tsx
@@ -47,6 +47,7 @@ import {
joinPath,
moveItems,
baseName,
+ parentPath,
renameEntry,
upFrom,
volumeOf,
@@ -124,6 +125,21 @@ export function batchMessage(r: BatchResult, op?: Busy["op"]): Message | null {
return { tone: "info", text: t(op === "chmod" ? "cw_files_fixperm_done" : "cw_files_done") };
}
+/**
+ * The items of `dir` a finished batch could not do (refused, or their job failed): they stay
+ * selected afterwards, so a retry (or a look at them) is one press away. Items elsewhere (a
+ * paste's sources) are left out.
+ */
+export function failedNamesIn(r: BatchResult, dir: string): string[] {
+ const out: string[] = [];
+ for (const p of r.failed.map((f) => f.item).concat(r.jobFailed)) {
+ if (parentPath(p) !== dir) continue;
+ const name = baseName(p);
+ if (name && !out.includes(name)) out.push(name);
+ }
+ return out;
+}
+
/** The line a finished paste leaves: batchMessage's, or what was skipped / already there. */
export function pasteMessage(r: PasteResult): Message | null {
if (r.halted.length && !r.failed.length && !r.jobFailed.length) {
@@ -546,7 +562,11 @@ function askView(p: FilesViewProps) {
case "fixperm":
return (
(null);
/** False once Files unmounts: a batch that ends later reports to no one (it ran to the end anyway). */
const mounted = useRef(true);
+ /** The folder shown now (a batch that ends later checks it is still the same). */
+ const pathRef = useRef(path);
+ useEffect(() => {
+ pathRef.current = path;
+ }, [path]);
/** Where focus was last in the list (to put it back when that row goes). */
const lastAnchor = useRef(null);
/** After the next load, focus this row ("" = the first one), then forget it. */
@@ -1162,9 +1187,17 @@ export function Files({ ctx }: { ctx: SectionContext }) {
work(items, { onProgress: ctl.onProgress, stopped: ctl.stopped, waitJob: (id) => waitJob(id, ctl.stopped) }),
destDir != null ? items.concat(destDir) : items,
);
+ const batchDir = path;
run?.then(
(r) => {
- if (mounted.current) report(r, op);
+ if (!mounted.current) return;
+ report(r, op);
+ // Still in that folder: what failed stays selected (Task 9 deferred).
+ const keep = failedNamesIn(r, batchDir);
+ if (keep.length && pathRef.current === batchDir) {
+ setSelecting(true);
+ setSelected(keep);
+ }
},
() => undefined,
);
diff --git a/client/src/console/files/TextPrompt.tsx b/client/src/console/files/TextPrompt.tsx
index 0bc17a12..5c23e762 100644
--- a/client/src/console/files/TextPrompt.tsx
+++ b/client/src/console/files/TextPrompt.tsx
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { t } from "../strings";
import { Modal } from "../ui/Modal";
+import { PhoneHandoff } from "../ui/PhoneHandoff";
import { validName } from "./move";
/** The default check: a file or folder name ("/", "." and ".." refused). */
@@ -11,7 +12,8 @@ const checkName = (v: string): string | null => (validName(v) ? null : t("cw_fil
* OK and Cancel. `check` says why a value cannot be used (shown under the box,
* the prompt stays open); by default a name with "/" (or "." / "..") is
* refused here, before any call. An async check (Go to path asks the console)
- * holds OK until it answers.
+ * holds OK until it answers. With LAN access on, "Continue on your phone" (a QR
+ * code of this page's LAN address) shows under the box.
*/
export function TextPrompt({
title,
@@ -95,6 +97,7 @@ export function TextPrompt({
) : null}
{extra}
+