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} +