console page: the pointer is the focus, the whole DualSense in one table, an input log, full screen, TV scale

The PS5 browser drives an on-screen pointer and ✕ clicks at it, so the page
now treats hover as focus: the ring and what ✕ activates are the same
element. After a key move the pointer is parked (drift under 6 px is
ignored) and a click there activates the focused control instead.

nav.ts's button table covers every DualSense button with its action, the
keys/keyCodes it may arrive as and verified:false until measured: L2/R2 page,
L3 top, R3 rail, OPTIONS help, Create refresh, touchpad full screen, PS/Mute
system. The hint bar shows ✕/○ always, the rest once verified.

POST /api/input-log appends compact, privacy-neutral event lines to a 64 KiB
ring at /data/ps5upload/webui-input.log (same-directory rewrite, bounded
body, 429 under 500 ms); the page sends only while setInputLogEnabled(true).

Full screen on the first activation (standard, then webkit-prefixed), with a
Settings switch hidden where unsupported. Type scales with the viewport
(26 px at 1540x700). Raw page budget 640 KiB.

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 08:25:31 -07:00
1 parent 20f8809415
commit c1c74dece8
51 files changed
+2156 -116

No files matched your search

+6
View File
@@ -917,6 +917,12 @@ test-payload: payload
$(PAYLOAD_DIR)/ava1/gen/ava1_gen.c $(PAYLOAD_DIR)/ava1/ava1_wire.c
@/tmp/ps5upload-webui-settings-selftest
@echo "✓ a missing or garbled settings file falls back to defaults; the tile is written only when it differs"
@echo "Running webui input log self-test (host build)..."
@cc -O2 -Wall -Wextra -Werror -I$(PAYLOAD_DIR)/include \
-o /tmp/ps5upload-webui-inputlog-selftest \
$(PAYLOAD_DIR)/tests/webui_inputlog_selftest.c $(PAYLOAD_DIR)/src/webui_inputlog.c
@/tmp/ps5upload-webui-inputlog-selftest
@echo "✓ the controller input log takes only plain short lines and never grows past its 64 KiB ring"
@echo "Running AVA1 in-flight RPC gate self-test (host build)..."
@cc -O2 -Wall -Wextra -Werror -pthread -I$(PAYLOAD_DIR)/ava1 \
-o /tmp/ps5upload-ava1-rpc-gate-selftest \
+11 -1
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { call, isOffline, onTokenExpired } from "./api";
import { useBackLevel } from "./backHistory";
import { toggleFullscreen } from "./fullscreen";
import { focusElement, onNavAction, type InputMap } from "./nav";
import { SECTIONS, type SectionContext, type SectionParams } from "./sections";
import { HintBar } from "./shell/HintBar";
@@ -69,10 +70,19 @@ export function App({ inputMap }: { inputMap: InputMap }) {
if (el) focusElement(el);
}, []);
// L1/R1 step through the sections; ○ goes from the content to the rail, then to Home.
// L1/R1 step through the sections; ○ goes from the content to the rail, then to Home;
// Create re-reads the status (sections re-read their own data first); the touchpad toggles full screen.
useEffect(
() =>
onNavAction((action) => {
if (action === "refresh") {
setStatusTick((n) => n + 1);
return true;
}
if (action === "fullscreen") {
toggleFullscreen();
return true;
}
const i = SECTIONS.findIndex((s) => s.id === active);
if (action === "prevSection" || action === "nextSection") {
const step = action === "nextSection" ? 1 : SECTIONS.length - 1;
+12 -16
View File
@@ -11,23 +11,19 @@
*/
html {
/* TV type: 22px at 1920×1080, the PS5 browser's viewport. */
/*
* TV type that scales with the viewport: the PS5 browser's page is 1540×700
* (26 px here, 27 rem tall), a 1920×1080 window gets ~34 px. The smaller of
* the width and height rules wins, so a short window never pushes a
* section's primary actions below the fold. clamp/min: Safari 13.1+.
*/
font-size: 22px;
font-size: clamp(16px, min(1.75vw, 3.75vh), 44px);
background: var(--atmosphere-base);
color: var(--color-text);
font-family: var(--font-sans);
-webkit-text-size-adjust: 100%;
}
@media (max-width: 1280px) {
html {
font-size: 18px;
}
}
@media (min-width: 2400px) {
html {
font-size: 30px;
}
}
*,
*::before,
@@ -74,7 +70,7 @@ button {
.cw-top {
display: flex;
align-items: center;
padding: 0.6rem 1.2rem;
padding: 0.35rem 1rem;
border-bottom: 1px solid var(--color-border);
}
.cw-top img {
@@ -143,12 +139,12 @@ button {
.cw-content {
flex: 1;
min-width: 0;
padding: 1rem 1.4rem;
padding: 0.6rem 1.2rem 1rem;
overflow-y: auto;
}
.cw-h1 {
margin: 0 0 0.8rem;
font-size: 1.5rem;
margin: 0 0 0.5rem;
font-size: 1.35rem;
font-weight: 600;
}
@@ -156,7 +152,7 @@ button {
.cw-hints {
display: flex;
align-items: center;
padding: 0.4rem 1.2rem;
padding: 0.3rem 1rem;
color: var(--color-muted);
font-size: 0.8rem;
border-top: 1px solid var(--color-border);
+6 -1
View File
@@ -588,6 +588,11 @@ export function Files({ ctx }: { ctx: SectionContext }) {
useEffect(
() =>
onNavAction((a) => {
if (a === "refresh") {
// Create: read the folder again (App's fallback still re-reads the status).
if (path != null) refresh();
return false;
}
const el = root.current;
const active = document.activeElement;
if (!el || !active || !el.contains(active)) return false;
@@ -613,7 +618,7 @@ export function Files({ ctx }: { ctx: SectionContext }) {
}
return false;
}),
[path, selecting, selected, goUp, onToggleItem, onToggleSelecting],
[path, selecting, selected, goUp, onToggleItem, onToggleSelecting, refresh],
);
const onDismiss = useCallback(() => {
+144
View File
@@ -0,0 +1,144 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
fullscreenApi,
fullscreenPref,
fullscreenSupported,
installFullscreen,
setFullscreenPref,
toggleFullscreen,
type FullscreenDoc,
} from "./fullscreen";
function doc(kind: "standard" | "webkit" | "both" | "none", enabled = true) {
const calls: string[] = [];
const d: FullscreenDoc = { documentElement: {} as FullscreenDoc["documentElement"] };
const el = d.documentElement as unknown as Record<string, unknown>;
if (kind === "standard" || kind === "both") {
el.requestFullscreen = () => {
calls.push("standard");
d.fullscreenElement = el as unknown as Element;
return Promise.resolve();
};
d.fullscreenEnabled = enabled;
d.exitFullscreen = () => {
calls.push("exit");
d.fullscreenElement = null;
return Promise.resolve();
};
}
if (kind === "webkit" || kind === "both") {
el.webkitRequestFullscreen = () => {
calls.push("webkit");
d.webkitFullscreenElement = el as unknown as Element;
};
d.webkitFullscreenEnabled = enabled;
d.webkitExitFullscreen = () => {
calls.push("webkit-exit");
d.webkitFullscreenElement = null;
};
}
return { d, calls };
}
function storage(init: Record<string, string> = {}, broken = false) {
const m = new Map(Object.entries(init));
vi.stubGlobal("localStorage", {
getItem: (k: string) => {
if (broken) throw new Error("denied");
return m.has(k) ? m.get(k) : null;
},
setItem: (k: string, v: string) => {
if (broken) throw new Error("denied");
m.set(k, v);
},
});
return m;
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("full screen", () => {
it("prefers the standard API, falls back to the webkit-prefixed one", () => {
expect(fullscreenApi(doc("both").d)).toBe("standard");
expect(fullscreenApi(doc("webkit").d)).toBe("webkit");
expect(fullscreenApi(doc("none").d)).toBeNull();
});
it("is unsupported when the API is missing or the browser disables it", () => {
expect(fullscreenSupported(doc("webkit").d)).toBe(true);
expect(fullscreenSupported(doc("none").d)).toBe(false);
expect(fullscreenSupported(doc("standard", false).d)).toBe(false);
});
it("the setting defaults to on, and survives broken storage", () => {
storage();
expect(fullscreenPref()).toBe(true);
storage({ "ps5upload.console.fullscreen": "0" });
expect(fullscreenPref()).toBe(false);
storage({}, true);
expect(fullscreenPref()).toBe(true);
expect(() => setFullscreenPref(false, doc("none").d)).not.toThrow();
});
it("enters on the first user activation with the prefixed API, only once", async () => {
storage();
const { d, calls } = doc("webkit");
const listeners: Record<string, (() => void)[]> = {};
const win = {
addEventListener: (t: string, fn: () => void) => (listeners[t] ??= []).push(fn),
removeEventListener: (t: string, fn: () => void) => {
listeners[t] = (listeners[t] ?? []).filter((f) => f !== fn);
},
};
installFullscreen(win, d);
for (const fn of listeners.click ?? []) fn();
await Promise.resolve();
expect(calls).toEqual(["webkit"]);
for (const fn of listeners.click ?? []) fn();
for (const fn of listeners.keydown ?? []) fn();
await Promise.resolve();
expect(calls).toEqual(["webkit"]);
});
it("does nothing on activation when the setting is off", async () => {
storage({ "ps5upload.console.fullscreen": "0" });
const { d, calls } = doc("standard");
const listeners: Record<string, (() => void)[]> = {};
installFullscreen({ addEventListener: (t: string, fn: () => void) => (listeners[t] ??= []).push(fn), removeEventListener: () => {} }, d);
for (const fn of listeners.click ?? []) fn();
await Promise.resolve();
expect(calls).toEqual([]);
});
it("a refused request is tried again on the next activation", async () => {
storage();
const { d } = doc("standard");
let n = 0;
(d.documentElement as unknown as Record<string, unknown>).requestFullscreen = () => {
n++;
return n === 1 ? Promise.reject(new Error("no")) : Promise.resolve();
};
const listeners: Record<string, (() => void)[]> = {};
installFullscreen({ addEventListener: (t: string, fn: () => void) => (listeners[t] ??= []).push(fn), removeEventListener: () => {} }, d);
for (const fn of listeners.click ?? []) fn();
await new Promise((r) => setTimeout(r, 0));
for (const fn of listeners.click ?? []) fn();
await new Promise((r) => setTimeout(r, 0));
expect(n).toBe(2);
});
it("the switch and the touchpad toggle enter and leave", async () => {
const m = storage();
const { d, calls } = doc("standard");
setFullscreenPref(false, d);
expect(m.get("ps5upload.console.fullscreen")).toBe("0");
setFullscreenPref(true, d);
await Promise.resolve();
expect(calls).toEqual(["standard"]);
toggleFullscreen(d);
await Promise.resolve();
expect(calls).toEqual(["standard", "exit"]);
});
});
+147
View File
@@ -0,0 +1,147 @@
/**
* Full screen for the console page: the PS5 browser's own bars take part of a
* 1540×700 viewport, so the page asks for full screen on the first user
* activation (a click or a key: browsers only grant it inside one) while the
* Settings switch is on (the default). The standard API first, then WebKit's
* prefixed one. Where neither exists, or the browser disables it, nothing is
* asked, Settings hides the switch and the layout simply scales to the viewport.
*/
/** The parts of `document` this module uses (tests pass a stand-in). */
export interface FullscreenDoc {
documentElement: Element & {
requestFullscreen?: () => Promise<void>;
webkitRequestFullscreen?: () => void;
};
fullscreenEnabled?: boolean;
webkitFullscreenEnabled?: boolean;
fullscreenElement?: Element | null;
webkitFullscreenElement?: Element | null;
exitFullscreen?: () => Promise<void>;
webkitExitFullscreen?: () => void;
}
interface Listener {
addEventListener(type: string, fn: () => void, capture?: boolean): void;
removeEventListener(type: string, fn: () => void, capture?: boolean): void;
}
const STORAGE_KEY = "ps5upload.console.fullscreen";
/** Activations that may carry the request. */
const ACTIVATIONS = ["click", "keydown"];
function currentDoc(): FullscreenDoc {
return document as unknown as FullscreenDoc;
}
/** Which API the browser has: the standard one, WebKit's prefixed one, or none. */
export function fullscreenApi(doc: FullscreenDoc = currentDoc()): "standard" | "webkit" | null {
const el = doc.documentElement;
if (el && typeof el.requestFullscreen === "function") return "standard";
if (el && typeof el.webkitRequestFullscreen === "function") return "webkit";
return null;
}
/** Whether the page can go full screen at all (an API, and the browser does not disable it). */
export function fullscreenSupported(doc: FullscreenDoc = currentDoc()): boolean {
const api = fullscreenApi(doc);
if (api === "standard") return doc.fullscreenEnabled !== false;
if (api === "webkit") return doc.webkitFullscreenEnabled !== false;
return false;
}
export function isFullscreen(doc: FullscreenDoc = currentDoc()): boolean {
return !!(doc.fullscreenElement || doc.webkitFullscreenElement);
}
/** Ask for full screen; resolves true when the browser accepted (or did not say no). */
export function enterFullscreen(doc: FullscreenDoc = currentDoc()): Promise<boolean> {
if (!fullscreenSupported(doc)) return Promise.resolve(false);
try {
if (fullscreenApi(doc) === "standard") {
return Promise.resolve(doc.documentElement.requestFullscreen!()).then(
() => true,
() => false,
);
}
doc.documentElement.webkitRequestFullscreen!(); // returns nothing; a refusal is an event
return Promise.resolve(true);
} catch {
return Promise.resolve(false);
}
}
export function exitFullscreen(doc: FullscreenDoc = currentDoc()): void {
try {
if (doc.fullscreenElement && typeof doc.exitFullscreen === "function") {
doc.exitFullscreen().catch(() => {});
} else if (doc.webkitFullscreenElement && typeof doc.webkitExitFullscreen === "function") {
doc.webkitExitFullscreen();
}
} catch {
// already left
}
}
/** The Settings switch: on unless the viewer turned it off (also when storage fails). */
export function fullscreenPref(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) !== "0";
} catch {
return true;
}
}
/**
* Flip the switch. Called from the switch's own click, a user activation, so
* turning it on goes full screen at once; turning it off leaves full screen.
*/
export function setFullscreenPref(on: boolean, doc: FullscreenDoc = currentDoc()): void {
try {
localStorage.setItem(STORAGE_KEY, on ? "1" : "0");
} catch {
// remembered for this page only
}
if (on) void enterFullscreen(doc);
else exitFullscreen(doc);
}
/** The touchpad button: in or out of full screen (from a key, so inside a user activation). */
export function toggleFullscreen(doc: FullscreenDoc = currentDoc()): void {
if (isFullscreen(doc)) exitFullscreen(doc);
else void enterFullscreen(doc);
}
/**
* Go full screen on the first user activation when the switch is on. A refused
* request is tried again on the next activation (at most MAX_TRIES); once full
* screen was entered, or the page is already full screen, the listeners go.
* Leaving full screen later (the browser's own way) is respected: no re-entry.
*/
const MAX_TRIES = 3;
export function installFullscreen(win: Listener = window, doc: FullscreenDoc = currentDoc()): () => void {
let tries = 0;
let pending = false;
let removed = false;
const remove = () => {
if (removed) return;
removed = true;
for (const t of ACTIVATIONS) win.removeEventListener(t, onActivation, true);
};
function onActivation() {
if (removed || pending) return;
if (!fullscreenPref() || !fullscreenSupported(doc) || isFullscreen(doc)) {
if (isFullscreen(doc) || !fullscreenSupported(doc)) remove();
return;
}
tries++;
pending = true;
void enterFullscreen(doc).then((ok) => {
pending = false;
if (ok || tries >= MAX_TRIES) remove();
});
}
for (const t of ACTIVATIONS) win.addEventListener(t, onActivation, true);
return remove;
}
+18 -4
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { FolderOpen, Gamepad2, HardDrive, Moon, RotateCw, Usb, X } from "lucide-react";
import { formatBytes } from "../../lib/format";
import { ApiError, call, isOffline } from "../api";
import { onNavAction } from "../nav";
import type { SectionContext } from "../sections";
import type { ConsoleStatus } from "../shell/TopBar";
import { t } from "../strings";
@@ -333,7 +334,7 @@ export function HomeView(p: HomeViewProps) {
/* ---- the section ---- */
function useVolumes(): { volumes: Volume[] | null; error: boolean } {
function useVolumes(tick: number): { volumes: Volume[] | null; error: boolean } {
const [volumes, setVolumes] = useState<Volume[] | null>(null);
const [error, setError] = useState(false);
useEffect(() => {
@@ -360,20 +361,33 @@ function useVolumes(): { volumes: Volume[] | null; error: boolean } {
live = false;
if (timer !== undefined) clearTimeout(timer);
};
}, []);
}, [tick]);
return { volumes, error };
}
/** The Home section: the running game, tasks, storage and quick actions. */
export function Home({ ctx }: { ctx: SectionContext }) {
const { jobs, loaded, error: jobsError, cancel } = useJobs();
const { volumes, error: volumesError } = useVolumes();
const { jobs, loaded, error: jobsError, cancel, refresh: refreshJobs } = useJobs();
const [volumesTick, setVolumesTick] = useState(0);
const { volumes, error: volumesError } = useVolumes(volumesTick);
const [ask, setAsk] = useState<Ask | null>(null);
const [busy, setBusy] = useState(false);
const [askError, setAskError] = useState<string | null>(null);
const [message, setMessage] = useState<Message | null>(null);
const { refreshStatus, openSection, canOpen } = ctx;
// Create (refresh): read the tasks and drives again; App's fallback re-reads the status.
useEffect(
() =>
onNavAction((a) => {
if (a !== "refresh") return false;
refreshJobs();
setVolumesTick((n) => n + 1);
return false;
}),
[refreshJobs],
);
useEffect(() => {
if (!message) return;
const timer = setTimeout(() => setMessage(null), MESSAGE_MS);
+7 -1
View File
@@ -126,6 +126,8 @@ export interface JobsState {
error: unknown;
/** Stop job `id`; throws the ApiError when the helper refuses. Re-reads either way. */
cancel(id: string): Promise<void>;
/** Read the jobs again now (Create = refresh). */
refresh(): void;
}
export function useJobs(): JobsState {
@@ -159,5 +161,9 @@ export function useJobs(): JobsState {
}
}, []);
return { jobs, loaded, error, cancel };
const refresh = useCallback(() => {
poller.current?.refresh();
}, []);
return { jobs, loaded, error, cancel, refresh };
}
+148
View File
@@ -0,0 +1,148 @@
import { describe, expect, it, vi } from "vitest";
import {
createInputLog,
describeEvent,
INPUT_LOG_MAX_BODY,
INPUT_LOG_MAX_QUEUE,
takeBatch,
} from "./inputLog";
const target = (tagName: string, attrs: Record<string, string> = {}, className = "") => ({
tagName,
className,
type: attrs.type,
isContentEditable: false,
getAttribute: (a: string) => (a in attrs ? attrs[a] : null),
});
/** What the helper accepts in a line (webui_inputlog.c): printable ASCII without quote or backslash. */
const SAFE = /^[\x20-\x7e]+$/;
describe("describeEvent", () => {
it("logs a key's key, code and keyCode and the element's kind", () => {
const line = describeEvent(
{ type: "keydown", key: "Enter", code: "Enter", keyCode: 13, target: target("BUTTON", {}, "cw-focusable cw-rail-item") },
1234,
);
expect(line).toBe("1234 keydown k=Enter c=Enter kc=13 el=button.cw-focusable");
});
it("never logs what is typed into a text field", () => {
const line = describeEvent(
{ type: "keydown", key: "s", code: "KeyS", keyCode: 83, target: target("INPUT", { type: "text" }) },
5,
);
expect(line).toBe("5 keydown k=char c=char kc=0 el=input");
});
it("logs a pointer's button and position, rounded", () => {
const line = describeEvent(
{ type: "click", button: 0, clientX: 10.6, clientY: 20.2, detail: 1, target: target("DIV") },
9,
);
expect(line).toBe("9 click b=0 x=11 y=20 d=1 el=div");
});
it("logs no file names: data attributes and text are not read", () => {
const line = describeEvent(
{ type: "mousedown", button: 0, clientX: 1, clientY: 2, detail: 1, target: target("BUTTON", { "data-entry": "secret.pkg", "aria-label": "secret.pkg" }, "cw-file-main") },
1,
)!;
expect(line).not.toContain("secret");
expect(line).toMatch(SAFE);
});
it("odd key names are reduced to the characters the helper accepts", () => {
const line = describeEvent({ type: "keyup", key: 'A"\\b c', code: "", keyCode: 0, target: null }, 1)!;
expect(line).toMatch(SAFE);
expect(line).not.toMatch(/["\\]/);
});
});
describe("takeBatch", () => {
it("takes lines from the front while the JSON body stays within 4 KiB", () => {
const queue = Array.from({ length: 200 }, (_, i) => `${i} keydown k=ArrowDown c=ArrowDown kc=40 el=button.cw-focusable`);
const batch = takeBatch(queue);
expect(JSON.stringify({ events: batch }).length).toBeLessThanOrEqual(INPUT_LOG_MAX_BODY);
expect(batch[0]).toMatch(/^0 /);
expect(queue.length + batch.length).toBe(200);
expect(queue[0]).toBe(`${batch.length} keydown k=ArrowDown c=ArrowDown kc=40 el=button.cw-focusable`);
});
});
describe("createInputLog", () => {
function setup() {
vi.useFakeTimers();
let now = 0;
const sent: string[][] = [];
const log = createInputLog({
send: (lines) => {
sent.push(lines);
return Promise.resolve();
},
now: () => now,
});
return {
log,
sent,
advance: async (ms: number) => {
now += ms;
await vi.advanceTimersByTimeAsync(ms);
},
};
}
it("sends nothing while disabled (the default)", async () => {
const { log, sent, advance } = setup();
log.record({ type: "keydown", key: "Enter", code: "Enter", keyCode: 13, target: null });
await advance(5000);
expect(sent).toEqual([]);
vi.useRealTimers();
});
it("batches events and sends at most one request a second", async () => {
const { log, sent, advance } = setup();
log.setEnabled(true);
for (let i = 0; i < 3; i++) log.record({ type: "keydown", key: "ArrowDown", code: "ArrowDown", keyCode: 40, target: null });
await advance(10);
expect(sent.length).toBe(1);
expect(sent[0].length).toBe(3);
log.record({ type: "keyup", key: "ArrowDown", code: "ArrowDown", keyCode: 40, target: null });
await advance(500);
expect(sent.length).toBe(1); // too soon after the last request
await advance(600);
expect(sent.length).toBe(2);
log.setEnabled(false);
log.record({ type: "keyup", key: "x", code: "KeyX", keyCode: 88, target: null });
await advance(3000);
expect(sent.length).toBe(2);
vi.useRealTimers();
});
it("keeps a bounded queue and says how many events it dropped", async () => {
const { log, sent, advance } = setup();
log.setEnabled(true);
for (let i = 0; i < INPUT_LOG_MAX_QUEUE + 50; i++)
log.record({ type: "keydown", key: "ArrowDown", code: "ArrowDown", keyCode: 40, target: null });
await advance(30_000);
const lines = sent.flat();
expect(lines.length).toBe(INPUT_LOG_MAX_QUEUE + 1);
expect(lines[lines.length - 1]).toMatch(/ dropped n=50$/);
for (const batch of sent) expect(JSON.stringify({ events: batch }).length).toBeLessThanOrEqual(INPUT_LOG_MAX_BODY);
vi.useRealTimers();
});
it("samples pointer moves (at most four a second)", async () => {
const { log, sent, advance } = setup();
log.setEnabled(true);
for (let i = 0; i < 100; i++) {
log.record({ type: "mousemove", button: 0, clientX: i, clientY: 0, detail: 0, target: null });
await advance(10);
}
await advance(2000);
const moves = sent.flat().filter((l) => l.includes(" mousemove "));
expect(moves.length).toBeLessThanOrEqual(5);
expect(moves.length).toBeGreaterThanOrEqual(3);
vi.useRealTimers();
});
});
+246
View File
@@ -0,0 +1,246 @@
/**
* The input log: what the PS5 browser really delivers for each controller
* button, measured on the console itself. While enabled the page records every
* key, pointer, wheel and history event it sees as one compact line and sends
* them in batches to POST /api/input-log, which appends them to the helper's
* 64 KiB ring file /data/ps5upload/webui-input.log (webui_inputlog.c).
*
* Off by default; the Controls screen (Task R5) turns it on while it is open
* with setInputLogEnabled(true).
*
* Privacy: a line holds the event type, key/code/keyCode, the pointer's button
* and position, and the element's tag plus its first cw- class (static code
* names). Never text, file names, paths, data attributes or labels; a key
* typed into a text field is logged as "char".
*
* Bounds: at most one request a second, each body at most 4 KiB, a queue of at
* most INPUT_LOG_MAX_QUEUE lines (extra events are counted, then reported as
* one "dropped n=N" line), pointer moves sampled at most four a second.
*/
import { call } from "./api";
export const INPUT_LOG_ROUTE = "/api/input-log";
export const INPUT_LOG_FLUSH_MS = 1000;
export const INPUT_LOG_MAX_BODY = 4096;
export const INPUT_LOG_MAX_QUEUE = 200;
/** The helper refuses longer lines (webui_inputlog.h WEBUI_INPUTLOG_LINE_MAX). */
export const INPUT_LOG_MAX_LINE = 160;
const MOVE_SAMPLE_MS = 250;
/** `{"events":[]}` */
const BODY_OVERHEAD = 13;
/** The event fields read (a real Event has them; tests pass plain objects). */
export interface LoggedEvent {
type: string;
target?: unknown;
key?: string;
code?: string;
keyCode?: number;
repeat?: boolean;
button?: number;
clientX?: number;
clientY?: number;
detail?: number;
deltaX?: number;
deltaY?: number;
deltaMode?: number;
}
interface ElementLike {
tagName?: string;
className?: unknown;
type?: string;
isContentEditable?: boolean;
}
/** Only the characters the helper accepts, nothing that could be read as structure. */
function token(s: string, max = 24): string {
const t = s.replace(/[^A-Za-z0-9_.:+-]/g, "_").slice(0, max);
return t || "-";
}
function isTextField(el: ElementLike | null): boolean {
if (!el || typeof el.tagName !== "string") return false;
if (el.isContentEditable || el.tagName === "TEXTAREA") return true;
if (el.tagName !== "INPUT") return false;
return !["button", "checkbox", "radio", "submit", "reset", "range", "color", "file"].includes(el.type ?? "text");
}
/** The element's kind: tag and its first cw- class, e.g. button.cw-focusable. */
function role(el: ElementLike | null): string {
if (!el || typeof el.tagName !== "string") return "-";
const tag = token(el.tagName.toLowerCase(), 12);
const cls = typeof el.className === "string" ? el.className.split(/\s+/).find((c) => c.startsWith("cw-")) : undefined;
return cls ? `${tag}.${token(cls, 24)}` : tag;
}
const round = (n: number | undefined) => Math.round(n ?? 0);
/** One event as a line, e.g. "1234 keydown k=Enter c=Enter kc=13 el=button.cw-focusable"; null to skip. */
export function describeEvent(e: LoggedEvent, t: number): string | null {
const el = (e.target ?? null) as ElementLike | null;
const head = `${Math.max(0, Math.round(t))} ${token(e.type, 24)}`;
let line: string;
if (e.type === "keydown" || e.type === "keyup") {
const key = e.key ?? "";
const hidden = isTextField(el) && key.length === 1;
const k = hidden
? "char"
: key.length !== 1
? token(key)
: /^[A-Za-z0-9]$/.test(key)
? key
: `U+${key.charCodeAt(0).toString(16)}`;
const c = hidden ? "char" : token(e.code ?? "");
const kc = hidden ? 0 : (e.keyCode ?? 0);
line = `${head} k=${k} c=${c} kc=${kc}${e.repeat ? " r=1" : ""} el=${role(el)}`;
} else if (e.type === "wheel") {
line = `${head} dx=${round(e.deltaX)} dy=${round(e.deltaY)} dm=${e.deltaMode ?? 0} el=${role(el)}`;
} else if (typeof e.clientX === "number") {
line = `${head} b=${e.button ?? 0} x=${round(e.clientX)} y=${round(e.clientY)} d=${e.detail ?? 0} el=${role(el)}`;
} else {
line = head;
}
return line.slice(0, INPUT_LOG_MAX_LINE);
}
/** Take lines from the front of `queue` while {"events":[...]} stays within `maxBytes`. */
export function takeBatch(queue: string[], maxBytes = INPUT_LOG_MAX_BODY): string[] {
let size = BODY_OVERHEAD;
let n = 0;
while (n < queue.length) {
const add = queue[n].length + 2 + (n ? 1 : 0); // quotes, comma (lines are ASCII, nothing to escape)
if (size + add > maxBytes) break;
size += add;
n++;
}
return queue.splice(0, n);
}
export interface InputLogDeps {
send: (lines: string[]) => Promise<unknown>;
now: () => number;
}
export interface InputLog {
record(e: LoggedEvent): void;
setEnabled(on: boolean): void;
enabled(): boolean;
}
export function createInputLog(deps: InputLogDeps): InputLog {
let on = false;
let t0 = 0;
let queue: string[] = [];
let dropped = 0;
let lastSend = -Infinity;
let lastMove = -Infinity;
let timer: ReturnType<typeof setTimeout> | null = null;
let inFlight = false;
const schedule = () => {
if (!on || timer !== null || inFlight || (!queue.length && !dropped)) return;
const wait = Math.max(0, lastSend + INPUT_LOG_FLUSH_MS - deps.now());
timer = setTimeout(flush, wait);
};
function flush() {
timer = null;
if (!on) return;
const batch = takeBatch(queue);
if (!queue.length && dropped) {
const note = `${Math.round(deps.now() - t0)} dropped n=${dropped}`;
const size = JSON.stringify({ events: batch.concat(note) }).length;
if (size <= INPUT_LOG_MAX_BODY) {
batch.push(note);
dropped = 0;
}
}
if (!batch.length) return;
inFlight = true;
lastSend = deps.now();
deps
.send(batch)
.catch(() => {
// measurement only: a lost batch is not retried
})
.then(() => {
inFlight = false;
schedule();
});
}
return {
record(e) {
if (!on) return;
const now = deps.now();
if (e.type === "mousemove") {
if (now - lastMove < MOVE_SAMPLE_MS) return;
lastMove = now;
}
const line = describeEvent(e, now - t0);
if (!line) return;
if (queue.length >= INPUT_LOG_MAX_QUEUE) dropped++;
else queue.push(line);
schedule();
},
setEnabled(next) {
if (next === on) return;
on = next;
if (on) {
t0 = deps.now();
lastMove = -Infinity;
} else {
queue = [];
dropped = 0;
if (timer !== null) clearTimeout(timer);
timer = null;
}
},
enabled: () => on,
};
}
/* ---- the page's log ---------------------------------------------------- */
const WINDOW_EVENTS = [
"keydown",
"keyup",
"mousedown",
"mouseup",
"click",
"contextmenu",
"wheel",
"mousemove",
"popstate",
"focus",
"blur",
"gamepadconnected",
];
const DOCUMENT_EVENTS = ["visibilitychange", "fullscreenchange", "webkitfullscreenchange"];
const pageLog = createInputLog({
send: (lines) => call(INPUT_LOG_ROUTE, { events: lines }),
now: () => (typeof performance !== "undefined" ? performance.now() : Date.now()),
});
const onEvent = (e: Event) => pageLog.record(e as unknown as LoggedEvent);
const OPTS: AddEventListenerOptions = { capture: true, passive: true };
/** Start (true) or stop (false) logging this page's input to the helper. */
export function setInputLogEnabled(on: boolean): void {
if (on === pageLog.enabled() || typeof window === "undefined") return;
pageLog.setEnabled(on);
for (const t of WINDOW_EVENTS) {
if (on) window.addEventListener(t, onEvent, OPTS);
else window.removeEventListener(t, onEvent, OPTS);
}
for (const t of DOCUMENT_EVENTS) {
if (on) document.addEventListener(t, onEvent, OPTS);
else document.removeEventListener(t, onEvent, OPTS);
}
}
export function inputLogEnabled(): boolean {
return pageLog.enabled();
}
+3
View File
@@ -4,11 +4,14 @@ import "virtual:pc-tokens.css";
import "./console.css";
import { App } from "./App";
import { installBackHistory } from "./backHistory";
import { installFullscreen } from "./fullscreen";
import { DEFAULT_INPUT_MAP, dispatchNav, installNavigation } from "./nav";
import { getLanguage, onLanguageChange, setLanguage } from "./strings";
setLanguage(getLanguage()); // sets <html lang/dir> for the detected language
installNavigation(DEFAULT_INPUT_MAP);
// Full screen on the first press while the Settings switch is on (the default).
installFullscreen();
// The browser's Back (the PS5's ○) steps back inside the page: see backHistory.ts.
installBackHistory(() => dispatchNav("back"));
+197 -1
View File
@@ -4,6 +4,7 @@ import {
DEFAULT_INPUT_MAP,
dispatchNav,
focusElement,
FOCUSABLE,
installNavigation,
onNavAction,
} from "./nav";
@@ -45,9 +46,14 @@ class FakeEl {
querySelectorAll(sel: string): FakeEl[] {
if (sel === "[data-nav-modal]") return this.descendants().filter((e) => "data-nav-modal" in e.attrs);
if (sel === "[data-nav-region]") return this.descendants().filter((e) => "data-nav-region" in e.attrs);
if (sel === '[data-nav-region="rail"]') return this.descendants().filter((e) => e.attrs["data-nav-region"] === "rail");
return this.descendants().filter((e) => e.tagName === "BUTTON"); // the FOCUSABLE selector
}
closest(sel: string): FakeEl | null {
if (sel === FOCUSABLE) {
if (this.tagName === "BUTTON") return this;
return this.parent ? this.parent.closest(sel) : null;
}
if (sel !== "[data-nav-region]") return null; // nothing is inert or aria-hidden in these fixtures
if ("data-nav-region" in this.attrs) return this;
return this.parent ? this.parent.closest(sel) : null;
@@ -62,7 +68,10 @@ class FakeEl {
const { left, top, width, height } = this.box;
return { left, top, width, height, right: left + width, bottom: top + height, x: left, y: top } as DOMRect;
}
focus(): void {
focusOpts: unknown = undefined;
scrollTop = 0;
focus(opts?: unknown): void {
this.focusOpts = opts;
fakeDoc.activeElement = this;
}
click(): void {
@@ -382,3 +391,190 @@ describe("R12 gamepad/keyboard echo", () => {
expect(fakeDoc.activeElement).toBe(below);
});
});
/* A window that keeps every listener, so a test can fire keys and pointer events at it. */
function fakeWindow() {
const listeners: Record<string, ((e: any) => void)[]> = {};
(globalThis as any).window = {
innerHeight: 700,
addEventListener: (t: string, fn: (e: any) => void) => {
(listeners[t] ??= []).push(fn);
},
removeEventListener: (t: string, fn: (e: any) => void) => {
listeners[t] = (listeners[t] ?? []).filter((f) => f !== fn);
},
};
(globalThis as any).requestAnimationFrame = () => 1;
(globalThis as any).cancelAnimationFrame = () => {};
vi.stubGlobal("navigator", {}); // no Gamepad API, like the PS5 browser
const fire = (type: string, e: Record<string, unknown>) => {
const ev = { preventDefault: vi.fn(), stopPropagation: vi.fn(), defaultPrevented: false, ...e };
for (const fn of listeners[type] ?? []) fn(ev);
return ev;
};
return {
fire,
key: (key: string, keyCode = 0) => fire("keydown", { key, keyCode, target: fakeDoc.activeElement }),
move: (target: FakeEl, x: number, y: number) => fire("mousemove", { target, clientX: x, clientY: y }),
};
}
describe("pointer = focus (the PS5 browser's left stick drives a pointer)", () => {
it("hovering a control focuses it without scrolling", () => {
const w = fakeWindow();
offs.push(installNavigation(DEFAULT_INPUT_MAP));
railHome.focus();
w.move(railFiles, 50, 180);
expect(fakeDoc.activeElement).toBe(railFiles);
expect(railFiles.focusOpts).toEqual({ preventScroll: true });
});
it("hovering inside a control (its icon) focuses the control", () => {
const w = fakeWindow();
offs.push(installNavigation(DEFAULT_INPUT_MAP));
const icon = new FakeEl("icon");
railFiles.add(icon);
w.move(icon, 50, 180);
expect(fakeDoc.activeElement).toBe(railFiles);
});
it("hover outside the open modal never moves focus behind it", () => {
const w = fakeWindow();
offs.push(installNavigation(DEFAULT_INPUT_MAP));
body.add(modal);
reload.focus();
w.move(railFiles, 50, 180);
expect(fakeDoc.activeElement).toBe(reload);
});
it("Enter activates the focused control (once: the native activation is prevented)", () => {
const w = fakeWindow();
offs.push(installNavigation(DEFAULT_INPUT_MAP));
w.move(railFiles, 50, 180);
const ev = w.key("Enter", 13);
expect(railFiles.clicks).toBe(1);
expect(ev.preventDefault).toHaveBeenCalled();
});
it("an arrow after hovering continues from the hovered control", () => {
const w = fakeWindow();
offs.push(installNavigation(DEFAULT_INPUT_MAP));
const below = btn("below", 0, 220);
body.add(below);
railHome.focus();
w.move(railFiles, 50, 180);
w.key("ArrowDown", 40);
expect(fakeDoc.activeElement).toBe(below);
});
it("after a key move, pointer jitter (stick drift) does not take focus back; a real move does", () => {
const w = fakeWindow();
offs.push(installNavigation(DEFAULT_INPUT_MAP));
w.move(railHome, 50, 120);
w.key("ArrowDown", 40);
expect(fakeDoc.activeElement).toBe(railFiles);
w.move(railHome, 52, 121); // drift, still over Home
expect(fakeDoc.activeElement).toBe(railFiles);
w.move(railHome, 50, 120); // a page scroll re-sends the same spot
expect(fakeDoc.activeElement).toBe(railFiles);
w.move(railHome, 60, 130); // the stick really moved
expect(fakeDoc.activeElement).toBe(railHome);
});
it("a pointer click after a key move activates the focused control, not the one under the pointer", () => {
const w = fakeWindow();
offs.push(installNavigation(DEFAULT_INPUT_MAP));
w.move(railHome, 50, 120);
w.key("ArrowDown", 40);
const down = w.fire("mousedown", { target: railHome, clientX: 50, clientY: 120, button: 0 });
expect(down.preventDefault).toHaveBeenCalled(); // focus does not jump to the pointer
const click = w.fire("click", { target: railHome, clientX: 50, clientY: 120, detail: 1, isTrusted: true });
expect(click.preventDefault).toHaveBeenCalled();
expect(click.stopPropagation).toHaveBeenCalled();
expect(railFiles.clicks).toBe(1);
expect(fakeDoc.activeElement).toBe(railFiles);
});
it("a pointer click on the hovered control is left to the browser", () => {
const w = fakeWindow();
offs.push(installNavigation(DEFAULT_INPUT_MAP));
w.move(railFiles, 50, 180);
const click = w.fire("click", { target: railFiles, clientX: 50, clientY: 180, detail: 1, isTrusted: true });
expect(click.preventDefault).not.toHaveBeenCalled();
expect(railFiles.clicks).toBe(0); // the native click runs, not ours
});
it("keys are matched by keyCode when the browser names the key Unidentified", () => {
const w = fakeWindow();
offs.push(installNavigation(DEFAULT_INPUT_MAP));
railHome.focus();
w.key("Unidentified", 13);
expect(railHome.clicks).toBe(1);
});
it("the wheel (right stick) is never taken over", () => {
const w = fakeWindow();
offs.push(installNavigation(DEFAULT_INPUT_MAP));
const ev = w.fire("wheel", { target: railHome, deltaY: 120 });
expect(ev.preventDefault).not.toHaveBeenCalled();
});
});
describe("the other DualSense actions", () => {
it("help, refresh, fullscreen and select reach the handlers", () => {
const seen: string[] = [];
offs.push(
onNavAction((a) => {
seen.push(a);
return true;
}),
);
railHome.focus();
for (const a of ["help", "refresh", "fullscreen", "select", "actions"] as const) dispatchNav(a);
expect(seen).toEqual(["help", "refresh", "fullscreen", "select", "actions"]);
});
it("R3 focuses the section rail at the active section", () => {
const r = regions();
const deep = btn("deep", 140, 500);
r.content.add(btn("first", 140, 100), deep);
deep.focus();
dispatchNav("rail");
expect(fakeDoc.activeElement).toBe(r.files);
});
it("L3 jumps to the top of the current region and scrolls it there", () => {
const r = regions();
const first = btn("first", 140, 100);
const deep = btn("deep", 140, 500);
r.content.add(first, deep);
r.content.scrollTop = 400;
deep.focus();
dispatchNav("top");
expect(fakeDoc.activeElement).toBe(first);
expect(r.content.scrollTop).toBe(0);
});
it("R2/L2 move focus about a page down/up the current list", () => {
(globalThis as any).window = { innerHeight: 300 };
const r = regions();
const rows = Array.from({ length: 20 }, (_, i) => btn(`row${i}`, 140, 100 + i * 50));
r.content.add(...rows);
rows[0].focus();
dispatchNav("pageDown");
// A page is 80 % of the 300 px viewport: 240 px, so 5 rows of 50 px.
expect(fakeDoc.activeElement).toBe(rows[5]);
dispatchNav("pageUp");
expect(fakeDoc.activeElement).toBe(rows[0]);
});
it("a section handler may take R2 over (it returns true)", () => {
const r = regions();
const rows = Array.from({ length: 4 }, (_, i) => btn(`row${i}`, 140, 100 + i * 50));
r.content.add(...rows);
rows[0].focus();
offs.push(onNavAction((a) => a === "pageDown"));
dispatchNav("pageDown");
expect(fakeDoc.activeElement).toBe(rows[0]);
});
});
+56 -10
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { createPadReader, DEFAULT_INPUT_MAP, nextFocus, type InputMap } from "./nav";
import { bindingForKey, createPadReader, DEFAULT_INPUT_MAP, hintEntries, nextFocus, type InputMap } from "./nav";
// Vitest runs in node here (no jsdom): rects are plain objects and the
// "elements" are tagged stand-ins, which is all nextFocus looks at.
@@ -91,20 +91,66 @@ describe("gamepad reader", () => {
});
it("ignores buttons the map does not name", () => {
const map: InputMap = { keys: {}, buttons: { 1: { action: "back", glyph: "○", verified: false } } };
const map: InputMap = [{ button: "circle", glyph: "○", action: "back", keys: [], keyCodes: [], pad: 1, verified: false }];
const read = createPadReader(map);
expect(read(press(0, 1), 0)).toEqual(["back"]);
});
});
describe("default input map", () => {
it("ships every R11 binding unverified", () => {
const all = [...Object.values(DEFAULT_INPUT_MAP.keys), ...Object.values(DEFAULT_INPUT_MAP.buttons)];
expect(all.length).toBeGreaterThan(0);
expect(all.every((b) => b.verified === false)).toBe(true);
expect(DEFAULT_INPUT_MAP.buttons[0].action).toBe("confirm");
expect(DEFAULT_INPUT_MAP.buttons[1].action).toBe("back");
expect(DEFAULT_INPUT_MAP.keys.Escape.action).toBe("back");
expect(DEFAULT_INPUT_MAP.keys.Backspace.action).toBe("back");
const entry = (b: string) => DEFAULT_INPUT_MAP.find((x) => x.button === b);
it("covers every DualSense button, each unverified until measured", () => {
const want = [
"cross", "circle", "triangle", "square", "up", "down", "left", "right", "lstick", "rstick",
"l1", "r1", "l2", "r2", "l3", "r3", "options", "create", "touchpad", "ps", "mute",
];
expect(DEFAULT_INPUT_MAP.map((x) => x.button).sort()).toEqual([...want].sort());
expect(DEFAULT_INPUT_MAP.every((x) => x.verified === false)).toBe(true);
});
it("wires each button to its action", () => {
const actions: Record<string, string> = {
cross: "confirm", circle: "back", triangle: "actions", square: "select",
up: "up", down: "down", left: "left", right: "right", lstick: "pointer", rstick: "scroll",
l1: "prevSection", r1: "nextSection", l2: "pageUp", r2: "pageDown", l3: "top", r3: "rail",
options: "help", create: "refresh", touchpad: "fullscreen", ps: "system", mute: "system",
};
for (const [b, a] of Object.entries(actions)) expect(entry(b)?.action, b).toBe(a);
});
it("seeds the standard keys: Enter, Escape/Backspace, arrows, PageUp/PageDown, Home, ContextMenu, F5", () => {
expect(entry("cross")?.keys).toContain("Enter");
expect(entry("circle")?.keys).toEqual(expect.arrayContaining(["Escape", "Backspace"]));
expect(entry("up")?.keys).toContain("ArrowUp");
expect(entry("l2")?.keys).toContain("PageUp");
expect(entry("r2")?.keys).toContain("PageDown");
expect(entry("l3")?.keys).toContain("Home");
expect(entry("triangle")?.keys).toContain("ContextMenu");
expect(entry("create")?.keys).toContain("F5");
expect(entry("cross")?.keyCodes).toContain(13);
// The PS button and Mute never reach the page; nothing listens for them.
expect(entry("ps")?.keys).toEqual([]);
expect(entry("mute")?.keys).toEqual([]);
});
it("bindingForKey matches by key, then by keyCode when the key is not named", () => {
expect(bindingForKey(DEFAULT_INPUT_MAP, "Enter", 0)?.action).toBe("confirm");
expect(bindingForKey(DEFAULT_INPUT_MAP, "Unidentified", 27)?.action).toBe("back");
expect(bindingForKey(DEFAULT_INPUT_MAP, "x", 88)).toBeNull();
});
});
describe("hint bar entries", () => {
it("shows ✕ and ○ always, others only once verified", () => {
expect(hintEntries(DEFAULT_INPUT_MAP).map((h) => h.glyph)).toEqual(["✕", "○"]);
const map = DEFAULT_INPUT_MAP.map((b) => (b.button === "l2" ? { ...b, verified: true } : b));
expect(hintEntries(map).map((h) => h.glyph)).toEqual(["✕", "○", "L2"]);
});
it("never advertises system buttons or the sticks", () => {
const all = DEFAULT_INPUT_MAP.map((b) => ({ ...b, verified: true }));
const glyphs = hintEntries(all).map((h) => h.glyph);
for (const g of ["PS", "MUTE", "LS", "RS"]) expect(glyphs).not.toContain(g);
});
});
+308 -69
View File
@@ -1,18 +1,25 @@
/**
* Controller and keyboard navigation for the console page.
* Controller, pointer and keyboard navigation for the console page.
*
* Focus is always real DOM focus on real controls (<button>, <a>, inputs), so
* the browser's own focus handling keeps working; this layer only adds:
* - pointer = focus: the PS5 browser drives an on-screen pointer with the
* left stick and ✕ clicks at it, so hovering a control focuses it; the
* focus ring and what ✕ activates are then the same element;
* - spatial movement: a direction moves focus to the nearest focusable
* element that way (nextFocus), from arrow keys or the D-pad;
* - confirm: clicks the focused control;
* - confirm: clicks the focused control (Enter, or ✕ when it arrives as a key);
* - page moves (L2/R2), top of the list (L3), the rail (R3);
* - the other actions (back, actions, select, prevSection, nextSection,
* filter) go to the handlers registered with onNavAction, newest first.
* help, refresh, fullscreen) go to the handlers registered with
* onNavAction, newest first.
*
* Which keys and Gamepad buttons the PS5 browser really delivers has not been
* measured yet (ruling R11), so the bindings are one table, InputMap, with a
* `verified` flag per entry. The HintBar shows only verified entries, and every
* action has an on-screen control as well, so nothing depends on a shortcut.
* Which keys the PS5 browser really delivers for each button has not been
* measured yet, so the bindings are one table, DEFAULT_INPUT_MAP, covering the
* whole DualSense with a `verified` flag per entry (the input log, inputLog.ts,
* is how they get measured). The HintBar shows ✕ and ○ always and the others
* only once verified, and every action has an on-screen control as well, so
* nothing depends on a shortcut.
*/
import { requestBack } from "./backHistory";
@@ -25,51 +32,134 @@ export type NavAction =
| "select"
| "prevSection"
| "nextSection"
| "filter";
| "pageUp"
| "pageDown"
| "top"
| "rail"
| "help"
| "refresh"
| "fullscreen";
/** What a table entry does: a NavAction, or a button the page never acts on itself. */
export type ButtonAction =
| NavAction
/** The browser's own pointer (left stick): hover = focus, handled by installNavigation. */
| "pointer"
/** The browser's own scrolling (right stick): left native. */
| "scroll"
/** Used by the PS5 itself, never delivered to the page (PS, Mute). */
| "system";
export type ButtonId =
| "cross"
| "circle"
| "triangle"
| "square"
| "up"
| "down"
| "left"
| "right"
| "lstick"
| "rstick"
| "l1"
| "r1"
| "l2"
| "r2"
| "l3"
| "r3"
| "options"
| "create"
| "touchpad"
| "ps"
| "mute";
export interface InputBinding {
action: NavAction;
/** The controller glyph the HintBar shows (gamepad entries). */
glyph?: string;
/** Measured on a real console. Only verified entries are advertised. */
button: ButtonId;
/** The controller glyph the HintBar and the Controls screen show. */
glyph: string;
action: ButtonAction;
/** KeyboardEvent.key values the button may arrive as (seeded guesses until verified). */
keys: string[];
/** KeyboardEvent.keyCode values, for a browser that names the key "Unidentified". */
keyCodes: number[];
/** Gamepad API "standard" button index (only where the Gamepad API exists; the PS5 browser has none). */
pad?: number;
/** Measured on a real console. Only verified entries (and ✕/○) are advertised. */
verified: boolean;
}
export interface InputMap {
/** KeyboardEvent.key → binding. */
keys: Record<string, InputBinding>;
/** Gamepad API "standard" button index → binding. */
buttons: Record<number, InputBinding>;
/** The whole controller, one entry per button. */
export type InputMap = InputBinding[];
const bind = (
button: ButtonId,
glyph: string,
action: ButtonAction,
keys: string[],
keyCodes: number[],
pad?: number,
): InputBinding => ({ button, glyph, action, keys, keyCodes, pad, verified: false });
/**
* Every DualSense button. Keys are the standard keyboard equivalents where one
* exists (the measurement pass replaces them with what the PS5 browser really
* sends and sets `verified`).
*/
export const DEFAULT_INPUT_MAP: InputMap = [
bind("cross", "✕", "confirm", ["Enter"], [13], 0),
bind("circle", "○", "back", ["Escape", "Backspace", "GoBack", "BrowserBack"], [27, 8], 1),
bind("square", "□", "select", [], [], 2),
bind("triangle", "△", "actions", ["ContextMenu"], [93], 3),
bind("up", "↑", "up", ["ArrowUp"], [38], 12),
bind("down", "↓", "down", ["ArrowDown"], [40], 13),
bind("left", "←", "left", ["ArrowLeft"], [37], 14),
bind("right", "→", "right", ["ArrowRight"], [39], 15),
bind("lstick", "LS", "pointer", [], []),
bind("rstick", "RS", "scroll", [], []),
bind("l1", "L1", "prevSection", [], [], 4),
bind("r1", "R1", "nextSection", [], [], 5),
bind("l2", "L2", "pageUp", ["PageUp"], [33], 6),
bind("r2", "R2", "pageDown", ["PageDown"], [34], 7),
bind("l3", "L3", "top", ["Home"], [36], 10),
bind("r3", "R3", "rail", [], [], 11),
bind("options", "OPTIONS", "help", ["F1", "Help"], [112], 9),
bind("create", "CREATE", "refresh", ["F5", "BrowserRefresh"], [116], 8),
bind("touchpad", "TOUCHPAD", "fullscreen", ["F11"], [122]),
bind("ps", "PS", "system", [], []),
bind("mute", "MUTE", "system", [], []),
];
const ACTIONS_THE_PAGE_RUNS: ReadonlySet<ButtonAction> = new Set<ButtonAction>([
"up",
"down",
"left",
"right",
"confirm",
"back",
"actions",
"select",
"prevSection",
"nextSection",
"pageUp",
"pageDown",
"top",
"rail",
"help",
"refresh",
"fullscreen",
]);
function isNavAction(a: ButtonAction): a is NavAction {
return ACTIONS_THE_PAGE_RUNS.has(a);
}
const k = (action: NavAction): InputBinding => ({ action, verified: false });
const b = (action: NavAction, glyph: string): InputBinding => ({ action, glyph, verified: false });
/** R11's default table. Every entry is unverified until the console capture. */
export const DEFAULT_INPUT_MAP: InputMap = {
keys: {
ArrowUp: k("up"),
ArrowDown: k("down"),
ArrowLeft: k("left"),
ArrowRight: k("right"),
Enter: k("confirm"),
Escape: k("back"),
Backspace: k("back"),
},
buttons: {
0: b("confirm", "✕"),
1: b("back", "○"),
2: b("select", "□"),
3: b("actions", "△"),
4: b("prevSection", "L1"),
5: b("nextSection", "R1"),
7: b("filter", "R2"),
12: b("up", "↑"),
13: b("down", "↓"),
14: b("left", "←"),
15: b("right", "→"),
},
};
/** The binding a keydown belongs to: by `key`, else by `keyCode` (a key the browser does not name). */
export function bindingForKey(map: InputMap, key: string, keyCode: number): InputBinding | null {
for (const b of map) if (isNavAction(b.action) && b.keys.includes(key)) return b;
if (key.length === 1) return null; // a printable key is text, never a button
if (keyCode) for (const b of map) if (isNavAction(b.action) && b.keyCodes.includes(keyCode)) return b;
return null;
}
/* ---- spatial focus ---------------------------------------------------- */
@@ -167,11 +257,12 @@ const DIRECTIONS: ReadonlySet<NavAction> = new Set(["up", "down", "left", "right
export function createPadReader(map: InputMap): (pressed: boolean[], now: number) => NavAction[] {
const downSince = new Map<number, number>();
const lastFire = new Map<number, number>();
const padded = map.filter((b) => b.pad !== undefined && isNavAction(b.action));
return (pressed, now) => {
const out: NavAction[] = [];
for (const key of Object.keys(map.buttons)) {
const i = Number(key);
const binding = map.buttons[i];
for (const binding of padded) {
const i = binding.pad as number;
const action = binding.action as NavAction;
if (!pressed[i]) {
downSince.delete(i);
continue;
@@ -180,15 +271,15 @@ export function createPadReader(map: InputMap): (pressed: boolean[], now: number
if (since === undefined) {
downSince.set(i, now);
lastFire.set(i, now);
out.push(binding.action);
out.push(action);
continue;
}
if (!DIRECTIONS.has(binding.action)) continue;
if (!DIRECTIONS.has(action)) continue;
const last = lastFire.get(i) ?? since;
const due = last === since ? since + REPEAT_DELAY_MS : last + REPEAT_MS;
if (now >= due) {
lastFire.set(i, now);
out.push(binding.action);
out.push(action);
}
}
return out;
@@ -217,7 +308,7 @@ export function createEchoGuard(windowMs: number = ECHO_MS) {
};
}
function isDirection(a: NavAction): a is Direction {
function isDirection(a: ButtonAction): a is Direction {
return a === "up" || a === "down" || a === "left" || a === "right";
}
@@ -348,15 +439,16 @@ function regionMove(current: FocusCandidate, region: Element, all: FocusCandidat
return nextFocus(current.rect, same, dir);
}
function move(dir: Direction): void {
/** Move focus one step `dir`; the distance the focus travelled along `dir` (before any scroll), or null. */
function move(dir: Direction): number | null {
const all = focusables();
if (!all.length) return;
if (!all.length) return null;
const active = document.activeElement as HTMLElement | null;
const current = active ? all.find((c) => c.el === active) : undefined;
if (!current) {
const preferred = all.find((c) => c.el.hasAttribute("data-nav-default")) ?? all[0];
focusElement(preferred.el);
return;
return null;
}
const region = topModal() ? null : regionOf(current.el);
const next = region
@@ -366,7 +458,50 @@ function move(dir: Direction): void {
all.filter((c) => c !== current),
dir,
);
if (next) focusElement(next);
if (!next) return null;
const to = all.find((c) => c.el === next)?.rect ?? next.getBoundingClientRect();
const along =
dir === "up" || dir === "down" ? Math.abs(to.top - current.rect.top) : Math.abs(to.left - current.rect.left);
focusElement(next);
return along;
}
/** A page for L2/R2: most of the viewport, so the last row of one page is still in sight on the next. */
export const PAGE_FRACTION = 0.8;
const PAGE_STEPS_MAX = 200;
/** L2/R2: focus moves down (up) the current list until it has travelled about a page. */
function page(dir: "up" | "down"): void {
const span = (typeof window !== "undefined" && window.innerHeight ? window.innerHeight : 700) * PAGE_FRACTION;
let travelled = 0;
for (let i = 0; i < PAGE_STEPS_MAX && travelled < span; i++) {
const d = move(dir);
if (d === null || d <= 0) break;
travelled += d;
}
}
/** L3: the first control of the focused element's region (or the open modal), scrolled to the top. */
function toTop(): void {
const all = focusables();
if (!all.length) return;
const modal = topModal();
const active = document.activeElement as HTMLElement | null;
const region = modal ?? regionOf(active) ?? null;
const inside = region ? all.filter((c) => region.contains(c.el)) : all;
if (!inside.length) return;
if (region && typeof (region as HTMLElement).scrollTop === "number") (region as HTMLElement).scrollTop = 0;
focusElement(inside[0].el);
}
/** R3: the section rail, at its entry (the active section). */
function toRail(): void {
if (topModal()) return;
const all = focusables();
const rail = document.querySelectorAll<HTMLElement>('[data-nav-region="rail"]')[0];
const regions = rail ? [rail] : Array.from(document.querySelectorAll<HTMLElement>(REGION));
const target = regions.length ? regionEntry(regions[0], all) : null;
if (target) focusElement(target);
}
/** Run one action: movement and confirm here, the rest through the handlers. */
@@ -395,6 +530,11 @@ export function dispatchNav(action: NavAction): void {
}
if (h.fn(action)) return;
}
// Nobody took it: the page-wide default, where there is one.
if (action === "pageUp") page("up");
else if (action === "pageDown") page("down");
else if (action === "top") toTop();
else if (action === "rail") toRail();
}
/** Text fields keep the keys that edit text. */
@@ -417,26 +557,116 @@ function act(action: NavAction): void {
else dispatchNav(action);
}
/** Pointer movement smaller than this (px) after a key move is stick drift or a scroll re-send, not the user. */
export const POINTER_SLOP_PX = 6;
/** Focus `el` because the pointer is over it: no scroll (the pointer is already there). */
function focusFromPointer(el: HTMLElement): void {
remember(document.activeElement);
el.focus({ preventScroll: true });
remember(el);
}
/** The control a pointer event is over, if hovering may focus it now. */
function hoverTarget(target: EventTarget | null): HTMLElement | null {
const t = target as Element | null;
if (!t || typeof t.closest !== "function") return null;
const el = t.closest<HTMLElement>(FOCUSABLE);
if (!el || el.closest("[inert], [aria-hidden='true']")) return null;
const modal = topModal();
if (modal && !modal.contains(el)) return null;
return el;
}
/**
* Listen for the keys and gamepad buttons in `map` until the returned function
* is called. Keyboard: keydown (one a control handled itself is left alone).
* Gamepad: polled once per animation frame.
* Listen for the keys, the pointer and gamepad buttons until the returned
* function is called.
*
* Keyboard: keydown (one a control handled itself is left alone), matched by
* key then keyCode (bindingForKey).
*
* Pointer: a mousemove over a control focuses it (hover = focus). After a key
* moved focus the pointer is "parked": it stays where it was, so moves within
* POINTER_SLOP_PX of that spot are ignored (stick drift, the browser re-sending
* the spot after a scroll), and a pointer click there, which would land on
* whatever is under the pointer, activates the focused control instead. The
* ring and what ✕ activates never disagree. The wheel (right stick) is left
* to the browser.
*
* Gamepad: polled once per animation frame where the Gamepad API exists.
*/
export function installNavigation(map: InputMap): () => void {
const echo = createEchoGuard();
const ptr = { x: NaN, y: NaN, parked: false, ax: NaN, ay: NaN };
const park = () => {
ptr.parked = true;
ptr.ax = ptr.x;
ptr.ay = ptr.y;
};
const onKey = (e: KeyboardEvent) => {
if (e.altKey || e.ctrlKey || e.metaKey || e.defaultPrevented) return;
const binding = map.keys[e.key];
if (!binding) return;
const binding = bindingForKey(map, e.key, e.keyCode);
if (!binding || !isNavAction(binding.action)) return;
const action = binding.action;
const typing = isTextField(e.target as Element | null);
if (typing && (binding.action === "left" || binding.action === "right" || e.key === "Backspace" || binding.action === "confirm")) {
if (
typing &&
(action === "left" || action === "right" || action === "confirm" || action === "top" || e.key === "Backspace")
) {
return;
}
e.preventDefault();
if (isDirection(binding.action) && !echo.keyAllowed(binding.action, performance.now())) return;
act(binding.action);
if (isDirection(action) && !echo.keyAllowed(action, performance.now())) return;
park();
act(action);
};
const onMove = (e: MouseEvent) => {
const x = e.clientX;
const y = e.clientY;
if (ptr.parked) {
if (Number.isNaN(ptr.ax)) {
// Where the pointer sat when the key moved focus was unknown: this is that spot.
ptr.ax = x;
ptr.ay = y;
ptr.x = x;
ptr.y = y;
return;
}
if (Math.hypot(x - ptr.ax, y - ptr.ay) < POINTER_SLOP_PX) return;
ptr.parked = false;
}
ptr.x = x;
ptr.y = y;
const el = hoverTarget(e.target);
if (!el || el === document.activeElement) return;
if (isTextField(document.activeElement)) return; // never pull focus out of a field being typed into
focusFromPointer(el);
};
/** A parked pointer's press would land away from the ring: true when it must be redirected. */
const strays = (target: EventTarget | null): boolean => {
if (!ptr.parked) return false;
const active = document.activeElement as HTMLElement | null;
if (!active || active === document.body) return false;
return !active.contains(target as Node | null);
};
const onDown = (e: MouseEvent) => {
if (e.button === 0 && strays(e.target)) e.preventDefault(); // focus stays on the ring
};
const onClick = (e: MouseEvent) => {
// detail 0: a click from a key or from .click(), not the pointer.
if (!e.isTrusted || !e.detail || !strays(e.target)) return;
e.preventDefault();
e.stopPropagation();
dispatchNav("confirm");
};
window.addEventListener("keydown", onKey);
window.addEventListener("mousemove", onMove, true);
window.addEventListener("mousedown", onDown, true);
window.addEventListener("click", onClick, true);
let frame = 0;
let stopped = false;
@@ -457,6 +687,7 @@ export function installNavigation(map: InputMap): () => void {
const pressed = pad.buttons.map((btn) => btn.pressed);
for (const action of read(pressed, now)) {
if (isDirection(action)) echo.padFired(action, now);
park();
act(action);
}
}
@@ -467,13 +698,21 @@ export function installNavigation(map: InputMap): () => void {
return () => {
stopped = true;
window.removeEventListener("keydown", onKey);
window.removeEventListener("mousemove", onMove, true);
window.removeEventListener("mousedown", onDown, true);
window.removeEventListener("click", onClick, true);
if (frame) cancelAnimationFrame(frame);
};
}
/** The bindings the HintBar may advertise: verified gamepad entries. */
export function verifiedButtons(map: InputMap): InputBinding[] {
return Object.keys(map.buttons)
.map((i) => map.buttons[Number(i)])
.filter((x) => x.verified);
/**
* What the HintBar shows: ✕ and ○ always (they work in every browser: ○ is the
* browser's own Back), then each verified button with an action of its own.
* Directions, the sticks and the system buttons are never listed.
*/
export function hintEntries(map: InputMap): InputBinding[] {
const always = (b: InputBinding) => b.button === "cross" || b.button === "circle";
return map.filter(
(b) => always(b) || (b.verified && isNavAction(b.action) && !isDirection(b.action)),
);
}
@@ -80,6 +80,16 @@ describe("settings", () => {
expect(last?.tile_busy).toBe(true);
});
it("full screen switch shows only where the browser supports it", () => {
const onToggleFullscreen = vi.fn();
const on = elements(SettingsView(base({ fullscreen: true, onToggleFullscreen }))).find((e) => e.props.action === "fullscreen");
expect(on?.props.on).toBe(true);
(on?.props.onClick as () => void)();
expect(onToggleFullscreen).toHaveBeenCalledTimes(1);
const hidden = elements(SettingsView(base({ fullscreen: null }))).find((e) => e.props.action === "fullscreen");
expect(hidden).toBeUndefined();
});
it("language_choice_persists_and_survives_storage_errors", () => {
const store = new Map<string, string>();
vi.stubGlobal("localStorage", {
+39 -2
View File
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ApiError, call, isOffline } from "../api";
import { fullscreenPref, fullscreenSupported, setFullscreenPref } from "../fullscreen";
import { onNavAction } from "../nav";
import { LANGUAGE_NAMES } from "../languages";
import { chooseLanguage, LANGUAGES, storedLanguage, t } from "../strings";
import { ConfirmDialog } from "../ui/ConfirmDialog";
@@ -69,6 +71,9 @@ export interface SettingsViewProps {
onDismissLan: () => void;
onToggleTile: () => void;
onLanguage: (code: string) => void;
/** The Full screen switch: on/off, or null where the browser cannot (the switch is hidden). */
fullscreen?: boolean | null;
onToggleFullscreen?: () => void;
}
function Switch({
@@ -142,6 +147,20 @@ export function SettingsView(p: SettingsViewProps) {
)}
</section>
{typeof p.fullscreen === "boolean" ? (
<section className="cw-home-section">
<h2 className="cw-h2">{t("cw_settings_display")}</h2>
<Switch
action="fullscreen"
label={t("cw_settings_fullscreen")}
on={p.fullscreen}
disabled={false}
onClick={() => p.onToggleFullscreen?.()}
note={t("cw_settings_fullscreen_note")}
/>
</section>
) : null}
<section className="cw-home-section">
<h2 className="cw-h2">{t("cw_settings_language")}</h2>
<div className="cw-quick" role="group" aria-label={t("cw_settings_language")}>
@@ -182,7 +201,7 @@ export function SettingsView(p: SettingsViewProps) {
);
}
/** The Settings section: network access, the home-screen tile, language, About. */
/** The Settings section: network access, the home-screen tile, full screen, language, About. */
export function Settings() {
const [settings, setSettings] = useState<WebSettings | null>(null);
const [loadFailed, setLoadFailed] = useState(false);
@@ -190,6 +209,18 @@ export function Settings() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [language, setLanguageState] = useState(storedLanguage);
const [fullscreen, setFullscreen] = useState<boolean | null>(() => (fullscreenSupported() ? fullscreenPref() : null));
/** Bumped by Create (refresh) to read /api/settings again. */
const [tick, setTick] = useState(0);
useEffect(
() =>
onNavAction((a) => {
if (a === "refresh") setTick((n) => n + 1);
return false;
}),
[],
);
useEffect(() => {
let live = true;
@@ -204,7 +235,7 @@ export function Settings() {
return () => {
live = false;
};
}, []);
}, [tick]);
const live = useRef(true);
useEffect(() => {
@@ -269,6 +300,12 @@ export function Settings() {
chooseLanguage(code);
setLanguageState(code);
}}
fullscreen={fullscreen}
onToggleFullscreen={() => {
if (fullscreen === null) return;
setFullscreenPref(!fullscreen);
setFullscreen(!fullscreen);
}}
/>
);
}
+14 -8
View File
@@ -1,26 +1,32 @@
import { verifiedButtons, type InputMap, type NavAction } from "../nav";
import { hintEntries, type ButtonAction, type InputMap } from "../nav";
import { t } from "../strings";
const LABELS: Partial<Record<NavAction, string>> = {
const LABELS: Partial<Record<ButtonAction, string>> = {
confirm: "cw_hint_open",
back: "cw_hint_back",
actions: "cw_actions",
select: "cw_select",
prevSection: "cw_hint_switch_section",
nextSection: "cw_hint_switch_section",
filter: "cw_hint_filter",
pageUp: "cw_hint_page",
pageDown: "cw_hint_page",
top: "cw_hint_top",
rail: "cw_nav_sections",
help: "cw_hint_help",
refresh: "cw_hint_refresh",
fullscreen: "cw_hint_fullscreen",
};
/**
* The controller hints along the bottom. Only bindings measured on a console
* (verified) are shown, so the bar never advertises a button that does nothing;
* with none verified it renders nothing.
* The controller hints along the bottom: ✕ and ○ always (○ is the browser's
* own Back, ✕ its click), every other button only once it was measured on a
* console (verified), so the bar never advertises a button that does nothing.
*/
export function HintBar({ map }: { map: InputMap }) {
const groups: { labelKey: string; glyphs: string[] }[] = [];
for (const b of verifiedButtons(map)) {
for (const b of hintEntries(map)) {
const labelKey = LABELS[b.action];
if (!labelKey || !b.glyph) continue;
if (!labelKey) continue;
const g = groups.find((x) => x.labelKey === labelKey);
if (g) g.glyphs.push(b.glyph);
else groups.push({ labelKey, glyphs: [b.glyph] });
+168
View File
@@ -57,8 +57,13 @@
"cw_files_view": "عرض",
"cw_hint_back": "رجوع",
"cw_hint_filter": "تصفية",
"cw_hint_fullscreen": "ملء الشاشة",
"cw_hint_help": "مساعدة",
"cw_hint_open": "فتح",
"cw_hint_page": "صفحة لأعلى/لأسفل",
"cw_hint_refresh": "تحديث",
"cw_hint_switch_section": "تبديل القسم",
"cw_hint_top": "الأعلى",
"cw_hints_label": "أزرار وحدة التحكم",
"cw_home_browse_usb": "تصفح USB",
"cw_home_cancel_task": "إلغاء",
@@ -119,10 +124,13 @@
"cw_select": "تحديد",
"cw_settings_about": "حول",
"cw_settings_app_name": "PS5Upload",
"cw_settings_display": "العرض",
"cw_settings_err_load": "تعذر تحميل الإعدادات.",
"cw_settings_err_save": "تعذر حفظ الإعداد. حاول مرة أخرى.",
"cw_settings_err_tile_add": "تعذرت إضافة PS5Upload إلى الشاشة الرئيسية. حاول مرة أخرى.",
"cw_settings_err_tile_remove": "تعذرت إزالة PS5Upload من الشاشة الرئيسية. حاول مرة أخرى.",
"cw_settings_fullscreen": "ملء الشاشة",
"cw_settings_fullscreen_note": "يخفي أشرطة المتصفح لتملأ الصفحة شاشة التلفاز. يسري عند ضغطتك التالية على زر.",
"cw_settings_lan": "السماح للأجهزة الأخرى في الشبكة",
"cw_settings_lan_allow": "سماح",
"cw_settings_lan_confirm_title": "السماح للأجهزة الأخرى؟",
@@ -198,8 +206,13 @@
"cw_files_view": "দেখুন",
"cw_hint_back": "পিছনে",
"cw_hint_filter": "ফিল্টার",
"cw_hint_fullscreen": "ফুল স্ক্রিন",
"cw_hint_help": "সাহায্য",
"cw_hint_open": "খুলুন",
"cw_hint_page": "পেজ উপরে/নিচে",
"cw_hint_refresh": "রিফ্রেশ",
"cw_hint_switch_section": "বিভাগ বদলান",
"cw_hint_top": "একদম উপরে",
"cw_hints_label": "কন্ট্রোলারের বোতাম",
"cw_home_browse_usb": "USB ব্রাউজ করুন",
"cw_home_cancel_task": "বাতিল",
@@ -260,10 +273,13 @@
"cw_select": "বাছাই করুন",
"cw_settings_about": "সম্পর্কে",
"cw_settings_app_name": "PS5Upload",
"cw_settings_display": "ডিসপ্লে",
"cw_settings_err_load": "সেটিংস লোড করা যায়নি।",
"cw_settings_err_save": "সেটিং সংরক্ষণ করা যায়নি। আবার চেষ্টা করুন।",
"cw_settings_err_tile_add": "হোম স্ক্রিনে PS5Upload যোগ করা যায়নি। আবার চেষ্টা করুন।",
"cw_settings_err_tile_remove": "হোম স্ক্রিন থেকে PS5Upload সরানো যায়নি। আবার চেষ্টা করুন।",
"cw_settings_fullscreen": "ফুল স্ক্রিন",
"cw_settings_fullscreen_note": "ব্রাউজারের বারগুলো লুকিয়ে রাখে যাতে পেজটি পুরো টিভি জুড়ে থাকে। পরের বার বোতাম চাপলে কার্যকর হয়।",
"cw_settings_lan": "নেটওয়ার্কের অন্য ডিভাইসকে অনুমতি দিন",
"cw_settings_lan_allow": "অনুমতি দিন",
"cw_settings_lan_confirm_title": "অন্য ডিভাইসকে অনুমতি দেবেন?",
@@ -339,8 +355,13 @@
"cw_files_view": "Ansehen",
"cw_hint_back": "Zurück",
"cw_hint_filter": "Filter",
"cw_hint_fullscreen": "Vollbild",
"cw_hint_help": "Hilfe",
"cw_hint_open": "Öffnen",
"cw_hint_page": "Seite hoch/runter",
"cw_hint_refresh": "Aktualisieren",
"cw_hint_switch_section": "Bereich wechseln",
"cw_hint_top": "Nach oben",
"cw_hints_label": "Controller-Tasten",
"cw_home_browse_usb": "USB durchsuchen",
"cw_home_cancel_task": "Abbrechen",
@@ -401,10 +422,13 @@
"cw_select": "Auswählen",
"cw_settings_about": "Info",
"cw_settings_app_name": "PS5Upload",
"cw_settings_display": "Anzeige",
"cw_settings_err_load": "Die Einstellungen konnten nicht geladen werden.",
"cw_settings_err_save": "Die Einstellung konnte nicht gespeichert werden. Versuche es erneut.",
"cw_settings_err_tile_add": "PS5Upload konnte nicht zum Startbildschirm hinzugefügt werden. Versuche es erneut.",
"cw_settings_err_tile_remove": "PS5Upload konnte nicht vom Startbildschirm entfernt werden. Versuche es erneut.",
"cw_settings_fullscreen": "Vollbild",
"cw_settings_fullscreen_note": "Blendet die Leisten des Browsers aus, damit die Seite den ganzen Fernseher füllt. Gilt ab dem nächsten Tastendruck.",
"cw_settings_lan": "Andere Geräte im Netzwerk zulassen",
"cw_settings_lan_allow": "Zulassen",
"cw_settings_lan_confirm_title": "Andere Geräte zulassen?",
@@ -480,8 +504,13 @@
"cw_files_view": "View",
"cw_hint_back": "Back",
"cw_hint_filter": "Filter",
"cw_hint_fullscreen": "Full screen",
"cw_hint_help": "Help",
"cw_hint_open": "Open",
"cw_hint_page": "Page up/down",
"cw_hint_refresh": "Refresh",
"cw_hint_switch_section": "Switch section",
"cw_hint_top": "Top",
"cw_hints_label": "Controller buttons",
"cw_home_browse_usb": "Browse USB",
"cw_home_cancel_task": "Cancel",
@@ -542,10 +571,13 @@
"cw_select": "Select",
"cw_settings_about": "About",
"cw_settings_app_name": "PS5Upload",
"cw_settings_display": "Display",
"cw_settings_err_load": "Could not load the settings.",
"cw_settings_err_save": "Could not save the setting. Try again.",
"cw_settings_err_tile_add": "Could not add PS5Upload to the home screen. Try again.",
"cw_settings_err_tile_remove": "Could not remove PS5Upload from the home screen. Try again.",
"cw_settings_fullscreen": "Full screen",
"cw_settings_fullscreen_note": "Hides the browser's bars so the page fills the TV. Takes effect on your next button press.",
"cw_settings_lan": "Allow other devices on the network",
"cw_settings_lan_allow": "Allow",
"cw_settings_lan_confirm_title": "Allow other devices?",
@@ -621,8 +653,13 @@
"cw_files_view": "Ver",
"cw_hint_back": "Atrás",
"cw_hint_filter": "Filtrar",
"cw_hint_fullscreen": "Pantalla completa",
"cw_hint_help": "Ayuda",
"cw_hint_open": "Abrir",
"cw_hint_page": "Página arriba/abajo",
"cw_hint_refresh": "Actualizar",
"cw_hint_switch_section": "Cambiar de sección",
"cw_hint_top": "Arriba del todo",
"cw_hints_label": "Botones del mando",
"cw_home_browse_usb": "Explorar USB",
"cw_home_cancel_task": "Cancelar",
@@ -683,10 +720,13 @@
"cw_select": "Seleccionar",
"cw_settings_about": "Acerca de",
"cw_settings_app_name": "PS5Upload",
"cw_settings_display": "Pantalla",
"cw_settings_err_load": "No se pudieron cargar los ajustes.",
"cw_settings_err_save": "No se pudo guardar el ajuste. Inténtalo de nuevo.",
"cw_settings_err_tile_add": "No se pudo añadir PS5Upload a la pantalla de inicio. Inténtalo de nuevo.",
"cw_settings_err_tile_remove": "No se pudo quitar PS5Upload de la pantalla de inicio. Inténtalo de nuevo.",
"cw_settings_fullscreen": "Pantalla completa",
"cw_settings_fullscreen_note": "Oculta las barras del navegador para que la página ocupe toda la TV. Se aplica en tu próxima pulsación.",
"cw_settings_lan": "Permitir otros dispositivos de la red",
"cw_settings_lan_allow": "Permitir",
"cw_settings_lan_confirm_title": "¿Permitir otros dispositivos?",
@@ -762,8 +802,13 @@
"cw_files_view": "نمایش",
"cw_hint_back": "بازگشت",
"cw_hint_filter": "فیلتر",
"cw_hint_fullscreen": "تمام‌صفحه",
"cw_hint_help": "راهنما",
"cw_hint_open": "باز کردن",
"cw_hint_page": "صفحه بالا/پایین",
"cw_hint_refresh": "تازه‌سازی",
"cw_hint_switch_section": "تغییر بخش",
"cw_hint_top": "بالای صفحه",
"cw_hints_label": "دکمه‌های دسته",
"cw_home_browse_usb": "مرور USB",
"cw_home_cancel_task": "لغو",
@@ -824,10 +869,13 @@
"cw_select": "انتخاب",
"cw_settings_about": "درباره",
"cw_settings_app_name": "PS5Upload",
"cw_settings_display": "نمایش",
"cw_settings_err_load": "تنظیمات بارگیری نشد.",
"cw_settings_err_save": "تنظیم ذخیره نشد. دوباره تلاش کنید.",
"cw_settings_err_tile_add": "افزودن PS5Upload به صفحه اصلی انجام نشد. دوباره تلاش کنید.",
"cw_settings_err_tile_remove": "حذف PS5Upload از صفحه اصلی انجام نشد. دوباره تلاش کنید.",
"cw_settings_fullscreen": "تمام‌صفحه",
"cw_settings_fullscreen_note": "نوارهای مرورگر را پنهان می‌کند تا صفحه کل تلویزیون را پر کند. با فشار بعدی دکمه اعمال می‌شود.",
"cw_settings_lan": "اجازه به دستگاه‌های دیگر در شبکه",
"cw_settings_lan_allow": "اجازه",
"cw_settings_lan_confirm_title": "به دستگاه‌های دیگر اجازه داده شود؟",
@@ -903,8 +951,13 @@
"cw_files_view": "Afficher",
"cw_hint_back": "Retour",
"cw_hint_filter": "Filtrer",
"cw_hint_fullscreen": "Plein écran",
"cw_hint_help": "Aide",
"cw_hint_open": "Ouvrir",
"cw_hint_page": "Page préc./suiv.",
"cw_hint_refresh": "Actualiser",
"cw_hint_switch_section": "Changer de section",
"cw_hint_top": "Haut",
"cw_hints_label": "Boutons de la manette",
"cw_home_browse_usb": "Parcourir l'USB",
"cw_home_cancel_task": "Annuler",
@@ -965,10 +1018,13 @@
"cw_select": "Sélectionner",
"cw_settings_about": "À propos",
"cw_settings_app_name": "PS5Upload",
"cw_settings_display": "Affichage",
"cw_settings_err_load": "Impossible de charger les réglages.",
"cw_settings_err_save": "Impossible d'enregistrer le réglage. Réessayez.",
"cw_settings_err_tile_add": "Impossible d'ajouter PS5Upload à l'écran d'accueil. Réessayez.",
"cw_settings_err_tile_remove": "Impossible de retirer PS5Upload de l'écran d'accueil. Réessayez.",
"cw_settings_fullscreen": "Plein écran",
"cw_settings_fullscreen_note": "Masque les barres du navigateur pour que la page remplisse la TV. Prend effet à votre prochaine pression de touche.",
"cw_settings_lan": "Autoriser les autres appareils du réseau",
"cw_settings_lan_allow": "Autoriser",
"cw_settings_lan_confirm_title": "Autoriser les autres appareils ?",
@@ -1044,8 +1100,13 @@
"cw_files_view": "देखें",
"cw_hint_back": "वापस",
"cw_hint_filter": "फ़िल्टर",
"cw_hint_fullscreen": "फ़ुल स्क्रीन",
"cw_hint_help": "सहायता",
"cw_hint_open": "खोलें",
"cw_hint_page": "पेज ऊपर/नीचे",
"cw_hint_refresh": "रीफ़्रेश करें",
"cw_hint_switch_section": "अनुभाग बदलें",
"cw_hint_top": "सबसे ऊपर",
"cw_hints_label": "कंट्रोलर के बटन",
"cw_home_browse_usb": "USB ब्राउज़ करें",
"cw_home_cancel_task": "रद्द करें",
@@ -1106,10 +1167,13 @@
"cw_select": "चुनें",
"cw_settings_about": "परिचय",
"cw_settings_app_name": "PS5Upload",
"cw_settings_display": "डिस्प्ले",
"cw_settings_err_load": "सेटिंग लोड नहीं हो सकीं।",
"cw_settings_err_save": "सेटिंग सहेजी नहीं जा सकी। फिर कोशिश करें।",
"cw_settings_err_tile_add": "होम स्क्रीन पर PS5Upload नहीं जोड़ा जा सका। फिर कोशिश करें।",
"cw_settings_err_tile_remove": "होम स्क्रीन से PS5Upload नहीं हटाया जा सका। फिर कोशिश करें।",
"cw_settings_fullscreen": "फ़ुल स्क्रीन",
"cw_settings_fullscreen_note": "ब्राउज़र की पट्टियाँ छिपाता है ताकि पेज पूरे टीवी पर दिखे। अगली बार बटन दबाने पर लागू होता है।",
"cw_settings_lan": "नेटवर्क के अन्य डिवाइस को अनुमति दें",
"cw_settings_lan_allow": "अनुमति दें",
"cw_settings_lan_confirm_title": "अन्य डिवाइस को अनुमति दें?",
@@ -1185,8 +1249,13 @@
"cw_files_view": "Megtekintés",
"cw_hint_back": "Vissza",
"cw_hint_filter": "Szűrés",
"cw_hint_fullscreen": "Teljes képernyő",
"cw_hint_help": "Súgó",
"cw_hint_open": "Megnyitás",
"cw_hint_page": "Lapozás fel/le",
"cw_hint_refresh": "Frissítés",
"cw_hint_switch_section": "Rész váltása",
"cw_hint_top": "Elejére",
"cw_hints_label": "Kontroller gombjai",
"cw_home_browse_usb": "USB tallózása",
"cw_home_cancel_task": "Megszakítás",
@@ -1247,10 +1316,13 @@
"cw_select": "Kijelölés",
"cw_settings_about": "Névjegy",
"cw_settings_app_name": "PS5Upload",
"cw_settings_display": "Megjelenítés",
"cw_settings_err_load": "A beállításokat nem sikerült betölteni.",
"cw_settings_err_save": "A beállítást nem sikerült menteni. Próbáld újra.",
"cw_settings_err_tile_add": "A PS5Upload nem került fel a kezdőképernyőre. Próbáld újra.",
"cw_settings_err_tile_remove": "A PS5Upload nem került le a kezdőképernyőről. Próbáld újra.",
"cw_settings_fullscreen": "Teljes képernyő",
"cw_settings_fullscreen_note": "Elrejti a böngésző sávjait, hogy az oldal kitöltse a tévét. A következő gombnyomástól érvényes.",
"cw_settings_lan": "Más eszközök engedélyezése a hálózaton",
"cw_settings_lan_allow": "Engedélyez",
"cw_settings_lan_confirm_title": "Engedélyezed más eszközöknek?",
@@ -1326,8 +1398,13 @@
"cw_files_view": "Lihat",
"cw_hint_back": "Kembali",
"cw_hint_filter": "Filter",
"cw_hint_fullscreen": "Layar penuh",
"cw_hint_help": "Bantuan",
"cw_hint_open": "Buka",
"cw_hint_page": "Halaman atas/bawah",
"cw_hint_refresh": "Muat ulang",
"cw_hint_switch_section": "Ganti bagian",
"cw_hint_top": "Paling atas",
"cw_hints_label": "Tombol kontroler",
"cw_home_browse_usb": "Jelajahi USB",
"cw_home_cancel_task": "Batalkan",
@@ -1388,10 +1465,13 @@
"cw_select": "Pilih",
"cw_settings_about": "Tentang",
"cw_settings_app_name": "PS5Upload",
"cw_settings_display": "Tampilan",
"cw_settings_err_load": "Pengaturan tidak dapat dimuat.",
"cw_settings_err_save": "Pengaturan tidak dapat disimpan. Coba lagi.",
"cw_settings_err_tile_add": "PS5Upload tidak dapat ditambahkan ke layar utama. Coba lagi.",
"cw_settings_err_tile_remove": "PS5Upload tidak dapat dihapus dari layar utama. Coba lagi.",
"cw_settings_fullscreen": "Layar penuh",
"cw_settings_fullscreen_note": "Menyembunyikan bilah browser agar halaman memenuhi TV. Berlaku saat Anda menekan tombol berikutnya.",
"cw_settings_lan": "Izinkan perangkat lain di jaringan",
"cw_settings_lan_allow": "Izinkan",
"cw_settings_lan_confirm_title": "Izinkan perangkat lain?",
@@ -1467,8 +1547,13 @@
"cw_files_view": "Visualizza",
"cw_hint_back": "Indietro",
"cw_hint_filter": "Filtra",
"cw_hint_fullscreen": "Schermo intero",
"cw_hint_help": "Guida",
"cw_hint_open": "Apri",
"cw_hint_page": "Pagina su/giù",
"cw_hint_refresh": "Aggiorna",
"cw_hint_switch_section": "Cambia sezione",
"cw_hint_top": "Inizio",
"cw_hints_label": "Tasti del controller",
"cw_home_browse_usb": "Sfoglia USB",
"cw_home_cancel_task": "Annulla",
@@ -1529,10 +1614,13 @@
"cw_select": "Seleziona",
"cw_settings_about": "Info",
"cw_settings_app_name": "PS5Upload",
"cw_settings_display": "Schermo",
"cw_settings_err_load": "Impossibile caricare le impostazioni.",
"cw_settings_err_save": "Impossibile salvare l'impostazione. Riprova.",
"cw_settings_err_tile_add": "Impossibile aggiungere PS5Upload alla schermata Home. Riprova.",
"cw_settings_err_tile_remove": "Impossibile rimuovere PS5Upload dalla schermata Home. Riprova.",
"cw_settings_fullscreen": "Schermo intero",
"cw_settings_fullscreen_note": "Nasconde le barre del browser così la pagina riempie la TV. Si applica alla prossima pressione di un tasto.",
"cw_settings_lan": "Consenti ad altri dispositivi della rete",
"cw_settings_lan_allow": "Consenti",
"cw_settings_lan_confirm_title": "Consentire ad altri dispositivi?",
@@ -1608,8 +1696,13 @@
"cw_files_view": "表示",
"cw_hint_back": "戻る",
"cw_hint_filter": "絞り込み",
"cw_hint_fullscreen": "全画面",
"cw_hint_help": "ヘルプ",
"cw_hint_open": "開く",
"cw_hint_page": "前/次のページ",
"cw_hint_refresh": "更新",
"cw_hint_switch_section": "セクションを切り替え",
"cw_hint_top": "先頭へ",
"cw_hints_label": "コントローラーのボタン",
"cw_home_browse_usb": "USBを参照",
"cw_home_cancel_task": "キャンセル",
@@ -1670,10 +1763,13 @@
"cw_select": "選択",
"cw_settings_about": "情報",
"cw_settings_app_name": "PS5Upload",
"cw_settings_display": "表示",
"cw_settings_err_load": "設定を読み込めませんでした。",
"cw_settings_err_save": "設定を保存できませんでした。もう一度お試しください。",
"cw_settings_err_tile_add": "ホーム画面に PS5Upload を追加できませんでした。もう一度お試しください。",
"cw_settings_err_tile_remove": "ホーム画面から PS5Upload を削除できませんでした。もう一度お試しください。",
"cw_settings_fullscreen": "全画面",
"cw_settings_fullscreen_note": "ブラウザーのバーを隠して、ページをテレビ全体に表示します。次にボタンを押したときに有効になります。",
"cw_settings_lan": "ネットワーク上の他の機器を許可",
"cw_settings_lan_allow": "許可する",
"cw_settings_lan_confirm_title": "他の機器を許可しますか?",
@@ -1749,8 +1845,13 @@
"cw_files_view": "보기",
"cw_hint_back": "뒤로",
"cw_hint_filter": "필터",
"cw_hint_fullscreen": "전체 화면",
"cw_hint_help": "도움말",
"cw_hint_open": "열기",
"cw_hint_page": "페이지 위/아래",
"cw_hint_refresh": "새로 고침",
"cw_hint_switch_section": "섹션 전환",
"cw_hint_top": "맨 위로",
"cw_hints_label": "컨트롤러 버튼",
"cw_home_browse_usb": "USB 탐색",
"cw_home_cancel_task": "취소",
@@ -1811,10 +1912,13 @@
"cw_select": "선택",
"cw_settings_about": "정보",
"cw_settings_app_name": "PS5Upload",
"cw_settings_display": "화면",
"cw_settings_err_load": "설정을 불러오지 못했습니다.",
"cw_settings_err_save": "설정을 저장하지 못했습니다. 다시 시도하세요.",
"cw_settings_err_tile_add": "홈 화면에 PS5Upload를 추가하지 못했습니다. 다시 시도하세요.",
"cw_settings_err_tile_remove": "홈 화면에서 PS5Upload를 제거하지 못했습니다. 다시 시도하세요.",
"cw_settings_fullscreen": "전체 화면",
"cw_settings_fullscreen_note": "브라우저 막대를 숨겨 페이지가 TV 화면을 가득 채웁니다. 다음에 버튼을 누를 때 적용됩니다.",
"cw_settings_lan": "네트워크의 다른 기기 허용",
"cw_settings_lan_allow": "허용",
"cw_settings_lan_confirm_title": "다른 기기를 허용할까요?",
@@ -1890,8 +1994,13 @@
"cw_files_view": "Pokaż",
"cw_hint_back": "Wstecz",
"cw_hint_filter": "Filtruj",
"cw_hint_fullscreen": "Pełny ekran",
"cw_hint_help": "Pomoc",
"cw_hint_open": "Otwórz",
"cw_hint_page": "Strona w górę/w dół",
"cw_hint_refresh": "Odśwież",
"cw_hint_switch_section": "Zmień sekcję",
"cw_hint_top": "Na górę",
"cw_hints_label": "Przyciski kontrolera",
"cw_home_browse_usb": "Przeglądaj USB",
"cw_home_cancel_task": "Anuluj",
@@ -1952,10 +2061,13 @@
"cw_select": "Zaznacz",
"cw_settings_about": "Informacje",
"cw_settings_app_name": "PS5Upload",
"cw_settings_display": "Ekran",
"cw_settings_err_load": "Nie udało się wczytać ustawień.",
"cw_settings_err_save": "Nie udało się zapisać ustawienia. Spróbuj ponownie.",
"cw_settings_err_tile_add": "Nie udało się dodać PS5Upload do ekranu głównego. Spróbuj ponownie.",
"cw_settings_err_tile_remove": "Nie udało się usunąć PS5Upload z ekranu głównego. Spróbuj ponownie.",
"cw_settings_fullscreen": "Pełny ekran",
"cw_settings_fullscreen_note": "Ukrywa paski przeglądarki, aby strona wypełniała cały telewizor. Działa od następnego naciśnięcia przycisku.",
"cw_settings_lan": "Zezwalaj innym urządzeniom w sieci",
"cw_settings_lan_allow": "Zezwól",
"cw_settings_lan_confirm_title": "Zezwolić innym urządzeniom?",
@@ -2031,8 +2143,13 @@
"cw_files_view": "Ver",
"cw_hint_back": "Voltar",
"cw_hint_filter": "Filtrar",
"cw_hint_fullscreen": "Tela cheia",
"cw_hint_help": "Ajuda",
"cw_hint_open": "Abrir",
"cw_hint_page": "Página acima/abaixo",
"cw_hint_refresh": "Atualizar",
"cw_hint_switch_section": "Trocar de seção",
"cw_hint_top": "Topo",
"cw_hints_label": "Botões do controle",
"cw_home_browse_usb": "Navegar no USB",
"cw_home_cancel_task": "Cancelar",
@@ -2093,10 +2210,13 @@
"cw_select": "Selecionar",
"cw_settings_about": "Sobre",
"cw_settings_app_name": "PS5Upload",
"cw_settings_display": "Tela",
"cw_settings_err_load": "Não foi possível carregar as configurações.",
"cw_settings_err_save": "Não foi possível salvar a configuração. Tente de novo.",
"cw_settings_err_tile_add": "Não foi possível adicionar o PS5Upload à tela inicial. Tente de novo.",
"cw_settings_err_tile_remove": "Não foi possível remover o PS5Upload da tela inicial. Tente de novo.",
"cw_settings_fullscreen": "Tela cheia",
"cw_settings_fullscreen_note": "Esconde as barras do navegador para a página ocupar a TV inteira. Vale a partir do próximo botão pressionado.",
"cw_settings_lan": "Permitir outros dispositivos da rede",
"cw_settings_lan_allow": "Permitir",
"cw_settings_lan_confirm_title": "Permitir outros dispositivos?",
@@ -2172,8 +2292,13 @@
"cw_files_view": "Просмотр",
"cw_hint_back": "Назад",
"cw_hint_filter": "Фильтр",
"cw_hint_fullscreen": "Полный экран",
"cw_hint_help": "Справка",
"cw_hint_open": "Открыть",
"cw_hint_page": "Страница вверх/вниз",
"cw_hint_refresh": "Обновить",
"cw_hint_switch_section": "Сменить раздел",
"cw_hint_top": "В начало",
"cw_hints_label": "Кнопки геймпада",
"cw_home_browse_usb": "Открыть USB",
"cw_home_cancel_task": "Отменить",
@@ -2234,10 +2359,13 @@
"cw_select": "Выбрать",
"cw_settings_about": "О программе",
"cw_settings_app_name": "PS5Upload",
"cw_settings_display": "Экран",
"cw_settings_err_load": "Не удалось загрузить настройки.",
"cw_settings_err_save": "Не удалось сохранить настройку. Повторите попытку.",
"cw_settings_err_tile_add": "Не удалось добавить PS5Upload на главный экран. Повторите попытку.",
"cw_settings_err_tile_remove": "Не удалось убрать PS5Upload с главного экрана. Повторите попытку.",
"cw_settings_fullscreen": "Полный экран",
"cw_settings_fullscreen_note": "Скрывает панели браузера, чтобы страница заполняла весь телевизор. Срабатывает при следующем нажатии кнопки.",
"cw_settings_lan": "Разрешить другим устройствам в сети",
"cw_settings_lan_allow": "Разрешить",
"cw_settings_lan_confirm_title": "Разрешить другим устройствам?",
@@ -2313,8 +2441,13 @@
"cw_files_view": "ดู",
"cw_hint_back": "กลับ",
"cw_hint_filter": "ตัวกรอง",
"cw_hint_fullscreen": "เต็มจอ",
"cw_hint_help": "ความช่วยเหลือ",
"cw_hint_open": "เปิด",
"cw_hint_page": "เลื่อนขึ้น/ลงทีละหน้า",
"cw_hint_refresh": "รีเฟรช",
"cw_hint_switch_section": "สลับส่วน",
"cw_hint_top": "บนสุด",
"cw_hints_label": "ปุ่มคอนโทรลเลอร์",
"cw_home_browse_usb": "เรียกดู USB",
"cw_home_cancel_task": "ยกเลิก",
@@ -2375,10 +2508,13 @@
"cw_select": "เลือก",
"cw_settings_about": "เกี่ยวกับ",
"cw_settings_app_name": "PS5Upload",
"cw_settings_display": "การแสดงผล",
"cw_settings_err_load": "โหลดการตั้งค่าไม่ได้",
"cw_settings_err_save": "บันทึกการตั้งค่าไม่ได้ ลองอีกครั้ง",
"cw_settings_err_tile_add": "เพิ่ม PS5Upload ลงในหน้าจอหลักไม่ได้ ลองอีกครั้ง",
"cw_settings_err_tile_remove": "นำ PS5Upload ออกจากหน้าจอหลักไม่ได้ ลองอีกครั้ง",
"cw_settings_fullscreen": "เต็มจอ",
"cw_settings_fullscreen_note": "ซ่อนแถบของเบราว์เซอร์เพื่อให้หน้าเต็มจอทีวี มีผลเมื่อคุณกดปุ่มครั้งถัดไป",
"cw_settings_lan": "อนุญาตอุปกรณ์อื่นในเครือข่าย",
"cw_settings_lan_allow": "อนุญาต",
"cw_settings_lan_confirm_title": "อนุญาตอุปกรณ์อื่นหรือไม่",
@@ -2454,8 +2590,13 @@
"cw_files_view": "Görüntüle",
"cw_hint_back": "Geri",
"cw_hint_filter": "Filtrele",
"cw_hint_fullscreen": "Tam ekran",
"cw_hint_help": "Yardım",
"cw_hint_open": "Aç",
"cw_hint_page": "Sayfa yukarı/aşağı",
"cw_hint_refresh": "Yenile",
"cw_hint_switch_section": "Bölüm değiştir",
"cw_hint_top": "En üst",
"cw_hints_label": "Kumanda düğmeleri",
"cw_home_browse_usb": "USB'ye göz at",
"cw_home_cancel_task": "İptal et",
@@ -2516,10 +2657,13 @@
"cw_select": "Seç",
"cw_settings_about": "Hakkında",
"cw_settings_app_name": "PS5Upload",
"cw_settings_display": "Ekran",
"cw_settings_err_load": "Ayarlar yüklenemedi.",
"cw_settings_err_save": "Ayar kaydedilemedi. Tekrar deneyin.",
"cw_settings_err_tile_add": "PS5Upload ana ekrana eklenemedi. Tekrar deneyin.",
"cw_settings_err_tile_remove": "PS5Upload ana ekrandan kaldırılamadı. Tekrar deneyin.",
"cw_settings_fullscreen": "Tam ekran",
"cw_settings_fullscreen_note": "Sayfanın tüm TV'yi doldurması için tarayıcı çubuklarını gizler. Bir sonraki tuşa basışınızda etkinleşir.",
"cw_settings_lan": "Ağdaki diğer cihazlara izin ver",
"cw_settings_lan_allow": "İzin ver",
"cw_settings_lan_confirm_title": "Diğer cihazlara izin verilsin mi?",
@@ -2595,8 +2739,13 @@
"cw_files_view": "Xem",
"cw_hint_back": "Quay lại",
"cw_hint_filter": "Lọc",
"cw_hint_fullscreen": "Toàn màn hình",
"cw_hint_help": "Trợ giúp",
"cw_hint_open": "Mở",
"cw_hint_page": "Lên/xuống một trang",
"cw_hint_refresh": "Làm mới",
"cw_hint_switch_section": "Chuyển mục",
"cw_hint_top": "Lên đầu",
"cw_hints_label": "Nút tay cầm",
"cw_home_browse_usb": "Duyệt USB",
"cw_home_cancel_task": "Hủy",
@@ -2657,10 +2806,13 @@
"cw_select": "Chọn",
"cw_settings_about": "Giới thiệu",
"cw_settings_app_name": "PS5Upload",
"cw_settings_display": "Hiển thị",
"cw_settings_err_load": "Không thể tải cài đặt.",
"cw_settings_err_save": "Không thể lưu cài đặt. Hãy thử lại.",
"cw_settings_err_tile_add": "Không thể thêm PS5Upload vào màn hình chính. Hãy thử lại.",
"cw_settings_err_tile_remove": "Không thể gỡ PS5Upload khỏi màn hình chính. Hãy thử lại.",
"cw_settings_fullscreen": "Toàn màn hình",
"cw_settings_fullscreen_note": "Ẩn các thanh của trình duyệt để trang lấp đầy TV. Có hiệu lực ở lần bấm nút tiếp theo.",
"cw_settings_lan": "Cho phép thiết bị khác trong mạng",
"cw_settings_lan_allow": "Cho phép",
"cw_settings_lan_confirm_title": "Cho phép thiết bị khác?",
@@ -2736,8 +2888,13 @@
"cw_files_view": "查看",
"cw_hint_back": "返回",
"cw_hint_filter": "筛选",
"cw_hint_fullscreen": "全屏",
"cw_hint_help": "帮助",
"cw_hint_open": "打开",
"cw_hint_page": "上一页/下一页",
"cw_hint_refresh": "刷新",
"cw_hint_switch_section": "切换分区",
"cw_hint_top": "顶部",
"cw_hints_label": "手柄按键",
"cw_home_browse_usb": "浏览 USB",
"cw_home_cancel_task": "取消",
@@ -2798,10 +2955,13 @@
"cw_select": "选择",
"cw_settings_about": "关于",
"cw_settings_app_name": "PS5Upload",
"cw_settings_display": "显示",
"cw_settings_err_load": "无法加载设置。",
"cw_settings_err_save": "无法保存设置,请重试。",
"cw_settings_err_tile_add": "无法将 PS5Upload 添加到主屏幕,请重试。",
"cw_settings_err_tile_remove": "无法从主屏幕移除 PS5Upload,请重试。",
"cw_settings_fullscreen": "全屏",
"cw_settings_fullscreen_note": "隐藏浏览器的工具栏,让页面占满电视。在你下次按键时生效。",
"cw_settings_lan": "允许网络中的其他设备",
"cw_settings_lan_allow": "允许",
"cw_settings_lan_confirm_title": "允许其他设备?",
@@ -2877,8 +3037,13 @@
"cw_files_view": "檢視",
"cw_hint_back": "返回",
"cw_hint_filter": "篩選",
"cw_hint_fullscreen": "全螢幕",
"cw_hint_help": "說明",
"cw_hint_open": "開啟",
"cw_hint_page": "上一頁/下一頁",
"cw_hint_refresh": "重新整理",
"cw_hint_switch_section": "切換分區",
"cw_hint_top": "頂端",
"cw_hints_label": "控制器按鍵",
"cw_home_browse_usb": "瀏覽 USB",
"cw_home_cancel_task": "取消",
@@ -2939,10 +3104,13 @@
"cw_select": "選取",
"cw_settings_about": "關於",
"cw_settings_app_name": "PS5Upload",
"cw_settings_display": "顯示",
"cw_settings_err_load": "無法載入設定。",
"cw_settings_err_save": "無法儲存設定,請再試一次。",
"cw_settings_err_tile_add": "無法將 PS5Upload 加到主畫面,請再試一次。",
"cw_settings_err_tile_remove": "無法從主畫面移除 PS5Upload,請再試一次。",
"cw_settings_fullscreen": "全螢幕",
"cw_settings_fullscreen_note": "隱藏瀏覽器的工具列,讓頁面佔滿電視。在你下次按鍵時生效。",
"cw_settings_lan": "允許網路中的其他裝置",
"cw_settings_lan_allow": "允許",
"cw_settings_lan_confirm_title": "允許其他裝置?",
+8
View File
@@ -4169,6 +4169,14 @@ c2c_test_deep_bad: "لم يصل الإرسال التجريبي بحجم 4 KiB:
"cw_files_too_large": "كبير جدًا للمعاينة.",
"cw_settings_err_tile_add": "تعذرت إضافة PS5Upload إلى الشاشة الرئيسية. حاول مرة أخرى.",
"cw_settings_err_tile_remove": "تعذرت إزالة PS5Upload من الشاشة الرئيسية. حاول مرة أخرى.",
"cw_hint_page": "صفحة لأعلى/لأسفل",
"cw_hint_top": "الأعلى",
"cw_hint_help": "مساعدة",
"cw_hint_refresh": "تحديث",
"cw_hint_fullscreen": "ملء الشاشة",
"cw_settings_display": "العرض",
"cw_settings_fullscreen": "ملء الشاشة",
"cw_settings_fullscreen_note": "يخفي أشرطة المتصفح لتملأ الصفحة شاشة التلفاز. يسري عند ضغطتك التالية على زر.",
};
export default ar;
+8
View File
@@ -4169,6 +4169,14 @@ c2c_test_deep_bad: "4 KiB-এর পরীক্ষামূলক পাঠা
"cw_files_too_large": "প্রিভিউ করার জন্য খুব বড়।",
"cw_settings_err_tile_add": "হোম স্ক্রিনে PS5Upload যোগ করা যায়নি। আবার চেষ্টা করুন।",
"cw_settings_err_tile_remove": "হোম স্ক্রিন থেকে PS5Upload সরানো যায়নি। আবার চেষ্টা করুন।",
"cw_hint_page": "পেজ উপরে/নিচে",
"cw_hint_top": "একদম উপরে",
"cw_hint_help": "সাহায্য",
"cw_hint_refresh": "রিফ্রেশ",
"cw_hint_fullscreen": "ফুল স্ক্রিন",
"cw_settings_display": "ডিসপ্লে",
"cw_settings_fullscreen": "ফুল স্ক্রিন",
"cw_settings_fullscreen_note": "ব্রাউজারের বারগুলো লুকিয়ে রাখে যাতে পেজটি পুরো টিভি জুড়ে থাকে। পরের বার বোতাম চাপলে কার্যকর হয়।",
};
export default bn;
+8
View File
@@ -4177,6 +4177,14 @@ c2c_test_deep_bad: "Die 4-KiB-Testsendung ist nicht angekommen: {detail}",
"cw_files_too_large": "Zu groß für die Vorschau.",
"cw_settings_err_tile_add": "PS5Upload konnte nicht zum Startbildschirm hinzugefügt werden. Versuche es erneut.",
"cw_settings_err_tile_remove": "PS5Upload konnte nicht vom Startbildschirm entfernt werden. Versuche es erneut.",
"cw_hint_page": "Seite hoch/runter",
"cw_hint_top": "Nach oben",
"cw_hint_help": "Hilfe",
"cw_hint_refresh": "Aktualisieren",
"cw_hint_fullscreen": "Vollbild",
"cw_settings_display": "Anzeige",
"cw_settings_fullscreen": "Vollbild",
"cw_settings_fullscreen_note": "Blendet die Leisten des Browsers aus, damit die Seite den ganzen Fernseher füllt. Gilt ab dem nächsten Tastendruck.",
};
export default de;
+8
View File
@@ -4492,6 +4492,14 @@ c2c_test_deep_bad: "The 4 KiB test send did not arrive: {detail}",
"cw_files_too_large": "Too large to preview.",
"cw_settings_err_tile_add": "Could not add PS5Upload to the home screen. Try again.",
"cw_settings_err_tile_remove": "Could not remove PS5Upload from the home screen. Try again.",
"cw_hint_page": "Page up/down",
"cw_hint_top": "Top",
"cw_hint_help": "Help",
"cw_hint_refresh": "Refresh",
"cw_hint_fullscreen": "Full screen",
"cw_settings_display": "Display",
"cw_settings_fullscreen": "Full screen",
"cw_settings_fullscreen_note": "Hides the browser's bars so the page fills the TV. Takes effect on your next button press.",
};
export default en;
+8
View File
@@ -4119,6 +4119,14 @@ c2c_test_deep_bad: "El envío de prueba de 4 KiB no llegó: {detail}",
"cw_files_too_large": "Demasiado grande para la vista previa.",
"cw_settings_err_tile_add": "No se pudo añadir PS5Upload a la pantalla de inicio. Inténtalo de nuevo.",
"cw_settings_err_tile_remove": "No se pudo quitar PS5Upload de la pantalla de inicio. Inténtalo de nuevo.",
"cw_hint_page": "Página arriba/abajo",
"cw_hint_top": "Arriba del todo",
"cw_hint_help": "Ayuda",
"cw_hint_refresh": "Actualizar",
"cw_hint_fullscreen": "Pantalla completa",
"cw_settings_display": "Pantalla",
"cw_settings_fullscreen": "Pantalla completa",
"cw_settings_fullscreen_note": "Oculta las barras del navegador para que la página ocupe toda la TV. Se aplica en tu próxima pulsación.",
};
export default es;
+8
View File
@@ -4470,6 +4470,14 @@ c2c_test_deep_bad: "ارسال آزمایشی ۴ KiB نرسید: {detail}",
"cw_files_too_large": "برای پیش‌نمایش بیش از حد بزرگ است.",
"cw_settings_err_tile_add": "افزودن PS5Upload به صفحه اصلی انجام نشد. دوباره تلاش کنید.",
"cw_settings_err_tile_remove": "حذف PS5Upload از صفحه اصلی انجام نشد. دوباره تلاش کنید.",
"cw_hint_page": "صفحه بالا/پایین",
"cw_hint_top": "بالای صفحه",
"cw_hint_help": "راهنما",
"cw_hint_refresh": "تازه‌سازی",
"cw_hint_fullscreen": "تمام‌صفحه",
"cw_settings_display": "نمایش",
"cw_settings_fullscreen": "تمام‌صفحه",
"cw_settings_fullscreen_note": "نوارهای مرورگر را پنهان می‌کند تا صفحه کل تلویزیون را پر کند. با فشار بعدی دکمه اعمال می‌شود.",
};
export default fa;
+8
View File
@@ -4187,6 +4187,14 @@ c2c_test_deep_bad: "L’envoi test de 4 Kio n’est pas arrivé : {detail}",
"cw_files_too_large": "Trop volumineux pour l'aperçu.",
"cw_settings_err_tile_add": "Impossible d'ajouter PS5Upload à l'écran d'accueil. Réessayez.",
"cw_settings_err_tile_remove": "Impossible de retirer PS5Upload de l'écran d'accueil. Réessayez.",
"cw_hint_page": "Page préc./suiv.",
"cw_hint_top": "Haut",
"cw_hint_help": "Aide",
"cw_hint_refresh": "Actualiser",
"cw_hint_fullscreen": "Plein écran",
"cw_settings_display": "Affichage",
"cw_settings_fullscreen": "Plein écran",
"cw_settings_fullscreen_note": "Masque les barres du navigateur pour que la page remplisse la TV. Prend effet à votre prochaine pression de touche.",
};
export default fr;
+8
View File
@@ -4185,6 +4185,14 @@ c2c_test_deep_bad: "4 KiB का परीक्षण नहीं पहुँ
"cw_files_too_large": "पूर्वावलोकन के लिए बहुत बड़ा है।",
"cw_settings_err_tile_add": "होम स्क्रीन पर PS5Upload नहीं जोड़ा जा सका। फिर कोशिश करें।",
"cw_settings_err_tile_remove": "होम स्क्रीन से PS5Upload नहीं हटाया जा सका। फिर कोशिश करें।",
"cw_hint_page": "पेज ऊपर/नीचे",
"cw_hint_top": "सबसे ऊपर",
"cw_hint_help": "सहायता",
"cw_hint_refresh": "रीफ़्रेश करें",
"cw_hint_fullscreen": "फ़ुल स्क्रीन",
"cw_settings_display": "डिस्प्ले",
"cw_settings_fullscreen": "फ़ुल स्क्रीन",
"cw_settings_fullscreen_note": "ब्राउज़र की पट्टियाँ छिपाता है ताकि पेज पूरे टीवी पर दिखे। अगली बार बटन दबाने पर लागू होता है।",
};
export default hi;
+8
View File
@@ -4484,6 +4484,14 @@ c2c_test_deep_bad: "A 4 KiB-os tesztküldés nem érkezett meg: {detail}",
"cw_files_too_large": "Túl nagy az előnézethez.",
"cw_settings_err_tile_add": "A PS5Upload nem került fel a kezdőképernyőre. Próbáld újra.",
"cw_settings_err_tile_remove": "A PS5Upload nem került le a kezdőképernyőről. Próbáld újra.",
"cw_hint_page": "Lapozás fel/le",
"cw_hint_top": "Elejére",
"cw_hint_help": "Súgó",
"cw_hint_refresh": "Frissítés",
"cw_hint_fullscreen": "Teljes képernyő",
"cw_settings_display": "Megjelenítés",
"cw_settings_fullscreen": "Teljes képernyő",
"cw_settings_fullscreen_note": "Elrejti a böngésző sávjait, hogy az oldal kitöltse a tévét. A következő gombnyomástól érvényes.",
};
export default hu;
+8
View File
@@ -4178,6 +4178,14 @@ c2c_test_deep_bad: "Kiriman uji 4 KiB tidak tiba: {detail}",
"cw_files_too_large": "Terlalu besar untuk pratinjau.",
"cw_settings_err_tile_add": "PS5Upload tidak dapat ditambahkan ke layar utama. Coba lagi.",
"cw_settings_err_tile_remove": "PS5Upload tidak dapat dihapus dari layar utama. Coba lagi.",
"cw_hint_page": "Halaman atas/bawah",
"cw_hint_top": "Paling atas",
"cw_hint_help": "Bantuan",
"cw_hint_refresh": "Muat ulang",
"cw_hint_fullscreen": "Layar penuh",
"cw_settings_display": "Tampilan",
"cw_settings_fullscreen": "Layar penuh",
"cw_settings_fullscreen_note": "Menyembunyikan bilah browser agar halaman memenuhi TV. Berlaku saat Anda menekan tombol berikutnya.",
};
export default id;
+8
View File
@@ -4119,6 +4119,14 @@ c2c_test_deep_bad: "L’invio di prova di 4 KiB non è arrivato: {detail}",
"cw_files_too_large": "Troppo grande per l'anteprima.",
"cw_settings_err_tile_add": "Impossibile aggiungere PS5Upload alla schermata Home. Riprova.",
"cw_settings_err_tile_remove": "Impossibile rimuovere PS5Upload dalla schermata Home. Riprova.",
"cw_hint_page": "Pagina su/giù",
"cw_hint_top": "Inizio",
"cw_hint_help": "Guida",
"cw_hint_refresh": "Aggiorna",
"cw_hint_fullscreen": "Schermo intero",
"cw_settings_display": "Schermo",
"cw_settings_fullscreen": "Schermo intero",
"cw_settings_fullscreen_note": "Nasconde le barre del browser così la pagina riempie la TV. Si applica alla prossima pressione di un tasto.",
};
export default it;
+8
View File
@@ -4190,6 +4190,14 @@ c2c_test_deep_bad: "4 KiB のテスト送信が届きませんでした: {detail
"cw_files_too_large": "大きすぎてプレビューできません。",
"cw_settings_err_tile_add": "ホーム画面に PS5Upload を追加できませんでした。もう一度お試しください。",
"cw_settings_err_tile_remove": "ホーム画面から PS5Upload を削除できませんでした。もう一度お試しください。",
"cw_hint_page": "前/次のページ",
"cw_hint_top": "先頭へ",
"cw_hint_help": "ヘルプ",
"cw_hint_refresh": "更新",
"cw_hint_fullscreen": "全画面",
"cw_settings_display": "表示",
"cw_settings_fullscreen": "全画面",
"cw_settings_fullscreen_note": "ブラウザーのバーを隠して、ページをテレビ全体に表示します。次にボタンを押したときに有効になります。",
};
export default ja;
+8
View File
@@ -4188,6 +4188,14 @@ c2c_test_deep_bad: "4 KiB 테스트 전송이 도착하지 않았습니다: {det
"cw_files_too_large": "너무 커서 미리 볼 수 없습니다.",
"cw_settings_err_tile_add": "홈 화면에 PS5Upload를 추가하지 못했습니다. 다시 시도하세요.",
"cw_settings_err_tile_remove": "홈 화면에서 PS5Upload를 제거하지 못했습니다. 다시 시도하세요.",
"cw_hint_page": "페이지 위/아래",
"cw_hint_top": "맨 위로",
"cw_hint_help": "도움말",
"cw_hint_refresh": "새로 고침",
"cw_hint_fullscreen": "전체 화면",
"cw_settings_display": "화면",
"cw_settings_fullscreen": "전체 화면",
"cw_settings_fullscreen_note": "브라우저 막대를 숨겨 페이지가 TV 화면을 가득 채웁니다. 다음에 버튼을 누를 때 적용됩니다.",
};
export default ko;
+8
View File
@@ -4424,6 +4424,14 @@ c2c_test_deep_bad: "Testowa wysyłka 4 KiB nie dotarła: {detail}",
"cw_files_too_large": "Za duży, by pokazać podgląd.",
"cw_settings_err_tile_add": "Nie udało się dodać PS5Upload do ekranu głównego. Spróbuj ponownie.",
"cw_settings_err_tile_remove": "Nie udało się usunąć PS5Upload z ekranu głównego. Spróbuj ponownie.",
"cw_hint_page": "Strona w górę/w dół",
"cw_hint_top": "Na górę",
"cw_hint_help": "Pomoc",
"cw_hint_refresh": "Odśwież",
"cw_hint_fullscreen": "Pełny ekran",
"cw_settings_display": "Ekran",
"cw_settings_fullscreen": "Pełny ekran",
"cw_settings_fullscreen_note": "Ukrywa paski przeglądarki, aby strona wypełniała cały telewizor. Działa od następnego naciśnięcia przycisku.",
};
export default pl;
+8
View File
@@ -4186,6 +4186,14 @@ c2c_test_deep_bad: "O envio de teste de 4 KiB não chegou: {detail}",
"cw_files_too_large": "Grande demais para visualizar.",
"cw_settings_err_tile_add": "Não foi possível adicionar o PS5Upload à tela inicial. Tente de novo.",
"cw_settings_err_tile_remove": "Não foi possível remover o PS5Upload da tela inicial. Tente de novo.",
"cw_hint_page": "Página acima/abaixo",
"cw_hint_top": "Topo",
"cw_hint_help": "Ajuda",
"cw_hint_refresh": "Atualizar",
"cw_hint_fullscreen": "Tela cheia",
"cw_settings_display": "Tela",
"cw_settings_fullscreen": "Tela cheia",
"cw_settings_fullscreen_note": "Esconde as barras do navegador para a página ocupar a TV inteira. Vale a partir do próximo botão pressionado.",
};
export default pt_BR;
+8
View File
@@ -4119,6 +4119,14 @@ c2c_test_deep_bad: "Тестовая отправка 4 КиБ не дошла:
"cw_files_too_large": "Слишком большой для просмотра.",
"cw_settings_err_tile_add": "Не удалось добавить PS5Upload на главный экран. Повторите попытку.",
"cw_settings_err_tile_remove": "Не удалось убрать PS5Upload с главного экрана. Повторите попытку.",
"cw_hint_page": "Страница вверх/вниз",
"cw_hint_top": "В начало",
"cw_hint_help": "Справка",
"cw_hint_refresh": "Обновить",
"cw_hint_fullscreen": "Полный экран",
"cw_settings_display": "Экран",
"cw_settings_fullscreen": "Полный экран",
"cw_settings_fullscreen_note": "Скрывает панели браузера, чтобы страница заполняла весь телевизор. Срабатывает при следующем нажатии кнопки.",
};
export default ru;
+8
View File
@@ -4178,6 +4178,14 @@ c2c_test_deep_bad: "การส่งทดสอบ 4 KiB ไปไม่ถ
"cw_files_too_large": "ใหญ่เกินกว่าจะแสดงตัวอย่าง",
"cw_settings_err_tile_add": "เพิ่ม PS5Upload ลงในหน้าจอหลักไม่ได้ ลองอีกครั้ง",
"cw_settings_err_tile_remove": "นำ PS5Upload ออกจากหน้าจอหลักไม่ได้ ลองอีกครั้ง",
"cw_hint_page": "เลื่อนขึ้น/ลงทีละหน้า",
"cw_hint_top": "บนสุด",
"cw_hint_help": "ความช่วยเหลือ",
"cw_hint_refresh": "รีเฟรช",
"cw_hint_fullscreen": "เต็มจอ",
"cw_settings_display": "การแสดงผล",
"cw_settings_fullscreen": "เต็มจอ",
"cw_settings_fullscreen_note": "ซ่อนแถบของเบราว์เซอร์เพื่อให้หน้าเต็มจอทีวี มีผลเมื่อคุณกดปุ่มครั้งถัดไป",
};
export default th;
+8
View File
@@ -4176,6 +4176,14 @@ c2c_test_deep_bad: "4 KiB'lik test gönderimi ulaşmadı: {detail}",
"cw_files_too_large": "Önizleme için çok büyük.",
"cw_settings_err_tile_add": "PS5Upload ana ekrana eklenemedi. Tekrar deneyin.",
"cw_settings_err_tile_remove": "PS5Upload ana ekrandan kaldırılamadı. Tekrar deneyin.",
"cw_hint_page": "Sayfa yukarı/aşağı",
"cw_hint_top": "En üst",
"cw_hint_help": "Yardım",
"cw_hint_refresh": "Yenile",
"cw_hint_fullscreen": "Tam ekran",
"cw_settings_display": "Ekran",
"cw_settings_fullscreen": "Tam ekran",
"cw_settings_fullscreen_note": "Sayfanın tüm TV'yi doldurması için tarayıcı çubuklarını gizler. Bir sonraki tuşa basışınızda etkinleşir.",
};
export default tr;
+8
View File
@@ -4171,6 +4171,14 @@ c2c_test_deep_bad: "Lượt gửi thử 4 KiB không tới: {detail}",
"cw_files_too_large": "Quá lớn để xem trước.",
"cw_settings_err_tile_add": "Không thể thêm PS5Upload vào màn hình chính. Hãy thử lại.",
"cw_settings_err_tile_remove": "Không thể gỡ PS5Upload khỏi màn hình chính. Hãy thử lại.",
"cw_hint_page": "Lên/xuống một trang",
"cw_hint_top": "Lên đầu",
"cw_hint_help": "Trợ giúp",
"cw_hint_refresh": "Làm mới",
"cw_hint_fullscreen": "Toàn màn hình",
"cw_settings_display": "Hiển thị",
"cw_settings_fullscreen": "Toàn màn hình",
"cw_settings_fullscreen_note": "Ẩn các thanh của trình duyệt để trang lấp đầy TV. Có hiệu lực ở lần bấm nút tiếp theo.",
};
export default vi;
+8
View File
@@ -4125,6 +4125,14 @@ c2c_test_deep_bad: "4 KiB 测试发送未到达:{detail}",
"cw_files_too_large": "文件太大,无法预览。",
"cw_settings_err_tile_add": "无法将 PS5Upload 添加到主屏幕,请重试。",
"cw_settings_err_tile_remove": "无法从主屏幕移除 PS5Upload,请重试。",
"cw_hint_page": "上一页/下一页",
"cw_hint_top": "顶部",
"cw_hint_help": "帮助",
"cw_hint_refresh": "刷新",
"cw_hint_fullscreen": "全屏",
"cw_settings_display": "显示",
"cw_settings_fullscreen": "全屏",
"cw_settings_fullscreen_note": "隐藏浏览器的工具栏,让页面占满电视。在你下次按键时生效。",
};
export default zh_CN;
+8
View File
@@ -4119,6 +4119,14 @@ c2c_test_deep_bad: "4 KiB 測試傳送未抵達:{detail}",
"cw_files_too_large": "檔案太大,無法預覽。",
"cw_settings_err_tile_add": "無法將 PS5Upload 加到主畫面,請再試一次。",
"cw_settings_err_tile_remove": "無法從主畫面移除 PS5Upload,請再試一次。",
"cw_hint_page": "上一頁/下一頁",
"cw_hint_top": "頂端",
"cw_hint_help": "說明",
"cw_hint_refresh": "重新整理",
"cw_hint_fullscreen": "全螢幕",
"cw_settings_display": "顯示",
"cw_settings_fullscreen": "全螢幕",
"cw_settings_fullscreen_note": "隱藏瀏覽器的工具列,讓頁面佔滿電視。在你下次按鍵時生效。",
};
export default zh_TW;
+2 -2
View File
@@ -8,7 +8,7 @@
* be down-levelled and is rejected by the client lint rule.
* - The PC app's dark-theme tokens come from src/index.css itself (pcTokens),
* not a copy, so the page follows the app's colours.
* - Size budget: the build FAILS over 450 KiB raw or 150 KiB gzip.
* - Size budget: the build FAILS over 640 KiB raw or 150 KiB gzip.
*/
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
@@ -16,7 +16,7 @@ import { gzipSync } from "node:zlib";
import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
const RAW_BUDGET = 450 * 1024;
const RAW_BUDGET = 640 * 1024;
const GZIP_BUDGET = 150 * 1024;
const TOKENS_ID = "virtual:pc-tokens.css";
+1 -1
View File
@@ -186,7 +186,7 @@ SRCS := src/main.c src/runtime.c src/shell_builtin.c src/takeover.c src/takeover
src/tmdb.c src/fw_spoof.c src/focus_probe.c \
src/content_db.c src/mgmt_rpc.c src/fs_jobs.c src/net_probe.c src/net_ports.c src/mgmt_fs.c src/path_policy.c \
src/webui_http.c src/webui_auth.c src/webui_server.c src/webui_codec.c src/webui_api.c \
src/webui_settings.c src/webui_tile.c $(WEBUI_TILE_ICON_C) $(WEBUI_PAGE_C) \
src/webui_settings.c src/webui_inputlog.c src/webui_tile.c $(WEBUI_TILE_ICON_C) $(WEBUI_PAGE_C) \
third_party/tiny_aes/aes.c \
$(BLAKE3_SRCS)
+1
View File
@@ -42,6 +42,7 @@
* src/ffpfsc_unpack.c 1 dir decompressed image: <name>.part -> <name>, beside the .ffpfsc
* src/shell_builtin.c 1 guard `mv` (mv_same_dev, lstat of the source)
* src/webui_settings.c 1 dir webui.json: tmp -> final
* src/webui_inputlog.c 1 dir webui-input.log ring rewrite: tmp -> final
* src/webui_tile.c 1 dir tile folder files: tmp -> final
* ava1/ava1_events.c 1 dir events.log -> events.log.old
* ava1/ava1_apply.c 3 guard file and folder commit (same_device hook, only a definite 1 renames);
+4
View File
@@ -6,6 +6,10 @@
* starts as "ps5upload-console-webui". The route table in webui_api.c is the allowlist: any other
* path answers 404, a known path with another method 405.
*
* POST /api/input-log {"events":["<line>",...]} is the one route that reaches no helper method: it
* appends the page's controller-input measurements to WEBUI_INPUTLOG_PATH (webui_inputlog.h),
* bodies over WEBUI_INPUTLOG_BODY_MAX answer 413, calls closer than 500 ms apart 429.
*
* Two GET routes sit outside the table: GET / serves the embedded page (webui_page.h) and
* GET /api/token answers {"token":"<hex>"}, both after the server's Host/Origin check only. */
#ifndef PS5UPLOAD_WEBUI_API_H
+4
View File
@@ -22,6 +22,10 @@ int webui_json_u64(const char *json, size_t n, const char *key, uint64_t *out);
/* true / false: 1 found (*out 1 or 0), 0 absent, -1 any other value. */
int webui_json_bool(const char *json, size_t n, const char *key, int *out);
/* The top-level `key`'s array value as a span: *val at its '[', *len through its ']' (brackets
* balanced outside strings). 1 found, 0 absent, -1 present but not a complete array. */
int webui_json_array(const char *json, size_t n, const char *key, const char **val, size_t *len);
/* Escapes n bytes of `s` as the inside of a JSON string (no quotes added): `"` and `\` are
* backslashed, control characters become \n \r \t \b \f or \u00XX, valid UTF-8 is copied as
* is, and every byte that is not part of a valid UTF-8 sequence becomes �, so the result
+37
View File
@@ -0,0 +1,37 @@
/* The console page's input log: what the PS5 browser delivers for each controller button,
* measured on the console. POST /api/input-log {"events":["<line>",...]} (webui_api.c) appends
* one line per event to WEBUI_INPUTLOG_PATH, a ring that never grows past WEBUI_INPUTLOG_CAP.
* The page (client/src/console/inputLog.ts) sends only while its Controls screen asks for it, at
* most one request a second, each body at most WEBUI_INPUTLOG_BODY_MAX bytes.
*
* Pure file I/O and parsing, no globals, no allocation (host-tested in
* payload/tests/webui_inputlog_selftest.c). Callers serialize appends to one path themselves. */
#ifndef PS5UPLOAD_WEBUI_INPUTLOG_H
#define PS5UPLOAD_WEBUI_INPUTLOG_H
#include <stddef.h>
#define WEBUI_INPUTLOG_PATH "/data/ps5upload/webui-input.log"
#define WEBUI_INPUTLOG_CAP (64u * 1024u) /* the file's ceiling */
#define WEBUI_INPUTLOG_BODY_MAX 4096u /* one request's body */
#define WEBUI_INPUTLOG_LINE_MAX 160u /* one event's line, newline excluded */
#define WEBUI_INPUTLOG_EVENTS_MAX 256u /* events in one request */
/* `arr` (n bytes, not NUL-terminated) is a JSON array as webui_json_array returns it. Every
* element must be a plain string: 1..WEBUI_INPUTLOG_LINE_MAX bytes of printable ASCII (0x20-0x7e)
* without a quote or backslash (so no escapes: a line can never carry a newline and forge
* another), at most WEBUI_INPUTLOG_EVENTS_MAX of them. Writes each as "<string>\n" to `out`.
* Returns the bytes written (0 for an empty array), or -1: an element breaks a rule, the array
* is malformed or has trailing bytes, or `out` (cap bytes) is too small. */
long webui_inputlog_lines(const char *arr, size_t n, char *out, size_t cap);
/* Appends `add` (whole lines) to the ring file `path`. While the result stays within `cap` it is
* a plain O_APPEND write. Otherwise the file is rewritten to its newest whole lines plus `add`,
* totalling at most cap - cap/4 (the slack makes rewrites rare), through `<path>.tmp` in the same
* directory, fsync, then rename() over `path` within that directory (never across mounts); when
* `add` alone is that large only its own newest whole lines are kept. `work` (work_cap bytes, at
* least `cap`) is the scratch for a rewrite; a smaller one refuses the rewrite. A failure leaves
* the previous file whole and no temp file behind. 0, or -1. */
int webui_inputlog_append(const char *path, const char *add, size_t add_len, size_t cap, char *work, size_t work_cap);
#endif
+55
View File
@@ -12,6 +12,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include "ava1_data.h"
#include "ava1_gen.h"
@@ -24,6 +25,7 @@
#include "proc_identity.h"
#include "ps5_firmware.h"
#include "webui_codec.h"
#include "webui_inputlog.h"
#include "webui_page.h"
#include "webui_server.h"
#include "webui_settings.h"
@@ -774,6 +776,58 @@ int webui_api_tile_off(void) {
return rc;
}
/* ---- input log ---- */
/* One append at a time (the file is a ring rewritten in place), and the time of the last one: a
* page that ignores its one-a-second rule is told 429 instead of making the helper write. */
static pthread_mutex_t g_inlog_mu = PTHREAD_MUTEX_INITIALIZER;
static long long g_inlog_last_ms = -1000000;
#define INLOG_MIN_GAP_MS 500 /* half the page's period: its timer jitter never trips it */
#define INLOG_LINES_CAP (8u * 1024u)
static long long mono_ms(void) {
struct timespec ts;
if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) return 0;
return (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
}
/* {events:["<line>",...]}: appended to WEBUI_INPUTLOG_PATH (webui_inputlog.c checks every line).
* `out` (API_OUT_MAX, heap) holds the lines, then serves as the ring rewrite's scratch. */
static void r_input_log(const char *b, size_t n, uint8_t *out, int fd) {
const char *arr;
size_t alen;
long lines;
long long now;
int rc;
_Static_assert(INLOG_LINES_CAP + WEBUI_INPUTLOG_CAP <= API_OUT_MAX, "input log scratch must fit the route buffer");
if (n > WEBUI_INPUTLOG_BODY_MAX) {
send_error(fd, 413, "too_large", 9);
return;
}
if (webui_json_array(b, n, "events", &arr, &alen) != 1) {
send_bad(fd, "bad_events");
return;
}
lines = webui_inputlog_lines(arr, alen, (char *)out, INLOG_LINES_CAP);
if (lines < 0) {
send_bad(fd, "bad_events");
return;
}
pthread_mutex_lock(&g_inlog_mu);
now = mono_ms();
if (now - g_inlog_last_ms < INLOG_MIN_GAP_MS) {
pthread_mutex_unlock(&g_inlog_mu);
send_error(fd, 429, "too_many", 8);
return;
}
g_inlog_last_ms = now;
rc = webui_inputlog_append(WEBUI_INPUTLOG_PATH, (const char *)out, (size_t)lines, WEBUI_INPUTLOG_CAP,
(char *)out + INLOG_LINES_CAP, API_OUT_MAX - INLOG_LINES_CAP);
pthread_mutex_unlock(&g_inlog_mu);
if (rc != 0) send_error(fd, 500, "write_failed", 12);
else send_json(fd, 200, "{\"ok\":true}", 11);
}
/* The allowlist: nothing else is reachable. */
static const struct {
const char *path;
@@ -794,6 +848,7 @@ static const struct {
{ "/api/power", r_power },
{ "/api/settings", r_settings },
{ "/api/settings/set", r_settings_set },
{ "/api/input-log", r_input_log },
};
/* The page's own headers: only same-origin fetches, inline script/style (the page is one file),
+11
View File
@@ -213,6 +213,17 @@ int webui_json_bool(const char *json, size_t n, const char *key, int *out) {
return -1;
}
int webui_json_array(const char *json, size_t n, const char *key, const char **val, size_t *len) {
const char *e = json + n, *p = top_value(json, n, key), *end;
if (!p) return 0;
if (*p != '[') return -1;
end = skip_val(p, e);
if (!end) return -1;
*val = p;
*len = (size_t)(end - p);
return 1;
}
/* A signed integer value (focus ids are -1 when unknown): 1, 0 absent, -1 not an integer. */
static int json_i64(const char *json, size_t n, const char *key, long long *out) {
const char *e = json + n, *p = top_value(json, n, key);
+1
View File
@@ -180,6 +180,7 @@ static const char *status_text(int status) {
case 408: return "Request Timeout";
case 413: return "Payload Too Large";
case 414: return "URI Too Long";
case 429: return "Too Many Requests";
case 431: return "Request Header Fields Too Large";
case 500: return "Internal Server Error";
case 503: return "Service Unavailable";
+139
View File
@@ -0,0 +1,139 @@
/* The console page's input log: see include/webui_inputlog.h. */
#include "webui_inputlog.h"
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
static const char *skip_ws(const char *p, const char *e) {
while (p < e && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')) p++;
return p;
}
long webui_inputlog_lines(const char *arr, size_t n, char *out, size_t cap) {
const char *p = arr, *e = arr + n;
size_t at = 0, count = 0;
p = skip_ws(p, e);
if (p >= e || *p != '[') return -1;
p = skip_ws(p + 1, e);
if (p < e && *p == ']') return skip_ws(p + 1, e) == e ? 0 : -1;
for (;;) {
const char *s;
size_t len;
if (p >= e || *p != '"') return -1;
s = ++p;
while (p < e && *p != '"') {
unsigned char c = (unsigned char)*p;
if (c < 0x20 || c > 0x7e || c == '\\') return -1;
p++;
}
if (p >= e) return -1;
len = (size_t)(p - s);
p++;
if (len == 0 || len > WEBUI_INPUTLOG_LINE_MAX || ++count > WEBUI_INPUTLOG_EVENTS_MAX) return -1;
if (at > cap || len + 1 > cap - at) return -1;
memcpy(out + at, s, len);
out[at + len] = '\n';
at += len + 1;
p = skip_ws(p, e);
if (p < e && *p == ',') {
p = skip_ws(p + 1, e);
continue;
}
if (p < e && *p == ']') break;
return -1;
}
return skip_ws(p + 1, e) == e ? (long)at : -1;
}
static int write_all(int fd, const char *buf, size_t len) {
size_t done = 0;
while (done < len) {
ssize_t w = write(fd, buf + done, len - done);
if (w < 0 && errno == EINTR) continue;
if (w <= 0) return -1;
done += (size_t)w;
}
return 0;
}
/* Reads exactly len bytes at off: 0, or -1. */
static int read_at(int fd, char *buf, size_t len, off_t off) {
size_t done = 0;
while (done < len) {
ssize_t r = pread(fd, buf + done, len - done, off + (off_t)done);
if (r < 0 && errno == EINTR) continue;
if (r <= 0) return -1;
done += (size_t)r;
}
return 0;
}
/* The index just past the first '\n' in buf[0..len), or len when there is none. */
static size_t after_first_newline(const char *buf, size_t len) {
const char *nl = memchr(buf, '\n', len);
return nl ? (size_t)(nl - buf) + 1 : len;
}
int webui_inputlog_append(const char *path, const char *add, size_t add_len, size_t cap, char *work, size_t work_cap) {
struct stat st;
size_t old = 0, target, len = 0;
char tmp[1024];
int fd, k;
if (add_len == 0) return 0;
if (stat(path, &st) == 0 && S_ISREG(st.st_mode)) old = (size_t)st.st_size;
if (old + add_len <= cap) {
fd = open(path, O_WRONLY | O_CREAT | O_APPEND, 0666);
if (fd < 0) return -1;
k = write_all(fd, add, add_len);
if (close(fd) != 0) k = -1;
return k;
}
/* Rewrite: the newest whole lines of the file, then `add`, within target. */
target = cap - cap / 4;
if (work_cap < cap || target == 0) return -1;
if (add_len >= target) {
/* add alone fills it: its newest whole lines (cut after the first newline past the excess) */
size_t skip = add_len - target, start = skip ? skip + after_first_newline(add + skip, add_len - skip) : 0;
if (skip && add[skip - 1] == '\n') start = skip; /* the cut already sits on a line start */
len = add_len - start;
memcpy(work, add + start, len);
} else {
size_t keep = target - add_len, start;
/* one byte more than keep, from before the cut: it tells whether the cut is on a line start */
if (old > keep) {
fd = open(path, O_RDONLY);
if (fd < 0) return -1;
if (read_at(fd, work, keep + 1, (off_t)(old - keep - 1)) != 0) {
close(fd);
return -1;
}
close(fd);
start = after_first_newline(work, keep + 1);
len = keep + 1 - start;
memmove(work, work + start, len);
}
memcpy(work + len, add, add_len);
len += add_len;
}
k = snprintf(tmp, sizeof tmp, "%s.tmp", path); /* same directory as `path`: the rename never crosses a mount */
if (k < 0 || (size_t)k >= sizeof tmp) return -1;
fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd < 0) return -1;
if (write_all(fd, work, len) != 0 || fsync(fd) != 0) {
close(fd);
unlink(tmp);
return -1;
}
if (close(fd) != 0 || rename(tmp, path) != 0) {
unlink(tmp);
return -1;
}
return 0;
}
+12
View File
@@ -525,8 +525,20 @@ static void focus_running_fallback_games_only(void) {
!webui_is_game_title_id(""));
}
static void array_span(void) {
const char *j = "{\"n\":{\"events\":[1]},\"events\":[\"a]\",[2],\"b\"],\"x\":3}";
const char *v = NULL;
size_t len = 0;
CHECK(webui_json_array(j, strlen(j), "events", &v, &len) == 1);
CHECK(v && len == strlen("[\"a]\",[2],\"b\"]") && memcmp(v, "[\"a]\",[2],\"b\"]", len) == 0);
CHECK(webui_json_array(j, strlen(j), "x", &v, &len) == -1); /* not an array */
CHECK(webui_json_array(j, strlen(j), "nope", &v, &len) == 0);
CHECK(webui_json_array("{\"events\":[\"a\"", 13, "events", &v, &len) == -1); /* cut short */
}
int main(void) {
unicode_path_round_trips();
array_span();
surrogate_pair_decodes();
missing_key_is_absent_not_empty();
list_result_to_json();
+181
View File
@@ -0,0 +1,181 @@
/* Host-side tests for the console page's input log (webui_inputlog.c): the {"events":[...]}
* array becomes one line per event, only plain short ASCII strings are accepted, and the log
* file is a ring that never grows past its cap, dropping its oldest whole lines. File tests run
* in a fresh mkdtemp directory. Build: see the root Makefile test-payload. */
#include <dirent.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include "webui_inputlog.h"
static int failures = 0;
#define CHECK(expr) \
do { \
if (!(expr)) { \
fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #expr); \
failures++; \
} \
} while (0)
static char g_dir[256];
static long lines_of(const char *arr, char *out, size_t cap) {
return webui_inputlog_lines(arr, strlen(arr), out, cap);
}
static void lines_from_events(void) {
char out[512];
long n = lines_of("[\"1 keydown k=Enter kc=13\", \"2 click b=0 x=5 y=6\"]", out, sizeof out);
CHECK(n == (long)strlen("1 keydown k=Enter kc=13\n2 click b=0 x=5 y=6\n"));
CHECK(n > 0 && memcmp(out, "1 keydown k=Enter kc=13\n2 click b=0 x=5 y=6\n", (size_t)n) == 0);
CHECK(lines_of("[]", out, sizeof out) == 0);
CHECK(lines_of("[ ]", out, sizeof out) == 0);
}
static void lines_refuse_anything_but_plain_strings(void) {
char out[512], big[WEBUI_INPUTLOG_LINE_MAX + 8];
char many[8 * (WEBUI_INPUTLOG_EVENTS_MAX + 2)];
size_t i, at = 0;
CHECK(lines_of("[1]", out, sizeof out) == -1);
CHECK(lines_of("[\"a\",{}]", out, sizeof out) == -1);
CHECK(lines_of("[\"\"]", out, sizeof out) == -1); /* empty */
CHECK(lines_of("[\"a\\nb\"]", out, sizeof out) == -1); /* an escape (a newline would forge a line) */
CHECK(lines_of("[\"a\\\"b\"]", out, sizeof out) == -1);
CHECK(lines_of("[\"caf\xc3\xa9\"]", out, sizeof out) == -1); /* not ASCII */
CHECK(lines_of("[\"a\tb\"]", out, sizeof out) == -1); /* a control byte */
CHECK(lines_of("[\"a\" \"b\"]", out, sizeof out) == -1); /* no comma */
CHECK(lines_of("[\"a\",]", out, sizeof out) == -1);
CHECK(lines_of("[\"a\"", out, sizeof out) == -1); /* cut short */
/* one line too long */
big[0] = '[';
big[1] = '"';
memset(big + 2, 'x', WEBUI_INPUTLOG_LINE_MAX + 1);
memcpy(big + 2 + WEBUI_INPUTLOG_LINE_MAX + 1, "\"]", 3);
CHECK(lines_of(big, out, sizeof out) == -1);
/* too many events */
many[at++] = '[';
for (i = 0; i < WEBUI_INPUTLOG_EVENTS_MAX + 1; i++) {
memcpy(many + at, i ? ",\"e\"" : "\"e\"", i ? 4 : 3);
at += i ? 4 : 3;
}
many[at++] = ']';
many[at] = '\0';
{
char *o = malloc(4 * WEBUI_INPUTLOG_EVENTS_MAX + 16);
CHECK(o && webui_inputlog_lines(many, at, o, 4 * WEBUI_INPUTLOG_EVENTS_MAX + 16) == -1);
free(o);
}
/* out too small */
CHECK(lines_of("[\"abcdef\"]", out, 4) == -1);
}
static size_t file_size(const char *path) {
struct stat st;
return stat(path, &st) == 0 ? (size_t)st.st_size : (size_t)-1;
}
static size_t read_all(const char *path, char *buf, size_t cap) {
FILE *f = fopen(path, "rb");
size_t n;
if (!f) return 0;
n = fread(buf, 1, cap, f);
fclose(f);
return n;
}
static int count_entries(const char *dir) {
DIR *d = opendir(dir);
struct dirent *e;
int n = 0;
if (!d) return -1;
while ((e = readdir(d)) != NULL)
if (strcmp(e->d_name, ".") && strcmp(e->d_name, "..")) n++;
closedir(d);
return n;
}
static void ring_keeps_newest_whole_lines_under_cap(void) {
char path[300], line[64], *work, *buf;
const size_t cap = 1024, work_cap = 4096;
int i;
snprintf(path, sizeof path, "%s/webui-input.log", g_dir);
work = malloc(work_cap);
buf = malloc(work_cap);
if (!work || !buf) exit(2);
/* appends below the cap are plain appends */
CHECK(webui_inputlog_append(path, "a\n", 2, cap, work, work_cap) == 0);
CHECK(webui_inputlog_append(path, "b\n", 2, cap, work, work_cap) == 0);
CHECK(file_size(path) == 4);
CHECK(read_all(path, buf, work_cap) == 4 && memcmp(buf, "a\nb\n", 4) == 0);
/* 200 numbered lines of 32 bytes: far past the 1 KiB cap */
for (i = 0; i < 200; i++) {
int k = snprintf(line, sizeof line, "%05d keydown k=ArrowDown kc=40\n", i);
CHECK(k == 32);
CHECK(webui_inputlog_append(path, line, (size_t)k, cap, work, work_cap) == 0);
CHECK(file_size(path) <= cap);
}
{
size_t n = read_all(path, buf, work_cap);
CHECK(n > 0 && n <= cap);
CHECK(n >= cap / 2); /* the ring keeps a useful amount, not just the last batch */
CHECK(buf[n - 1] == '\n');
CHECK(n >= 32 && memcmp(buf + n - 32, "00199 keydown", 13) == 0); /* the newest last */
CHECK(n % 32 == 0 && buf[0] == '0'); /* whole lines only */
}
/* no temp file is left in the directory */
CHECK(count_entries(g_dir) == 1);
/* one batch larger than the cap keeps only its own newest whole lines */
{
char *bigadd = malloc(4096);
size_t at = 0;
if (!bigadd) exit(2);
for (i = 0; i < 64; i++) at += (size_t)snprintf(bigadd + at, 4096 - at, "%05d click b=0 x=1 y=2 d=1 e=-\n", 1000 + i);
CHECK(webui_inputlog_append(path, bigadd, at, cap, work, work_cap) == 0);
CHECK(file_size(path) <= cap);
{
size_t n = read_all(path, buf, work_cap);
CHECK(n > 0 && buf[n - 1] == '\n' && memcmp(buf + n - 32, "01063 click", 11) == 0);
CHECK(buf[0] == '0' && buf[5] == ' '); /* starts on a line */
}
free(bigadd);
}
/* a work buffer smaller than the file it must rewrite: refused, the file untouched */
{
size_t before = file_size(path);
CHECK(webui_inputlog_append(path, buf, 900, cap, work, 64) == -1);
CHECK(file_size(path) == before);
}
/* an unwritable place fails cleanly */
CHECK(webui_inputlog_append("/nonexistent-dir/x.log", "a\n", 2, cap, work, work_cap) == -1);
unlink(path);
free(work);
free(buf);
}
int main(void) {
snprintf(g_dir, sizeof g_dir, "/tmp/ps5upload-inputlog-XXXXXX");
if (!mkdtemp(g_dir)) {
perror("mkdtemp");
return 2;
}
lines_from_events();
lines_refuse_anything_but_plain_strings();
ring_keeps_newest_whole_lines_under_cap();
rmdir(g_dir);
if (failures) {
fprintf(stderr, "webui_inputlog_selftest: %d failure(s)\n", failures);
return 1;
}
printf("webui_inputlog_selftest: all passed\n");
return 0;
}