mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-19 02:20:02 -05:00
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:
1 parent
4ff889720d
commit
f77c33e579
30 files changed
+1297
-42
No files matched your search
@@ -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 \
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user