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://<addr>: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 <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:18:01 -07:00
1 parent 4ff889720d
commit f77c33e579
30 files changed
+1297 -42

No files matched your search

+1 -1
View File
@@ -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 \
+30
View File
@@ -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(() => {});
+11 -1
View File
@@ -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<unknown>("/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();
+48
View File
@@ -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;
}
@@ -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}
</ul>
)}
<PhoneHandoff />
</div>
</div>
{!wait && plan.first ? (
+20 -1
View File
@@ -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"]);
});
});
+35 -2
View File
@@ -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 (
<ConfirmDialog
title={a.names.length === 1 ? `${t("cw_files_fixperm")}: ${a.names[0]}` : t("cw_files_fixperm")}
title={
a.names.length === 1
? `${t("cw_files_fixperm")}: ${a.names[0]}`
: t("cw_files_fixperm_many", { n: a.names.length })
}
body={busyText(p.busy) ?? t("cw_files_fixperm_body")}
confirmLabel={t("cw_files_fixperm")}
busy={p.busy != null}
@@ -995,6 +1015,11 @@ export function Files({ ctx }: { ctx: SectionContext }) {
const root = useRef<HTMLDivElement>(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<FocusAnchor | null>(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,
);
+4 -1
View File
@@ -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({
</p>
) : null}
{extra}
<PhoneHandoff />
<div className="cw-dialog-buttons">
<button type="button" className="cw-focusable cw-button" onClick={onCancel}>
<span>{t("cw_dialog_cancel")}</span>
+8 -4
View File
@@ -3,11 +3,15 @@
*
* Both live outside any component (like batch.ts): the clipboard stays while
* folders change and while another section is open, for the whole page
* session; a paste that meets a conflict while Files is closed waits until
* Files is open again to ask. Nothing here is saved: a reload empties it.
* session. A conflict is asked only while Files is open: Stop, or Files
* closing, answers the waiting one (and every later one) with Skip
* (skipPendingConflict), so a paste never waits behind a dialog nobody sees.
* Nothing here is saved: a reload empties it.
*
* Ruling R22: ○ (browser Back) never clears the clipboard, at any level; only
* the bar's Clear does, or a cut whose items all moved.
* Ruling R22: ○ (browser Back) never clears the clipboard, at any level. Only
* the bar's Clear empties it; a new Copy / Cut replaces it; a cut item leaves
* it once it has moved (or was skipped on purpose), and a copy stays to be
* pasted again.
*/
import { useEffect, useState } from "react";
import type { Conflict, Decision, PasteMode } from "./paste";
+3
View File
@@ -6,6 +6,7 @@ import { App } from "./App";
import { probeHelper } from "./api";
import { installBackHistory } from "./backHistory";
import { installFullscreen } from "./fullscreen";
import { installMarquee } from "./ui/marquee";
import { DEFAULT_INPUT_MAP, dispatchNav, installNavigation } from "./nav";
import { installOfflineCopy } from "./offline/offlineCopy";
import { OfflinePage } from "./offline/OfflinePage";
@@ -22,6 +23,8 @@ installNavigation(DEFAULT_INPUT_MAP);
installFullscreen();
// The browser's Back (the PS5's ○) steps back inside the page: see backHistory.ts.
installBackHistory(() => dispatchNav("back"));
// Long names scroll inside their row while focused.
installMarquee();
// The offline copy (service worker + appcache): the tile opens the "not running" screen, not an error.
installOfflineCopy();
+3 -1
View File
@@ -7,6 +7,8 @@ import { pollWhileVisible } from "../ui/visiblePoll";
/** While the offline screen shows, the helper is asked again this often. */
export const OFFLINE_RETRY_MS = 5000;
const reloadPage = () => window.location.reload();
/**
* Shown instead of the page when the helper did not answer GET /api/token: the
* page came from the browser's offline copy (offlineCopy.ts) and the helper is
@@ -16,7 +18,7 @@ export const OFFLINE_RETRY_MS = 5000;
*/
export function OfflinePage({
probe = probeHelper,
onBack = () => window.location.reload(),
onBack = reloadPage,
}: {
probe?: () => Promise<boolean>;
/** The helper answered. */
+2
View File
@@ -6,6 +6,7 @@ import { LANGUAGE_NAMES } from "../languages";
import { chooseLanguage, LANGUAGES, storedLanguage, t } from "../strings";
import { getContrast, getTheme, setContrast, setTheme, THEMES, type Theme } from "../theme";
import { ConfirmDialog } from "../ui/ConfirmDialog";
import { forgetPhoneUrl } from "../ui/PhoneHandoff";
/** /api/settings (payload/src/webui_api.c send_settings). */
export interface WebSettings {
@@ -340,6 +341,7 @@ export function Settings({ onOpenControls, onShowTips }: { onOpenControls?: () =
.then(
async (s) => {
setSettings(s);
if (change.lan !== undefined) forgetPhoneUrl(); // "Continue on your phone" asks again
if (change.tile === undefined) return;
// The tile is added or removed after the reply: wait for the real result (the switch stays
// disabled meanwhile) and say so when it did not work.
+45 -1
View File
@@ -11,6 +11,7 @@ import {
setLanguage,
stringsReady,
stringsUrl,
STRINGS_TIMEOUT_MS,
stringsVersion,
t,
} from "./strings";
@@ -187,13 +188,56 @@ describe("console strings", () => {
expect(asked).toEqual(["de", "fr", "de"]);
});
it("switching from a shown language to one still loading keeps the shown one (no English flash)", async () => {
let releaseFr: (v: Record<string, string>) => void = () => {};
_setStringsLoaderForTests((code) =>
code === "fr"
? new Promise((resolve) => {
releaseFr = resolve;
})
: Promise.resolve(de),
);
setLanguage("de");
await stringsReady();
expect(html.lang).toBe("de");
setLanguage("fr");
expect(getLanguage()).toBe("fr");
expect(html.lang).toBe("de"); // still German while French loads
expect(t("cw_nav_settings")).toBe(de.cw_nav_settings);
releaseFr(JSON.parse(committed["./locales/fr.json"]) as Record<string, string>);
await stringsReady();
expect(html.lang).toBe("fr");
});
it("the real loader gives up on a strings file that never arrives", async () => {
vi.useFakeTimers();
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const fetchMock = vi.fn(
(_url: string, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(new Error("aborted")));
}),
);
vi.stubGlobal("fetch", fetchMock);
try {
setLanguage("ko");
const done = stringsReady();
await vi.advanceTimersByTimeAsync(STRINGS_TIMEOUT_MS);
await done;
expect(warn).toHaveBeenCalledTimes(1);
expect(html.lang).toBe("en");
} finally {
vi.useRealTimers();
}
});
it("the real loader fetches the helper's URL and keeps English when it fails", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const fetchMock = vi.fn(() => Promise.resolve(new Response("nope", { status: 404 })));
vi.stubGlobal("fetch", fetchMock);
setLanguage("ja");
await stringsReady();
expect(fetchMock).toHaveBeenCalledWith(stringsUrl("ja"), { credentials: "same-origin" });
expect(fetchMock).toHaveBeenCalledWith(stringsUrl("ja"), expect.objectContaining({ credentials: "same-origin" }));
expect(t("cw_nav_settings")).toBe(en.cw_nav_settings);
expect(warn).toHaveBeenCalledTimes(1);
fetchMock.mockImplementation(() =>
+21 -10
View File
@@ -5,9 +5,11 @@
* language is fetched from the helper (GET /strings/<code>.json?v=<hash>,
* gzip'd into the ELF) when it is chosen, at start or in Settings.
*
* Until a language's strings arrive the page stays in English (no flash of
* raw keys), keys a language lacks fall back to English, and a failed load
* leaves the page in English (logged once). The language follows the PS5's
* Until a language's strings arrive the page stays in the language it shows
* (English at start; the previous choice when Settings switches: never a flash
* of English in between), keys a language lacks fall back to English, and a
* failed or timed-out load (STRINGS_TIMEOUT_MS) leaves the page as it was
* (logged once). The language follows the PS5's
* system language (navigator.language) until setLanguage overrides it.
*/
import { LANGUAGE_NAMES } from "./languages";
@@ -80,10 +82,19 @@ export function cleanStrings(raw: unknown): Record<string, string> {
return out;
}
/** A strings file that has not arrived by then is given up (the page stays as it is). */
export const STRINGS_TIMEOUT_MS = 10_000;
const fetchStrings: StringsLoader = async (code) => {
const res = await fetch(stringsUrl(code), { credentials: "same-origin" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return cleanStrings(await res.json());
const stop = typeof AbortController === "function" ? new AbortController() : null;
const timer = setTimeout(() => stop?.abort(), STRINGS_TIMEOUT_MS);
try {
const res = await fetch(stringsUrl(code), { credentials: "same-origin", signal: stop?.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return cleanStrings(await res.json());
} finally {
clearTimeout(timer);
}
};
const STORAGE_KEY = "ps5upload.console.lang";
@@ -157,7 +168,7 @@ function load(code: string): Promise<void> {
(err: unknown) => {
if (!loggedFailure) {
loggedFailure = true;
console.warn(`console page: ${code} strings did not load, staying in English`, err);
console.warn(`console page: ${code} strings did not load, staying in ${shown}`, err);
}
},
)
@@ -171,8 +182,9 @@ function load(code: string): Promise<void> {
/**
* Switch the page's language (Settings' manual override, and main.tsx at start). A language
* already loaded applies at once; another one is fetched and applies when it arrives, English
* until then. Re-renders through the listeners whenever the shown strings change.
* already loaded applies at once; another one is fetched and applies when it arrives, the page
* keeping the language it shows until then. Re-renders through the listeners whenever the shown
* strings change.
*/
export function setLanguage(code: string): void {
current = pickLanguage(code, LANGUAGES);
@@ -180,7 +192,6 @@ export function setLanguage(code: string): void {
show(current);
return;
}
if (shown !== "en") show("en");
void load(current);
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { phoneUrl } from "./PhoneHandoff";
describe("continue on your phone", () => {
it("is this page on the console's LAN address while LAN access is on", () => {
expect(phoneUrl({ lan: true, addr: "192.168.86.99" }, "9180")).toBe("http://192.168.86.99:9180/");
expect(phoneUrl({ lan: true, addr: "10.0.0.7" }, "")).toBe("http://10.0.0.7:9180/");
});
it("shows nothing when LAN access is off, the helper is older, or the address is not usable", () => {
expect(phoneUrl({ lan: false, addr: "192.168.86.99" }, "9180")).toBeNull();
expect(phoneUrl({ lan: true }, "9180")).toBeNull(); // an older helper: no addr
expect(phoneUrl({ lan: true, addr: "" }, "9180")).toBeNull();
expect(phoneUrl({ lan: true, addr: "127.0.0.1" }, "9180")).toBeNull();
expect(phoneUrl({ lan: true, addr: "evil.example/x?" }, "9180")).toBeNull();
expect(phoneUrl(null, "9180")).toBeNull();
});
});
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useMemo, useState } from "react";
import { call } from "../api";
import { t } from "../strings";
import { encodeQr, qrPath } from "./qr";
/** /api/settings, the part read here: `addr` is the console's LAN address (newer helpers, LAN on). */
interface LanInfo {
lan?: unknown;
addr?: unknown;
}
/** The address a phone opens, or null when LAN access is off (or the helper is older and has no addr). */
export function phoneUrl(info: LanInfo | null, port: string): string | null {
if (!info || info.lan !== true || typeof info.addr !== "string") return null;
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(info.addr) || info.addr.startsWith("127.")) return null;
const p = /^\d{1,5}$/.test(port) ? port : "9180";
return `http://${info.addr}:${p}/`;
}
/** Read once per page and kept briefly: every text dialog asks, the answer rarely changes. */
const KEEP_MS = 30_000;
let cached: { at: number; url: string | null } | null = null;
let inflight: Promise<string | null> | null = null;
function currentPort(): string {
return typeof location !== "undefined" && location.port ? location.port : "9180";
}
/** The phone URL (cached for a moment), or null. Never rejects. */
export function loadPhoneUrl(now: number = Date.now()): Promise<string | null> {
if (cached && now - cached.at < KEEP_MS) return Promise.resolve(cached.url);
if (!inflight) {
inflight = call<LanInfo>("/api/settings")
.then(
(info) => phoneUrl(info, currentPort()),
() => null,
)
.then((url) => {
cached = { at: Date.now(), url };
inflight = null;
return url;
});
}
return inflight;
}
/** Settings changed LAN access: the next dialog asks again. */
export function forgetPhoneUrl(): void {
cached = null;
}
/**
* "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
* 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).
*/
export function PhoneHandoff() {
const [url, setUrl] = useState<string | null>(cached?.url ?? null);
useEffect(() => {
let live = true;
void loadPhoneUrl().then((u) => {
if (live) setUrl(u);
});
return () => {
live = false;
};
}, []);
const path = useMemo(() => {
if (!url) return null;
try {
const q = encodeQr(url, "M");
return { d: qrPath(q), n: q.size + 8 };
} catch {
return null;
}
}, [url]);
if (!url || !path) return null;
return (
<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">
<rect width={path.n} height={path.n} fill="#fff" />
<path d={path.d} fill="#000" />
</svg>
<div>
<strong>{t("cw_phone_title")}</strong>
<p>{t("cw_phone_body")}</p>
<code>{url}</code>
</div>
</div>
);
}
/** Tests: forget the cached answer. */
export function _resetPhoneForTests(): void {
cached = null;
inflight = null;
}
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { installMarquee, marqueeSeconds, marqueeShift } from "./marquee";
/** Just enough DOM: a focused button holding one name span. */
function fakeName(scrollWidth: number, clientWidth: number) {
const props = new Map<string, string>();
const classes = new Set<string>();
const name = {
scrollWidth,
clientWidth,
style: {
setProperty: (k: string, v: string) => void props.set(k, v),
removeProperty: (k: string) => void props.delete(k),
},
classList: { add: (c: string) => void classes.add(c), remove: (c: string) => void classes.delete(c) },
};
const button = {
querySelectorAll: (sel: string) => (sel.includes("cw-marquee") ? (classes.has("cw-marquee") ? [name] : []) : [name]),
};
const fns: Record<string, (e: unknown) => void> = {};
const doc = {
addEventListener: (type: string, fn: (e: unknown) => void) => void (fns[type] = fn),
removeEventListener: (type: string) => void delete fns[type],
};
return { props, classes, button, doc, fns };
}
describe("marquee for long names", () => {
it("slides only names that are cut off", () => {
expect(marqueeShift(300, 300)).toBe(0);
expect(marqueeShift(301, 300)).toBe(0); // rounding
expect(marqueeShift(420.4, 300)).toBe(121);
expect(marqueeSeconds(40)).toBe(3);
expect(marqueeSeconds(400)).toBe(10);
expect(marqueeSeconds(4000)).toBe(15);
});
it("starts when its row gets focus and stops when it leaves", () => {
const f = fakeName(500, 300);
const stop = installMarquee(f.doc as unknown as Document);
f.fns.focusin({ target: f.button });
expect(f.classes.has("cw-marquee")).toBe(true);
expect(f.props.get("--cw-marquee")).toBe("200px");
expect(f.props.get("--cw-marquee-s")).toBe("5s");
f.fns.focusout({ target: f.button });
expect(f.classes.has("cw-marquee")).toBe(false);
expect(f.props.size).toBe(0);
stop();
expect(Object.keys(f.fns)).toEqual([]);
});
it("leaves a name that fits alone", () => {
const f = fakeName(200, 300);
installMarquee(f.doc as unknown as Document);
f.fns.focusin({ target: f.button });
expect(f.classes.size).toBe(0);
});
});
+62
View File
@@ -0,0 +1,62 @@
/**
* Long names scroll while their row is focused (Payload Manager's marquee): a
* name cut off with "…" slides to its end and back, so the whole of it can be
* read on the TV without opening Info. One document-level focusin/focusout
* pair for the whole page (no per-row work; a 10k-entry folder costs
* nothing until a row is focused). The motion itself is CSS (.cw-marquee in
* console.css), only under prefers-reduced-motion: no-preference; with
* reduced motion the name stays cut off as before.
*/
/** The names that may scroll, inside a focused control. */
export const MARQUEE_SELECTOR = ".cw-file-name";
const ON = "cw-marquee";
/** Pixels a name must slide to show its end; 0 when it fits (1 px of rounding is not overflow). */
export function marqueeShift(scrollWidth: number, clientWidth: number): number {
const over = Math.ceil(scrollWidth - clientWidth);
return over > 1 ? over : 0;
}
/** Seconds for one slide: about 40 px a second, never under 3 s or over 15 s. */
export function marqueeSeconds(shift: number): number {
return Math.min(15, Math.max(3, Math.round((shift / 40) * 10) / 10));
}
function start(root: Element): void {
const names = root.querySelectorAll<HTMLElement>(MARQUEE_SELECTOR);
for (let i = 0; i < names.length && i < 4; i++) {
const el = names[i];
const shift = marqueeShift(el.scrollWidth, el.clientWidth);
if (!shift) continue;
el.style.setProperty("--cw-marquee", `${shift}px`);
el.style.setProperty("--cw-marquee-s", `${marqueeSeconds(shift)}s`);
el.classList.add(ON);
}
}
function stop(root: Element): void {
root.querySelectorAll<HTMLElement>(`.${ON}`).forEach((el) => {
el.classList.remove(ON);
el.style.removeProperty("--cw-marquee");
el.style.removeProperty("--cw-marquee-s");
});
}
/** Listen on `doc` (the page's document); returns the stop function. */
export function installMarquee(doc: Document = document): () => void {
const onIn = (e: FocusEvent) => {
const el = e.target as Element | null;
if (el && typeof el.querySelectorAll === "function") start(el);
};
const onOut = (e: FocusEvent) => {
const el = e.target as Element | null;
if (el && typeof el.querySelectorAll === "function") stop(el);
};
doc.addEventListener("focusin", onIn);
doc.addEventListener("focusout", onOut);
return () => {
doc.removeEventListener("focusin", onIn);
doc.removeEventListener("focusout", onOut);
};
}
+195
View File
@@ -0,0 +1,195 @@
import { describe, expect, it } from "vitest";
import { dataCodewords, encodeQr, qrPath, type Ecl } from "./qr";
/**
* Known symbols, from another encoder: segno (Python, ISO 18004; v1.6) for the same text, level
* and byte mode, no ECC boost, once per mask; each symbol hashed (rows of 0/1 joined by "\n").
* segno's padding was patched to the standard's (7.4.10: zero bits up to the codeword boundary,
* none when already on it; segno adds a whole 0x00 there, which decoders skip but which changes
* every pad and ECC codeword after it). The cases cover versions 1–10 (one and several blocks,
* short and long blocks, version information from 7, the 16-bit length at 10), UTF-8 and both
* levels. `mask` is the one encodeQr picks (penalty scored with the format bits drawn, as Project
* Nayuki's encoder does); segno scores before drawing them and picks the same mask in all but the
* first case (segnoMask). Any mask is a valid symbol; the choice only tunes readability.
*/
const VECTORS: { text: string; ecl: Ecl; version: number; mask: number; segnoMask: number; masks: string[] }[] = [
{
text: "HELLO",
ecl: "M",
version: 1,
mask: 4,
segnoMask: 2,
masks: [
"b664c5ebcc2451fb6630e63f516abd72b3cdfbf16b9ceb0d92d226f6a6d0cde0",
"aac7b8561561aa9ae4f5c8a20a12321e0a1ad77028376a765f63e49f263c210b",
"1fff7fd781f0f59edd796e0f79b20d68de6ecef41b47571460c4bbfbac1afe97",
"71bc9ab3ffcaa0d281e4873d98c839634768a360dd4020111d405a3c41752367",
"c346c75add5698735afe3f7eb4f3e6c57ccefd9563f45f65c6d76aa518fb6f91",
"ba746fc2cc99bfbc1a9f7ce2b807778e4e5bc573e306afe3e0e27d323ff0b83b",
"533270fea1ec92a09839f69ab35d0555d27dff9a8d05ad876b51cd5805cd8a62",
"c59a8159c247ab477a18292aa2f9bfbe4775ec9af09bc6556d3824289c8360e9",
],
},
{
text: "http://192.168.86.99:9180/",
ecl: "M",
version: 2,
mask: 2,
segnoMask: 2,
masks: [
"94bc35a6297de2a3a71062da34051debc5eaef645c6afcdf5e262521e0382b04",
"0ea79532e071f8a7493c7761a3fa5e695e541266a8edd126686bab90c0f521d0",
"7c95be6475025a574619026c3683611defeeafbc383b88d6521f5d0fb61221f7",
"ede50cdcc1fee5a54550a016608f0ed96fdc7bddf0a2dac197a6d417a521117d",
"1fc220e7a97ef178a798592456c91585781172ce830bd0bfc5d44abd644f4947",
"927e6fa20155c63991f79a7f6a03fca051c69df536b351db47fe195e79af324b",
"b107e347747643d7eb61ec1cca3643289c03e1a23947ebac4c46e4cb569522e1",
"81f5c940fcebfaf92324991e4a6df19689cd53ff300c786e17877bea488aabf8",
],
},
{
text: "http://10.0.0.5:9180/",
ecl: "L",
version: 2,
mask: 4,
segnoMask: 4,
masks: [
"14903dfca5a315d59828c527fc731cecaa60cc8ab0737ba40d51a7e4660f03a8",
"0ce213c069792bddcd7b3a5f1c7f66a9749dcce29f30e65619e1c2f0caeb4ea6",
"ca464ae10e0fe6725e10cbcc7a36e2536f984712c8ead50dcbae42ab920b88d1",
"c9396e68b6a285711603f2e0dd3c2e66c2a149306469cda1afb749f0d919acd9",
"1bb68a0637f9f6c819963b6b8726bbf07642558413419d32a4b22192554b8c4c",
"779dfae360f8a641f54990e8ea568d155a65ebde77b6fe7f4332fb34ec0b6e37",
"e891994e2bf4c3bc221414c7b771e0c253d270ea73ec5f743da4375f1684c899",
"91c514fdaeea869036c4406eb109ef78b2a74a3a2c40781ecb098ce85bfcba21",
],
},
{
text: "http://192.168.100.200:9180/?x=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
ecl: "M",
version: 6,
mask: 2,
segnoMask: 2,
masks: [
"02216116764c964cb4585a49aa490f01cd6457f883d00e9c4bc1524d56264713",
"82e566dd9289a1386ae287e7cc0925a79b528528b7046bd608e33b5300472d5c",
"4ba4adef7aa35b1a988c91381b575b10c0e29e611c728fd97d7c14bc1ce0f33b",
"9b4d492a184ae33b0f939ef33dc4c96cb522c31903757aa5ff2aa88ca53c2581",
"f8d003d1b8576a8146cb73207765c2c40130576f69ec33db10abd248a4000baa",
"99388a251735d617aeefb05a9ca33945f9c968c02c5369eaddaab2eca1e78755",
"c71e09612a929dbe0d3fc161c7ca5a2334820ca6b22172b1e1a485f8fca41f9b",
"9ceb72c1a6f49ce9cac2193550bf37659268ae90bb8604116a79a03b3c0a98de",
],
},
{
text: "https://example.com/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
ecl: "M",
version: 8,
mask: 2,
segnoMask: 2,
masks: [
"0877803b1f3d4c3edcba07d209edc0403cc75b402d5deefba2152b8aa04dd7ea",
"9a16397c3a54cc337b5c0335494590bf96d2e03918ddfb6b105a21ac7d5f5b12",
"de2c22e2d9f9d7d2a4b260792a5baf32f6fedfbc47f1eac8e284c2fc2e25a2d9",
"708972d60d727af9070a7641d64395756563459ea61a9bae1e9d178eb955ed6c",
"8cb3b86b7b84c605aa31ffc536e787ec4737f59718c5d06618742c98b69febc5",
"d980c91a957ddded5140af00a151e9dce37f62dfd9170c765ff2e2de391a5c56",
"2b2b2f5e7b3d5606f11f9fba41013687d81c8c3c9119e4d44e94674e49f31964",
"afd86522ff73c44b7d8d15a7d598d124888dbe1c2f83cd11554ad234acf0e2d1",
],
},
{
text: "https://example.com/cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
ecl: "M",
version: 10,
mask: 2,
segnoMask: 2,
masks: [
"d27c5047e526226b190ae82ee23788b295ae96f49cb452ec3f47a579f53137b7",
"c1601e9ba0849fdcf1779f3c8845e622961cf027bbfa1d9746f74ae68b6fc0e2",
"6f81bb63098d7e2d869b1e7a9ed709676df4feb9d6e6c92220436eb11047495e",
"2038043527c1a5c7f6e8688447e64dc2170302b69b6fcfa265b770e96bc8b746",
"acfec5a589a923092b72e525f15836b403c28105ba482b11a5ac70aa695d592a",
"4092429426e1a0e80b3179aa746d8589586ba2667fa35bf72416fe9af61242e0",
"c65d2ba46d698b1b8c168600b73e7d47c1647d5e38434b9984e4a3a92808e9d2",
"3ad9cc18428fca4945e75e589fc5b6cfa4a03d91884b4e4e6d4fe22bbb591e1d",
],
},
{
text: "Grüße aus 東京 — PS5Upload",
ecl: "M",
version: 3,
mask: 3,
segnoMask: 3,
masks: [
"e76244e75e7474dd5bdcc75d2b04cd54629cb2222478c4e094efac47c3400bdd",
"ec78a085d9faf026f8a7e9e140277260afd7d29bd992751fc290ac356eb14895",
"43e67d341c14ff29e2c442259774018962d9664b70ce4737576de0a782967169",
"566d68565ee4064a9e3fe1f4e71d451301e7164599318bbaa01489422f287fd5",
"7009b0bf4d2dc0940ec5b6f823bb6e36925fe756269370e13021cc2cb03cbbe4",
"db9cd19a4661678f10c4373f2cac09f30999b78050bb89bf4b97f39c8967a61f",
"f68b5e7b615da962432990eb792b0fd50217ac6475866e5761d15aa495ec221e",
"6efe828e88e67dac751f7fe118a15d83dc7ba9b24fc44b279a6a8f998cabffa8",
],
},
{
text: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
ecl: "L",
version: 9,
mask: 0,
segnoMask: 0,
masks: [
"71ddfbc79101aebe329e96f941cecd5e8440af18575c5b44b1700da69a5e03d3",
"b48fa9361e96e0a2c1d85c50daa22e8e85361b50d6767b4273889544f9a7a8f6",
"42c3e7947ec6891459e2b23afba7d4002c5530594ec7c0242463484f166f6317",
"3610cdc901987a502b8c49a588fe648cecde665032f0d313ef9d49f2d5ffcc27",
"c851a54ca6851a9cb303043c42ba81eef126fc3b0988e265e2e3bd4e6c360d6b",
"8f9480c5e84832683bd12e715482b0db09f142115727e4c6a1315e6662291beb",
"1b87fdc0b2042dc60424d3bc6656b9497bc6d88bf67a9e18ca4c0e0db7471cd9",
"e709b05f425bd871e12fee96a0241edb6011b4afcf7dff7da79712c7f1fda709",
],
},
];
async function digest(modules: boolean[][]): Promise<string> {
const s = modules.map((row) => row.map((d) => (d ? "1" : "0")).join("")).join("\n");
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
return Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join("");
}
describe("QR encoder", () => {
it.each(VECTORS)("$text ($ecl) is the reference symbol under every mask", async (v) => {
for (let m = 0; m < 8; m++) {
const q = encodeQr(v.text, v.ecl, m);
expect(q.version).toBe(v.version);
expect(q.size).toBe(v.version * 4 + 17);
expect(await digest(q.modules), `mask ${m}`).toBe(v.masks[m]);
}
});
it.each(VECTORS)("$text ($ecl) picks its mask by the penalty rules", async (v) => {
const q = encodeQr(v.text, v.ecl);
expect(q.mask).toBe(v.mask);
expect(await digest(q.modules)).toBe(v.masks[v.mask]);
});
it("capacities match the standard's tables (byte mode: data codewords)", () => {
expect(dataCodewords(1, "M")).toBe(16);
expect(dataCodewords(2, "M")).toBe(28);
expect(dataCodewords(5, "M")).toBe(86);
expect(dataCodewords(10, "M")).toBe(216);
expect(dataCodewords(10, "L")).toBe(274);
});
it("refuses text longer than version 10 holds", () => {
expect(() => encodeQr("x".repeat(214), "M")).toThrow(/too long/);
});
it("draws one path square per dark module inside the quiet zone", () => {
const q = encodeQr("HELLO");
const dark = q.modules.flat().filter(Boolean).length;
const path = qrPath(q);
expect(path.split("z").length - 1).toBe(dark);
expect(path.startsWith("M4 4h1v1h-1z")).toBe(true); // the finder's top-left corner
});
});
+343
View File
@@ -0,0 +1,343 @@
/**
* A tiny QR code encoder (ISO/IEC 18004) for "Continue on your phone": byte
* mode, versions 1–10, error correction L or M, the mask chosen by the
* standard penalty rules. Enough for a URL of up to 213 bytes at M; no
* dependency (the page's size budget), no BigInt (safari13). Follows Project
* Nayuki's reference implementation (MIT); qr.test.ts checks whole symbols
* against another encoder's output.
*/
export type Ecl = "L" | "M";
/** Index 0 unused; versions 1–10. */
const ECC_PER_BLOCK: Record<Ecl, number[]> = {
L: [-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18],
M: [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26],
};
const BLOCKS: Record<Ecl, number[]> = {
L: [-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4],
M: [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5],
};
const FORMAT_BITS: Record<Ecl, number> = { L: 1, M: 0 };
export const MAX_VERSION = 10;
/** Data-area modules of a version (everything but the function patterns). */
function rawModules(ver: number): number {
let n = (16 * ver + 128) * ver + 64;
if (ver >= 2) {
const align = Math.floor(ver / 7) + 2;
n -= (25 * align - 10) * align - 55;
if (ver >= 7) n -= 36;
}
return n;
}
/** Data codewords (bytes) a version holds at `ecl`. */
export function dataCodewords(ver: number, ecl: Ecl): number {
return Math.floor(rawModules(ver) / 8) - ECC_PER_BLOCK[ecl][ver] * BLOCKS[ecl][ver];
}
function gfMul(x: number, y: number): number {
let z = 0;
for (let i = 7; i >= 0; i--) {
z = (z << 1) ^ ((z >>> 7) * 0x11d);
z ^= ((y >>> i) & 1) * x;
}
return z & 0xff;
}
function rsDivisor(degree: number): number[] {
const out: number[] = [];
for (let i = 0; i < degree - 1; i++) out.push(0);
out.push(1);
let root = 1;
for (let i = 0; i < degree; i++) {
for (let j = 0; j < out.length; j++) {
out[j] = gfMul(out[j], root);
if (j + 1 < out.length) out[j] ^= out[j + 1];
}
root = gfMul(root, 0x02);
}
return out;
}
function rsRemainder(data: number[], divisor: number[]): number[] {
const out = divisor.map(() => 0);
for (const b of data) {
const factor = b ^ (out.shift() as number);
out.push(0);
divisor.forEach((coef, i) => {
out[i] ^= gfMul(coef, factor);
});
}
return out;
}
function utf8(text: string): number[] {
return Array.from(new TextEncoder().encode(text));
}
/** The symbol: modules[y][x], true = dark. No quiet zone. */
export interface QrCode {
version: number;
ecl: Ecl;
mask: number;
size: number;
modules: boolean[][];
}
/**
* Encode `text` (UTF-8, byte mode) in the smallest version that fits at `ecl`.
* `mask` forces a mask (tests); otherwise the lowest penalty wins. Throws when
* it does not fit version 10.
*/
export function encodeQr(text: string, ecl: Ecl = "M", mask?: number): QrCode {
const bytes = utf8(text);
let ver = 1;
for (; ver <= MAX_VERSION; ver++) {
const countBits = ver <= 9 ? 8 : 16;
if (4 + countBits + bytes.length * 8 <= dataCodewords(ver, ecl) * 8) break;
}
if (ver > MAX_VERSION) throw new Error("qr: text too long");
// The bit stream: mode 0100 (byte), count, the bytes, terminator, padding.
const bits: number[] = [];
const put = (val: number, len: number) => {
for (let i = len - 1; i >= 0; i--) bits.push((val >>> i) & 1);
};
put(4, 4);
put(bytes.length, ver <= 9 ? 8 : 16);
for (const b of bytes) put(b, 8);
const capBits = dataCodewords(ver, ecl) * 8;
put(0, Math.min(4, capBits - bits.length));
put(0, (8 - (bits.length % 8)) % 8);
for (let pad = 0xec; bits.length < capBits; pad ^= 0xec ^ 0x11) put(pad, 8);
const data: number[] = [];
for (let i = 0; i < bits.length; i += 8) {
let b = 0;
for (let j = 0; j < 8; j++) b = (b << 1) | bits[i + j];
data.push(b);
}
// Error correction per block, then interleaved.
const nBlocks = BLOCKS[ecl][ver];
const eccLen = ECC_PER_BLOCK[ecl][ver];
const raw = Math.floor(rawModules(ver) / 8);
const nShort = nBlocks - (raw % nBlocks);
const shortLen = Math.floor(raw / nBlocks);
const div = rsDivisor(eccLen);
const blocks: number[][] = [];
for (let i = 0, k = 0; i < nBlocks; i++) {
const dat = data.slice(k, k + shortLen - eccLen + (i < nShort ? 0 : 1));
k += dat.length;
const ecc = rsRemainder(dat, div);
if (i < nShort) dat.push(0);
blocks.push(dat.concat(ecc));
}
const codewords: number[] = [];
for (let i = 0; i < blocks[0].length; i++) {
blocks.forEach((block, j) => {
if (i !== shortLen - eccLen || j >= nShort) codewords.push(block[i]);
});
}
const size = ver * 4 + 17;
const modules: boolean[][] = [];
const isFn: boolean[][] = [];
for (let y = 0; y < size; y++) {
modules.push(new Array<boolean>(size).fill(false));
isFn.push(new Array<boolean>(size).fill(false));
}
const setFn = (x: number, y: number, dark: boolean) => {
modules[y][x] = dark;
isFn[y][x] = true;
};
// Timing, finders, alignment, version.
for (let i = 0; i < size; i++) {
setFn(6, i, i % 2 === 0);
setFn(i, 6, i % 2 === 0);
}
const finder = (cx: number, cy: number) => {
for (let dy = -4; dy <= 4; dy++) {
for (let dx = -4; dx <= 4; dx++) {
const d = Math.max(Math.abs(dx), Math.abs(dy));
const x = cx + dx;
const y = cy + dy;
if (x >= 0 && x < size && y >= 0 && y < size) setFn(x, y, d !== 2 && d !== 4);
}
}
};
finder(3, 3);
finder(size - 4, 3);
finder(3, size - 4);
const align: number[] = [];
if (ver > 1) {
const n = Math.floor(ver / 7) + 2;
const step = Math.ceil((ver * 4 + 4) / (n * 2 - 2)) * 2;
align.push(6);
for (let pos = size - 7; align.length < n; pos -= step) align.splice(1, 0, pos);
}
for (let i = 0; i < align.length; i++) {
for (let j = 0; j < align.length; j++) {
const corner = (i === 0 && j === 0) || (i === 0 && j === align.length - 1) || (i === align.length - 1 && j === 0);
if (corner) continue;
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) setFn(align[i] + dx, align[j] + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
}
}
}
const drawFormat = (m: number) => {
const d = (FORMAT_BITS[ecl] << 3) | m;
let rem = d;
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
const f = ((d << 10) | rem) ^ 0x5412;
const bit = (i: number) => ((f >>> i) & 1) !== 0;
for (let i = 0; i <= 5; i++) setFn(8, i, bit(i));
setFn(8, 7, bit(6));
setFn(8, 8, bit(7));
setFn(7, 8, bit(8));
for (let i = 9; i < 15; i++) setFn(14 - i, 8, bit(i));
for (let i = 0; i < 8; i++) setFn(size - 1 - i, 8, bit(i));
for (let i = 8; i < 15; i++) setFn(8, size - 15 + i, bit(i));
setFn(8, size - 8, true);
};
drawFormat(0); // reserves the format area before the data goes in
if (ver >= 7) {
let rem = ver;
for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
const v = (ver << 12) | rem;
for (let i = 0; i < 18; i++) {
const dark = ((v >>> i) & 1) !== 0;
const a = size - 11 + (i % 3);
const b = Math.floor(i / 3);
setFn(a, b, dark);
setFn(b, a, dark);
}
}
// The codewords, in two-column zigzags from the bottom right.
let at = 0;
for (let right = size - 1; right >= 1; right -= 2) {
if (right === 6) right = 5;
for (let vert = 0; vert < size; vert++) {
for (let j = 0; j < 2; j++) {
const x = right - j;
const up = ((right + 1) & 2) === 0;
const y = up ? size - 1 - vert : vert;
if (!isFn[y][x] && at < codewords.length * 8) {
modules[y][x] = ((codewords[at >>> 3] >>> (7 - (at & 7))) & 1) !== 0;
at++;
}
}
}
}
const applyMask = (m: number) => {
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (isFn[y][x]) continue;
let inv: boolean;
switch (m) {
case 0: inv = (x + y) % 2 === 0; break;
case 1: inv = y % 2 === 0; break;
case 2: inv = x % 3 === 0; break;
case 3: inv = (x + y) % 3 === 0; break;
case 4: inv = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0; break;
case 5: inv = ((x * y) % 2) + ((x * y) % 3) === 0; break;
case 6: inv = (((x * y) % 2) + ((x * y) % 3)) % 2 === 0; break;
default: inv = (((x + y) % 2) + ((x * y) % 3)) % 2 === 0; break;
}
if (inv) modules[y][x] = !modules[y][x];
}
}
};
let chosen = mask ?? -1;
if (chosen < 0) {
let best = Infinity;
for (let m = 0; m < 8; m++) {
applyMask(m);
drawFormat(m);
const p = penalty(modules);
if (p < best) {
best = p;
chosen = m;
}
applyMask(m); // XOR again: undone
}
}
applyMask(chosen);
drawFormat(chosen);
return { version: ver, ecl, mask: chosen, size, modules };
}
/** The standard mask penalty (rules N1–N4). */
function penalty(m: boolean[][]): number {
const size = m.length;
let score = 0;
const addHistory = (run: number, hist: number[]) => {
if (hist[0] === 0) run += size; // light border before the first run
hist.pop();
hist.unshift(run);
};
const countFinders = (hist: number[]) => {
const n = hist[1];
const core = n > 0 && hist[2] === n && hist[3] === n * 3 && hist[4] === n && hist[5] === n;
return (core && hist[0] >= n * 4 && hist[6] >= n ? 1 : 0) + (core && hist[6] >= n * 4 && hist[0] >= n ? 1 : 0);
};
const terminate = (color: boolean, run: number, hist: number[]) => {
if (color) {
addHistory(run, hist);
run = 0;
}
run += size; // light border after the last run
addHistory(run, hist);
return countFinders(hist);
};
for (let pass = 0; pass < 2; pass++) {
for (let a = 0; a < size; a++) {
let color = false;
let run = 0;
const hist = [0, 0, 0, 0, 0, 0, 0];
for (let b = 0; b < size; b++) {
const v = pass === 0 ? m[a][b] : m[b][a];
if (v === color) {
run++;
if (run === 5) score += 3;
else if (run > 5) score++;
} else {
addHistory(run, hist);
if (!color) score += countFinders(hist) * 40;
color = v;
run = 1;
}
}
score += terminate(color, run, hist) * 40;
}
}
let dark = 0;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (m[y][x]) dark++;
if (y < size - 1 && x < size - 1) {
const c = m[y][x];
if (c === m[y][x + 1] && c === m[y + 1][x] && c === m[y + 1][x + 1]) score += 3;
}
}
}
const total = size * size;
const k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
return score + k * 10;
}
/** One SVG path ("M x y h1 v1 h-1 z" per dark module), offset by a `quiet`-module border. */
export function qrPath(code: QrCode, quiet = 4): string {
const parts: string[] = [];
for (let y = 0; y < code.size; y++) {
for (let x = 0; x < code.size; x++) {
if (code.modules[y][x]) parts.push(`M${x + quiet} ${y + quiet}h1v1h-1z`);
}
}
return parts.join("");
}
+2 -1
View File
@@ -150,7 +150,8 @@ int webui_cheats_mods_to_json(const char *json, size_t n, char *out, size_t cap,
/* A failed CHEATS_TOGGLE's cause ({"ok":false,"err":"<text>"}) as a token the page acts on:
* "game_not_running" (no game, or a different game than the one asked for), "no_cheats",
* "bad_index", "module_missing", else "toggle_failed". */
* "bad_index", "module_missing", "game_busy" (another debugger, e.g. the PC app's toggle at the same
* moment, is attached), else "toggle_failed". The texts matched are cheats.c's (cheats_toggle). */
const char *webui_cheats_toggle_error(const char *cause, size_t n);
/* Why /api/volumes/unmount refuses `p` before asking the helper, or NULL: "system_volume" for the console's
+22
View File
@@ -9,6 +9,7 @@
#ifndef PS5UPLOAD_WEBUI_SERVER_H
#define PS5UPLOAD_WEBUI_SERVER_H
#include <netinet/in.h>
#include <stddef.h>
#include <stdint.h>
#include "webui_http.h"
@@ -41,6 +42,27 @@ void webui_stop(void);
* retrying every 5 s. */
int webui_restart(int lan);
/* The listener heals itself (the accept thread, no other thread): every 5 s it checks for a resume
* from rest mode (a gap of 10 s or more on either clock) and, with LAN access on, for a change in
* the console's IPv4 addresses (DHCP, another network: the Host allowlist is refreshed); either one
* rebinds the listener (the token is kept). A listener whose poll or accept keeps failing for 5 s
* is rebound too. */
/* The console's LAN address a phone should open (a private one first, never loopback), from the
* running server's list; 1 and `out` set when LAN access is on and there is one, else 0 and "". */
int webui_lan_addr(char out[INET_ADDRSTRLEN]);
/* The watch's decisions, pure (tests): */
/* Did the console sleep between two checks? (monotonic and wall-clock ms at each) */
int webui_watch_resumed(uint64_t mono_prev, uint64_t mono_now, int64_t real_prev, int64_t real_now);
/* Has a listener failing since `err_since` (0 = not failing) failed long enough to rebind? */
int webui_watch_broken(uint64_t err_since, uint64_t now);
/* Do two address lists differ (as sets)? */
int webui_addrs_differ(const char (*a)[INET_ADDRSTRLEN], size_t na, const char (*b)[INET_ADDRSTRLEN], size_t nb);
/* The address to show for LAN access: private ranges first, then others, link-local last; never
* 127.x or 0.x. 1 and `out` set, else 0 and "". */
int webui_pick_lan_addr(const char (*addrs)[INET_ADDRSTRLEN], size_t n, char out[INET_ADDRSTRLEN]);
/* The token for this start ("" when the server is not running). */
const char *webui_token(void);
/* The token copied under the server's lock (webui_token() reads it unsynchronized): use this one
+14 -2
View File
@@ -29,6 +29,7 @@
#include <errno.h>
#include <fcntl.h>
#include <pthread.h>
#include <signal.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
@@ -1592,10 +1593,21 @@ int cheats_toggle(const char *title_id, int mod_index, int turn_on,
}
}
/* Attach and apply */
/* Attach and apply. A failed attach is told apart, because the console page (webui_codec.c
* 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
* 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
* moment, or another cheat tool): "is attached to the game". */
if (pt_attach(pid) != 0) {
int e = errno;
free(target_cf);
if (err) snprintf(err, err_cap, "pt_attach failed (errno=%d)", errno);
invalidate_rg_cache();
if (kill(pid, 0) != 0 && errno == ESRCH) {
if (err) snprintf(err, err_cap, "no game is currently running");
} else if (err) {
snprintf(err, err_cap, "pt_attach failed: another debugger or cheat tool is attached to the game (errno=%d)", e);
}
return -1;
}
+17 -7
View File
@@ -381,9 +381,10 @@ static void r_fs_stat(const char *b, size_t n, uint8_t *out, int fd) {
}
/* The entry's identity (device + inode, as strings: an inode can pass 2^53) so the page can tell
* "the item itself" from a different entry by more than its spelling: exFAT USB is
* case-insensitive, so /mnt/usb0/Games and /mnt/usb0/games are one folder. lstat (the
* entry itself, not a link's target) of the path the FS_STAT above already accepted. */
if (lstat(path, &ls) == 0)
* case-insensitive, so /mnt/usb0/Games and /mnt/usb0/games are one folder. stat(), like
* FS_STAT above (which follows links): a symlink to the destination is the destination, so a
* paste of that link into it is seen as "into itself". The path is the one FS_STAT accepted. */
if (stat(path, &ls) == 0)
k = snprintf(json, sizeof json,
"{\"kind\":\"%s\",\"size\":%llu,\"mtime\":%llu,\"mode\":%lu,\"dev\":\"%llu\",\"ino\":\"%llu\"}",
kind_name(s.kind), (unsigned long long)s.size, (unsigned long long)s.mtime, (unsigned long)s.mode,
@@ -977,14 +978,21 @@ void webui_api_settings_init(const webui_settings_t *s) {
/* tile_busy / tile_failed: the tile switch is applied on the tile thread after this reply (Sony calls
* take seconds), so the page re-reads /api/settings until tile_busy is false and then says so when
* the last ensure/remove failed (webui_tile_state). */
/* `addr`: with LAN access on, the console's address a phone opens ("Continue on your phone"; the
* page builds http://<addr>:<port>/ from it); "" when off or there is none. Dotted IPv4 only. */
static void send_settings(int fd, const webui_settings_t *s) {
char json[200];
char json[256], addr[INET_ADDRSTRLEN];
int busy, failed, k;
webui_tile_state(&busy, &failed);
if (!s->lan || !webui_lan_addr(addr)) addr[0] = '\0';
k = snprintf(json, sizeof json,
"{\"lan\":%s,\"tile\":%s,\"tile_busy\":%s,\"tile_failed\":%s,\"version\":\"%s\"}",
"{\"lan\":%s,\"tile\":%s,\"tile_busy\":%s,\"tile_failed\":%s,\"version\":\"%s\",\"addr\":\"%s\"}",
s->lan ? "true" : "false", s->tile ? "true" : "false", busy ? "true" : "false",
failed ? "true" : "false", PS5UPLOAD2_VERSION);
failed ? "true" : "false", PS5UPLOAD2_VERSION, addr);
if (k < 0 || (size_t)k >= sizeof json) {
send_error(fd, 500, "bad_settings_reply", 18);
return;
}
send_json(fd, 200, json, (size_t)k);
}
@@ -1142,7 +1150,9 @@ static void r_cheats_list(const char *b, size_t n, uint8_t *out, int fd) {
}
static int cheats_error_http(const char *token) {
if (strcmp(token, "game_not_running") == 0 || strcmp(token, "module_missing") == 0) return 409;
if (strcmp(token, "game_not_running") == 0 || strcmp(token, "module_missing") == 0 ||
strcmp(token, "game_busy") == 0)
return 409;
if (strcmp(token, "no_cheats") == 0) return 404;
if (strcmp(token, "bad_index") == 0) return 400;
return 500;
+1
View File
@@ -882,6 +882,7 @@ const char *webui_cheats_toggle_error(const char *cause, size_t n) {
if (strstr(msg, "no cheat file")) return "no_cheats";
if (strstr(msg, "index out of range")) return "bad_index";
if (strstr(msg, "is not loaded in the target process")) return "module_missing";
if (strstr(msg, "is attached to the game")) return "game_busy";
return "toggle_failed";
}
+17 -3
View File
@@ -188,14 +188,28 @@ static const char *status_text(int status) {
}
}
/* Does a header block ("Name: value\r\n"...) have a line starting with `name` (lowercase, ending
* in ':'), compared without regard to case? */
static int has_line(const char *h, const char *name) {
size_t n = strlen(name);
while (h && *h) {
size_t i = 0;
while (i < n && h[i] && lower(h[i]) == name[i]) i++;
if (i == n) return 1;
h = strstr(h, "\r\n");
if (h) h += 2;
}
return 0;
}
int webui_http_head(char *out, size_t cap, int status, const char *content_type,
size_t body_len, const char *extra_headers) {
int n, own_cache;
size_t used;
if (!out || cap == 0) return -1;
/* no-store unless the extra headers carry their own Cache-Control line (the strings files). */
own_cache = extra_headers && (strncmp(extra_headers, "Cache-Control:", 14) == 0 ||
strstr(extra_headers, "\r\nCache-Control:") != NULL);
/* no-store unless the extra headers carry their own Cache-Control line (the page, the strings
* files, the offline copy), in any letter case. */
own_cache = has_line(extra_headers, "cache-control:");
n = snprintf(out, cap,
"HTTP/1.1 %d %s\r\n"
"Content-Type: %s\r\n"
+161 -7
View File
@@ -47,6 +47,9 @@
#define WEBUI_DRAIN_MS 250u
#define WEBUI_STACK (256u * 1024u)
#define WEBUI_MAX_ADDRS 8
#define WEBUI_WATCH_MS 5000u /* the accept thread's listener check (resume, IP change) */
#define WEBUI_BROKEN_MS 5000u /* a listener failing this long is rebound */
#define WEBUI_RESUME_GAP_MS 10000u /* a clock gap this long between checks = a resume */
#define WEBUI_THREAD_NAME "ps5upload-web"
#define WEBUI_BUF_CAP (WEBUI_HEADER_CAP + WEBUI_BODY_CAP)
@@ -151,6 +154,69 @@ static size_t own_addrs(char out[WEBUI_MAX_ADDRS][INET_ADDRSTRLEN]) {
return n;
}
/* ---- listener watch (pure; webui_server.h) ------------------------------ */
int webui_watch_broken(uint64_t err_since, uint64_t now) {
return err_since != 0 && now >= err_since && now - err_since >= WEBUI_BROKEN_MS;
}
int webui_watch_resumed(uint64_t mono_prev, uint64_t mono_now, int64_t real_prev, int64_t real_now) {
uint64_t mono = mono_now >= mono_prev ? mono_now - mono_prev : 0;
int64_t real = real_now - real_prev;
/* The loop wakes every 200 ms: a long gap on either clock means the console slept (or the
* thread was frozen); a wall clock that ran far ahead of the monotonic one too (a clock that
* stops in rest mode). A clock set by hand looks the same: one harmless rebind. */
if (mono > WEBUI_RESUME_GAP_MS) return 1;
if (real > (int64_t)mono + (int64_t)WEBUI_RESUME_GAP_MS) return 1;
return 0;
}
int webui_addrs_differ(const char (*a)[INET_ADDRSTRLEN], size_t na, const char (*b)[INET_ADDRSTRLEN], size_t nb) {
size_t i, j;
if (na != nb) return 1;
for (i = 0; i < na; i++) {
for (j = 0; j < nb; j++)
if (strcmp(a[i], b[j]) == 0) break;
if (j == nb) return 1;
}
return 0;
}
int webui_pick_lan_addr(const char (*addrs)[INET_ADDRSTRLEN], size_t n, char out[INET_ADDRSTRLEN]) {
size_t i;
int best = -1, best_rank = 0;
for (i = 0; i < n; i++) {
const char *a = addrs[i];
int rank;
if (strncmp(a, "127.", 4) == 0 || strncmp(a, "0.", 2) == 0) continue;
/* a private LAN address first, then any other, link-local (no DHCP answer) last */
if (strncmp(a, "192.168.", 8) == 0 || strncmp(a, "10.", 3) == 0 ||
(strncmp(a, "172.", 4) == 0 && atoi(a + 4) >= 16 && atoi(a + 4) <= 31))
rank = 3;
else if (strncmp(a, "169.254.", 8) == 0)
rank = 1;
else
rank = 2;
if (rank > best_rank) {
best_rank = rank;
best = (int)i;
}
}
if (best < 0) {
out[0] = '\0';
return 0;
}
memcpy(out, addrs[best], INET_ADDRSTRLEN);
out[INET_ADDRSTRLEN - 1] = '\0';
return 1;
}
static int64_t real_ms(void) {
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
return (int64_t)ts.tv_sec * 1000 + (int64_t)ts.tv_nsec / 1000000;
}
/* Non-blocking listener on 127.0.0.1 or 0.0.0.0. -1 with errno on failure. */
static int bind_listener(uint16_t port, int lan) {
struct sockaddr_in a;
@@ -402,20 +468,62 @@ static void *worker_main(void *arg) {
/* ---- accept thread ------------------------------------------------------ */
/* Close the listener so the loop binds a fresh one (s->mu held). */
static void drop_listener_locked(srv_t *s) {
if (s->listen_fd >= 0) close(s->listen_fd);
s->listen_fd = -1;
s->bound = 0;
}
/* Every WEBUI_WATCH_MS: after a resume from rest mode, or (LAN access on) when the console's
* addresses changed (DHCP, another network), refresh the Host allowlist and rebind, as Payload
* Manager's watchdog does. One getifaddrs per check, on this thread: no thread, no allocation that
* outlives the call. */
static void watch_listener(srv_t *s, uint64_t *mono_prev, int64_t *real_prev) {
uint64_t mono = mono_ms();
int64_t real = real_ms();
int resumed, lan, changed = 0;
char addrs[WEBUI_MAX_ADDRS][INET_ADDRSTRLEN];
size_t n = 0;
resumed = webui_watch_resumed(*mono_prev, mono, *real_prev, real);
*mono_prev = mono;
*real_prev = real;
pthread_mutex_lock(&s->mu);
lan = s->lan;
pthread_mutex_unlock(&s->mu);
if (lan) n = own_addrs(addrs);
pthread_mutex_lock(&s->mu);
if (lan && s->lan && webui_addrs_differ((const char (*)[INET_ADDRSTRLEN])s->addrs, s->n_addrs,
(const char (*)[INET_ADDRSTRLEN])addrs, n)) {
memcpy(s->addrs, addrs, sizeof addrs);
s->n_addrs = n;
changed = 1;
}
if ((resumed || changed) && s->listen_fd >= 0) drop_listener_locked(s);
pthread_mutex_unlock(&s->mu);
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);
}
static void *accept_main(void *arg) {
srv_t *s = (srv_t *)arg;
unsigned fails = 0;
uint64_t next_watch = mono_ms() + WEBUI_WATCH_MS, mono_prev = mono_ms(), err_since = 0;
int64_t real_prev = real_ms();
proc_name_set_self(WEBUI_THREAD_NAME);
while (!stopping(s)) {
struct pollfd pf;
int fd, r, lan, fl;
if (mono_ms() >= next_watch) {
watch_listener(s, &mono_prev, &real_prev);
next_watch = mono_ms() + WEBUI_WATCH_MS;
}
pthread_mutex_lock(&s->mu);
if (s->rebind_req) {
s->rebind_req = 0;
if (s->listen_fd >= 0) close(s->listen_fd);
s->listen_fd = -1;
s->bound = 0;
drop_listener_locked(s);
fails = 0;
}
lan = s->lan;
@@ -458,17 +566,48 @@ static void *accept_main(void *arg) {
pf.events = POLLIN;
pf.revents = 0;
r = poll(&pf, 1, WEBUI_SLICE_MS);
if (r <= 0) {
if (r < 0 && errno != EINTR) nap_ms(50);
if (r == 0) {
err_since = 0; /* a quiet, healthy listener */
continue;
}
if (r < 0 || (pf.revents & (POLLERR | POLLHUP | POLLNVAL))) {
if (r < 0 && errno == EINTR) continue;
/* A broken listener (the network went away under it): rebind once it has failed for
* WEBUI_BROKEN_MS, instead of failing every slice forever. */
if (!err_since) err_since = mono_ms();
else if (webui_watch_broken(err_since, mono_ms())) {
fprintf(stderr, "webui: listener on port %u failing for %u ms; rebinding\n", (unsigned)s->port,
(unsigned)WEBUI_BROKEN_MS);
pthread_mutex_lock(&s->mu);
if (s->listen_fd == fd) drop_listener_locked(s);
pthread_mutex_unlock(&s->mu);
err_since = 0;
continue;
}
nap_ms(50);
continue;
}
r = accept(fd, NULL, NULL);
if (r < 0) {
/* 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. */
if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) nap_ms(50);
* server); back off briefly on the ones that would otherwise spin, and rebind when they
* go on for WEBUI_BROKEN_MS. */
if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR && errno != ECONNABORTED) {
if (!err_since) 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",
(unsigned)s->port, (unsigned)WEBUI_BROKEN_MS, errno);
pthread_mutex_lock(&s->mu);
if (s->listen_fd == fd) drop_listener_locked(s);
pthread_mutex_unlock(&s->mu);
err_since = 0;
continue;
}
nap_ms(50);
}
continue;
}
err_since = 0;
/* BSD hands the listener's O_NONBLOCK to the accepted socket; workers expect blocking
* sockets bounded by timeouts and poll. */
fl = fcntl(r, F_GETFL, 0);
@@ -691,3 +830,18 @@ void webui_token_copy(char out[65]) {
pthread_mutex_unlock(&g_mu);
out[64] = '\0';
}
int webui_lan_addr(char out[INET_ADDRSTRLEN]) {
srv_t *s;
int ok = 0;
out[0] = '\0';
pthread_mutex_lock(&g_mu);
s = g_srv;
if (s) {
pthread_mutex_lock(&s->mu);
if (s->lan) ok = webui_pick_lan_addr((const char (*)[INET_ADDRSTRLEN])s->addrs, s->n_addrs, out);
pthread_mutex_unlock(&s->mu);
}
pthread_mutex_unlock(&g_mu);
return ok;
}
+2
View File
@@ -794,6 +794,8 @@ static void cheats_toggle_errors(void) {
CHECK(strcmp(webui_cheats_toggle_error(c, strlen(c)), "bad_index") == 0);
c = "{\"ok\":false,\"err\":\"module \\\"x.prx\\\" is not loaded in the target process\"}";
CHECK(strcmp(webui_cheats_toggle_error(c, strlen(c)), "module_missing") == 0);
c = "{\"ok\":false,\"err\":\"pt_attach failed: another debugger or cheat tool is attached to the game (errno=16)\"}";
CHECK(strcmp(webui_cheats_toggle_error(c, strlen(c)), "game_busy") == 0);
c = "{\"ok\":false,\"err\":\"pt_attach failed (errno=1)\"}";
CHECK(strcmp(webui_cheats_toggle_error(c, strlen(c)), "toggle_failed") == 0);
CHECK(strcmp(webui_cheats_toggle_error(NULL, 0), "toggle_failed") == 0);
+8
View File
@@ -161,6 +161,14 @@ static void head_has_connection_close(void) {
/* "Cache-Control" inside another header's value is not a Cache-Control line. */
n = webui_http_head(out, sizeof out, 200, "text/plain", 0, "X-Note: Cache-Control: nope\r\n");
CHECK(n > 0 && strstr(out, "Cache-Control: no-store\r\n") != NULL);
/* Any letter case counts as the own line (HTTP header names are case-insensitive). */
n = webui_http_head(out, sizeof out, 200, "text/plain", 0, "cache-control: no-cache\r\n");
CHECK(n > 0 && strstr(out, "no-store") == NULL && strstr(out, "cache-control: no-cache\r\n") != NULL);
n = webui_http_head(out, sizeof out, 200, "text/plain", 0, "X-A: 1\r\nCACHE-CONTROL: no-cache\r\n");
CHECK(n > 0 && strstr(out, "no-store") == NULL);
/* A longer name that starts the same is another header. */
n = webui_http_head(out, sizeof out, 200, "text/plain", 0, "Cache-Controlled: x\r\n");
CHECK(n > 0 && strstr(out, "Cache-Control: no-store\r\n") != NULL);
CHECK(webui_http_head(out, 20, 200, "text/plain", 1, NULL) == -1);
CHECK(webui_http_head(out, sizeof out, 200, NULL, 1, NULL) > 0);
+46
View File
@@ -278,6 +278,50 @@ static void restart_keeps_token_and_serves(void) {
CHECK(request("/api/x", webui_token(), resp, sizeof resp) == 200);
}
/* The listener watch's decisions (pure). */
static void watch_decisions(void) {
char a[3][INET_ADDRSTRLEN] = { "127.0.0.1", "192.168.86.99", "169.254.3.4" };
char b[3][INET_ADDRSTRLEN] = { "192.168.86.99", "169.254.3.4", "127.0.0.1" };
char c[2][INET_ADDRSTRLEN] = { "127.0.0.1", "192.168.86.120" };
char d[2][INET_ADDRSTRLEN] = { "127.0.0.1", "169.254.9.9" };
char e[1][INET_ADDRSTRLEN] = { "127.0.0.1" };
char f[2][INET_ADDRSTRLEN] = { "8.8.4.4", "172.20.1.2" };
char out[INET_ADDRSTRLEN];
/* resume: a normal 5 s check is not one; a 10 s+ gap on either clock is */
CHECK(webui_watch_resumed(1000, 6000, 50000, 55000) == 0);
CHECK(webui_watch_resumed(1000, 6200, 50000, 55300) == 0); /* jitter */
CHECK(webui_watch_resumed(1000, 31000, 50000, 80000) == 1); /* the thread was frozen */
CHECK(webui_watch_resumed(1000, 6000, 50000, 3650000) == 1); /* monotonic stopped in rest mode */
CHECK(webui_watch_resumed(6000, 1000, 0, 0) == 0); /* never underflows */
/* broken listener: only after 5 s of failures */
CHECK(webui_watch_broken(0, 999999) == 0);
CHECK(webui_watch_broken(1000, 5999) == 0);
CHECK(webui_watch_broken(1000, 6000) == 1);
/* address sets */
CHECK(webui_addrs_differ((const char (*)[INET_ADDRSTRLEN])a, 3, (const char (*)[INET_ADDRSTRLEN])b, 3) == 0);
CHECK(webui_addrs_differ((const char (*)[INET_ADDRSTRLEN])a, 3, (const char (*)[INET_ADDRSTRLEN])c, 2) == 1);
CHECK(webui_addrs_differ((const char (*)[INET_ADDRSTRLEN])c, 2, (const char (*)[INET_ADDRSTRLEN])d, 2) == 1); /* DHCP moved */
CHECK(webui_addrs_differ((const char (*)[INET_ADDRSTRLEN])e, 0, (const char (*)[INET_ADDRSTRLEN])e, 0) == 0);
/* the address a phone opens */
CHECK(webui_pick_lan_addr((const char (*)[INET_ADDRSTRLEN])a, 3, out) == 1 && strcmp(out, "192.168.86.99") == 0);
CHECK(webui_pick_lan_addr((const char (*)[INET_ADDRSTRLEN])d, 2, out) == 1 && strcmp(out, "169.254.9.9") == 0);
CHECK(webui_pick_lan_addr((const char (*)[INET_ADDRSTRLEN])e, 1, out) == 0 && out[0] == '\0');
CHECK(webui_pick_lan_addr((const char (*)[INET_ADDRSTRLEN])f, 2, out) == 1 && strcmp(out, "172.20.1.2") == 0);
}
/* The watch runs on the accept thread every 5 s: across a check the server keeps serving (loopback:
* nothing to rebind), and LAN mode off means no address for a phone. */
static void watch_cycle_keeps_serving(void) {
char resp[2048], addr[INET_ADDRSTRLEN];
sleep_ms(5600);
CHECK(request("/api/x", webui_token(), resp, sizeof resp) == 200);
CHECK(webui_lan_addr(addr) == 0 && addr[0] == '\0');
}
int main(void) {
signal(SIGPIPE, SIG_IGN);
g_port = free_port();
@@ -294,6 +338,8 @@ int main(void) {
stop_then_start_rebinds_with_new_token();
stop_while_workers_busy();
restart_keeps_token_and_serves();
watch_decisions();
watch_cycle_keeps_serving();
webui_stop();
if (failures) {
fprintf(stderr, "webui_server_selftest: %d failure(s)\n", failures);