mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-19 02:20:02 -05:00
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
126 lines
5.2 KiB
TypeScript
126 lines
5.2 KiB
TypeScript
/**
|
|
* Build of the console web page (src/console/) that the helper serves on the
|
|
* PS5 itself: `npm run build:console` → dist-console/index.html, ONE file with
|
|
* the script and styles inlined (payload/Makefile gzips it and embeds it in
|
|
* ps5upload.elf; the helper serves it at GET /).
|
|
*
|
|
* - Target safari13 like the PC app (the PS5 browser is WebKit). BigInt cannot
|
|
* 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 640 KiB raw or 150 KiB gzip.
|
|
*/
|
|
import { readFileSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { gzipSync } from "node:zlib";
|
|
import { defineConfig, type Plugin } from "vite";
|
|
import react from "@vitejs/plugin-react";
|
|
|
|
const RAW_BUDGET = 640 * 1024;
|
|
const GZIP_BUDGET = 150 * 1024;
|
|
const TOKENS_ID = "virtual:pc-tokens.css";
|
|
|
|
/** The `{…}` body that starts at `open` (the index of a "{"), braces balanced. */
|
|
function blockBody(css: string, open: number): string {
|
|
let depth = 0;
|
|
for (let i = open; i < css.length; i++) {
|
|
if (css[i] === "{") depth++;
|
|
else if (css[i] === "}" && --depth === 0) return css.slice(open + 1, i);
|
|
}
|
|
throw new Error("pcTokens: unbalanced braces in src/index.css");
|
|
}
|
|
|
|
/**
|
|
* virtual:pc-tokens.css = the PC app's font stacks (its Tailwind @theme block)
|
|
* and its dark :root token block, read from src/index.css at build time.
|
|
* Importing index.css itself would pull in Tailwind and every app rule.
|
|
*/
|
|
function pcTokens(): Plugin {
|
|
const file = fileURLToPath(new URL("./src/index.css", import.meta.url));
|
|
return {
|
|
name: "ps5upload-console-pc-tokens",
|
|
resolveId(id) {
|
|
return id === TOKENS_ID ? `\0${TOKENS_ID}` : undefined;
|
|
},
|
|
load(id) {
|
|
if (id !== `\0${TOKENS_ID}`) return undefined;
|
|
this.addWatchFile(file);
|
|
const css = readFileSync(file, "utf8");
|
|
const theme = css.indexOf("@theme {");
|
|
const dark = css.indexOf(":root {");
|
|
if (theme < 0 || dark < 0) this.error("pcTokens: @theme or :root block not found in src/index.css");
|
|
const themeBody = blockBody(css, css.indexOf("{", theme));
|
|
const darkBody = blockBody(css, css.indexOf("{", dark));
|
|
return `:root {${themeBody}}\n:root {${darkBody}}\n`;
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Move every emitted script and stylesheet into index.html, then enforce the budget. */
|
|
function singleFile(): Plugin {
|
|
return {
|
|
name: "ps5upload-console-single-file",
|
|
apply: "build",
|
|
enforce: "post",
|
|
generateBundle(_, bundle) {
|
|
const html = Object.values(bundle).find((f) => f.type === "asset" && f.fileName === "index.html");
|
|
if (!html || html.type !== "asset") return this.error("singleFile: no index.html in the bundle");
|
|
let page = String(html.source);
|
|
for (const [name, file] of Object.entries(bundle)) {
|
|
if (file.type === "chunk") {
|
|
const re = new RegExp(`<script[^>]*src="[^"]*${escapeRe(name)}"[^>]*></script>`);
|
|
if (!re.test(page)) return this.error(`singleFile: ${name} is not referenced by index.html`);
|
|
// Escape "</script" so the code cannot close its own tag.
|
|
const code = file.code.replace(/<\/script/gi, "<\\/script");
|
|
page = page.replace(re, () => `<script type="module">${code}</script>`);
|
|
delete bundle[name];
|
|
} else if (file.fileName.endsWith(".css")) {
|
|
const re = new RegExp(`<link[^>]*href="[^"]*${escapeRe(name)}"[^>]*>`);
|
|
if (!re.test(page)) return this.error(`singleFile: ${name} is not referenced by index.html`);
|
|
const css = String(file.source).replace(/<\/style/gi, "<\\/style");
|
|
page = page.replace(re, () => `<style>${css}</style>`);
|
|
delete bundle[name];
|
|
}
|
|
}
|
|
const left = Object.keys(bundle).filter((n) => n !== "index.html");
|
|
if (left.length) return this.error(`singleFile: files left outside index.html: ${left.join(", ")}`);
|
|
html.source = page;
|
|
|
|
const raw = Buffer.byteLength(page);
|
|
const gz = gzipSync(page, { level: 9 }).length;
|
|
const kib = (n: number) => `${(n / 1024).toFixed(1)} KiB`;
|
|
this.info(`console page: ${kib(raw)} raw, ${kib(gz)} gzip (budget ${kib(RAW_BUDGET)} / ${kib(GZIP_BUDGET)})`);
|
|
if (raw > RAW_BUDGET || gz > GZIP_BUDGET) {
|
|
this.error(`console page over budget: ${kib(raw)} raw (max ${kib(RAW_BUDGET)}), ${kib(gz)} gzip (max ${kib(GZIP_BUDGET)})`);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
function escapeRe(s: string): string {
|
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
export default defineConfig({
|
|
root: fileURLToPath(new URL("./src/console", import.meta.url)),
|
|
base: "./",
|
|
publicDir: false,
|
|
plugins: [react(), pcTokens(), singleFile()],
|
|
clearScreen: false,
|
|
build: {
|
|
outDir: fileURLToPath(new URL("./dist-console", import.meta.url)),
|
|
emptyOutDir: true,
|
|
target: "safari13",
|
|
cssTarget: "safari13",
|
|
// One chunk, no preload helper: everything ends up inline.
|
|
modulePreload: false,
|
|
cssCodeSplit: false,
|
|
assetsInlineLimit: Number.MAX_SAFE_INTEGER,
|
|
reportCompressedSize: false,
|
|
rollupOptions: {
|
|
// @ts-expect-error checks is a rolldown passthrough not typed by Vite (see vite.config.ts)
|
|
checks: { pluginTimings: false },
|
|
},
|
|
},
|
|
});
|