mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-19 02:20:02 -05:00
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:
1 parent
20f8809415
commit
c1c74dece8
51 files changed
+2156
-116
No files matched your search
@@ -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 \
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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", {
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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] });
|
||||
|
||||
@@ -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": "允許其他裝置?",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user