From 4c39e4bec1055c423df7bd4b67cbf5ec615bfef2 Mon Sep 17 00:00:00 2001 From: 7eedle <232406712+7eedle@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:30:13 +0300 Subject: [PATCH] UI --- DESIGN.md | 57 +- README.md | 28 + build/check-bundle-size.ts | 2 +- functions/_lib/body-limit.js | 32 + functions/_lib/feedback-payload.js | 48 ++ functions/_middleware.js | 19 +- functions/api/feedback.js | 65 +- index.html | 4 +- public/fonts/DM-Sans-OFL.txt | 93 +++ public/fonts/DM-Sans-source.json | 17 + public/fonts/DMSans-Variable.ttf | Bin 0 -> 240164 bytes src/app/App.tsx | 2 + src/device/controller.ts | 85 ++- src/device/profile-write-ownership.test.ts | 191 ++++++ src/features/bridge/bridge.test.ts | 48 ++ src/features/bridge/bridge.ts | 46 +- src/features/bridge/profile-ownership.test.ts | 130 ++++ .../bridge/useBridgeProfileApplier.ts | 112 ++-- src/features/devices/DeviceHero.tsx | 109 ++-- src/features/devices/OverviewPage.tsx | 17 +- src/features/games/GameProfilePanel.tsx | 184 ++++-- src/features/games/GamesPage.tsx | 217 ++++--- src/features/games/LocalApplicationDialog.tsx | 130 ++++ .../games/local-application-dialog.test.ts | 91 +++ src/features/games/local-applications.test.ts | 131 ++++ src/features/games/local-applications.ts | 63 ++ .../games/profile-draft-ownership.test.ts | 67 ++ .../games/profile-target-switch.test.ts | 166 +++++ src/i18n/index.ts | 40 +- src/i18n/locales/ar.ts | 40 +- src/i18n/locales/de.ts | 40 +- src/i18n/locales/es.ts | 40 +- src/i18n/locales/fr.ts | 40 +- src/i18n/locales/ja.ts | 40 +- src/i18n/locales/ko.ts | 40 +- src/i18n/locales/pt.ts | 40 +- src/i18n/locales/ru.ts | 40 +- src/i18n/locales/tr.ts | 40 +- src/i18n/locales/vi.ts | 40 +- src/i18n/locales/zh.ts | 40 +- src/shared/AsciiField.tsx | 44 ++ src/shared/ascii-golden.json | 593 ++++++++++++++++++ src/shared/ascii.test.ts | 101 +++ src/shared/ascii.ts | 134 ++++ src/styles/areas/pages.css | 13 + src/styles/controls.css | 36 +- src/styles/device.css | 89 ++- src/styles/fonts.css | 7 + src/styles/index.css | 1 + src/styles/shell.css | 35 +- src/styles/tokens.css | 29 +- src/styles/visual-contract.json | 64 ++ src/styles/visual-contract.test.ts | 110 ++++ test/feedback-security.test.ts | 62 ++ test/web-security.test.ts | 35 ++ 55 files changed, 3541 insertions(+), 446 deletions(-) create mode 100644 functions/_lib/body-limit.js create mode 100644 functions/_lib/feedback-payload.js create mode 100644 public/fonts/DM-Sans-OFL.txt create mode 100644 public/fonts/DM-Sans-source.json create mode 100644 public/fonts/DMSans-Variable.ttf create mode 100644 src/device/profile-write-ownership.test.ts create mode 100644 src/features/bridge/profile-ownership.test.ts create mode 100644 src/features/games/LocalApplicationDialog.tsx create mode 100644 src/features/games/local-application-dialog.test.ts create mode 100644 src/features/games/local-applications.test.ts create mode 100644 src/features/games/local-applications.ts create mode 100644 src/features/games/profile-draft-ownership.test.ts create mode 100644 src/features/games/profile-target-switch.test.ts create mode 100644 src/shared/AsciiField.tsx create mode 100644 src/shared/ascii-golden.json create mode 100644 src/shared/ascii.test.ts create mode 100644 src/shared/ascii.ts create mode 100644 src/styles/fonts.css create mode 100644 src/styles/visual-contract.json create mode 100644 src/styles/visual-contract.test.ts create mode 100644 test/feedback-security.test.ts create mode 100644 test/web-security.test.ts diff --git a/DESIGN.md b/DESIGN.md index f52cf2e9..88b82d1f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -9,8 +9,9 @@ precise, instrument-like. Dial ENERGY 1 / RHYTHM 2 / MOTION 1. ## Principles -- **The device is the hero.** Product renders sit on a lit stage (a dark well with one soft - accent light behind the mouse). Everything else is quiet chrome. +- **The device is the hero.** On its settings page, simple name and connection information sit + above the standalone contained mouse image, directly on the page canvas with no card or stage. + Device-shelf blocks retain their own existing artwork treatment. - **Numbers first.** Settings read as figures: label above, value, unit after, meta under. All numbers are tabular. - **Colour means something.** The accent (from the user's theme) marks the active place, the @@ -25,9 +26,10 @@ precise, instrument-like. Dial ENERGY 1 / RHYTHM 2 / MOTION 1. | Layer | Recipe | |---|---| -| Ground | `--ground`: page ink plus one soft accent light from the top right. Painted once on the shell. | +| Ground | `--ground`: flat page ink, matching Desktop. The shared ASCII is the single background identity motif. | +| ASCII field | `shared/AsciiField.tsx`: the Desktop app's glow field, top right, under the sidebar and main column. Decorative (`aria-hidden`, no pointer events). | | Chrome | Sidebar on `--ink-0` with a hairline edge. | -| Panel | `.om-panel` (or the legacy `.setting-card`): `--sheen` over `--surface-1`, `--lit` edge, `--r-md`. Featured panel: `.om-panel.is-lit` (one or two per page). | +| Panel | `.om-panel` (or the legacy `.setting-card`): `--sheen` over `--surface-1` over `--ink-1` (opaque, so the ASCII field never shows through), `--lit` edge, `--r-md`. Featured panel: `.om-panel.is-lit` (one or two per page). | | Row | Inside a panel: hairline `--line-1` separators. Don't nest panels. | | Well | Inset areas (segmented controls, inputs, render stages): `--well` + `--inset-shadow`. | | Raised | Buttons, the selected segment key, switch thumb: `--btn` / `--key`. | @@ -35,13 +37,13 @@ precise, instrument-like. Dial ENERGY 1 / RHYTHM 2 / MOTION 1. ## App layout -- **Sidebar** (`AppSidebar.tsx`, 248px, collapsible to a 68px icon rail, remembered): +- **Sidebar** (`AppSidebar.tsx`, 248px, collapsible to a 68px icon rail, remembered; nav rows 36px, radius 6, 10px inline padding, 13/500): brand + build; Devices, Hardware test, Games (without Bridge it lists the supported games under a download-Bridge panel); "Your devices" switcher (thumbnail, name, battery icon + percent or "Not connected"); footer: Docs ↗, What's new, Help & feedback (Discord), Settings; Bridge status line, which opens a small card with Bridge's status, update notice and download link (`features/bridge/BridgeStatus.tsx`). -- **Main column**: news strip on top, then the scrolling page (`.om-content` > `.om-page`, max - 1160px). The pending-changes bar floats at the bottom of the main column. A help button floats +- **Main column**: news strip on top, then the scrolling page (`.om-content` > `.om-page`: 28px + top and 32px side padding around a content box of at most 1160px). The pending-changes bar floats at the bottom of the main column. A help button floats bottom right and opens a small panel of common questions with instant answers, plus links to the site FAQ and Discord (`app/HelpPanel.tsx`). - **Devices home** (`features/devices/DevicesHome.tsx`): a swipeable shelf of device blocks, each @@ -51,9 +53,12 @@ precise, instrument-like. Dial ENERGY 1 / RHYTHM 2 / MOTION 1. middle of the page and centres when it doesn't fill the width. No devices: the same shelf with only the Add device block, larger, in the middle of the page; its button becomes the primary action. -- **Device page** (`features/devices/DeviceHero.tsx`): hero (render stage, breadcrumb, name, connection line, stats - with icons) with the section tabs bolted under it as one unit. Overview: Current setup figures + - DPI stage chips, Battery over time chart (recorded samples), Firmware. +- **Device page** (`features/devices/DeviceHero.tsx`): the device rail on the left (breadcrumb, name, + "Connected" + connection type, then the contained render alone on the page, no stage), the + workspace beside it (tabs in a 40px well, then the panels). The rail is 280px when the page's + content box is at least 1000px wide, 220px from 760px, and stacks above the tabs below that + (container query on `.om-device-page`); 16px gap. Overview: Current setup figures + DPI stage + chips, Battery over time chart (recorded samples), Firmware. - **Setting cards** flow in a masonry column layout (`CardSlot`, `styles/instrument.css`) so there are no holes; every card heading carries a glyph, and feature switches are tiles (`SwitchRow` in `shared/ui.tsx`). @@ -86,14 +91,23 @@ No component may fork on a theme name. ## Type -- DM Sans for everything; JetBrains Mono only for raw identifiers, hex and HID logs. -- Scale: page title 22, panel title 15, body 13, meta 12, small 11. Figures 28–30 at 650. -- Sentence case everywhere. No uppercase tracked labels. Weights 400–650. +- DM Sans for everything, self-hosted from `public/fonts/DMSans-Variable.ttf` (SIL OFL 1.1, licence + and source record beside it), pinned to optical size 14 (`--font-opsz`) so it renders the same + named instance as the Desktop app. JetBrains Mono only for raw identifiers, hex and HID logs. + The ASCII field uses Cascadia Mono 11/500 at a 15.4px line. +- Scale: page title 22, panel title 15, body 13, meta 12, small 11. Figures 30 at 600. +- Sentence case everywhere. No uppercase tracked labels. Weights 400–600 (the Desktop renderer + only has the 100-step named instances), no negative tracking on titles or figures. ## Shape and space - Radii: 3 (chips), 6 (controls, rows), 10 (panels, wells), 14 (hero, device blocks, dialogs). -- Spacing on a 4px grid. Panel padding 16–18px; gaps between panels 16px; rows ≥ 40px. +- Spacing on a 4px grid. Panel padding 16px; gaps between panels 16px; rows ≥ 40px; buttons 32px; + switches 36×20 with a 16px thumb. Tabs: a 40px well (4px padding and gap, radius 10) holding + 32px keys (radius 6). +- These numbers are shared with the Desktop app: `src/styles/visual-contract.json` is checked + against `tokens.css` by `visual-contract.test.ts`, and the same file is checked against the + Desktop constants. - Device renders: always `object-fit: contain`, absolutely positioned inside their stage, never cropped. @@ -113,9 +127,18 @@ is the custom `BatteryIcon` (fill tracks the level; low and charging change its ## Motion -120–260ms washes and fades. The device page body fades in; panels and bars rise 8px once. No -loops except the add-device crossfade, which stops with reduced motion. The Animations preference -and `prefers-reduced-motion` both stop motion. +120–260ms washes and fades. The device page body fades in; panels and bars rise 8px once. The +ASCII field sweeps in once (520ms, rows staggered top-down) when the page loads and when the theme +changes. No loops except the add-device crossfade, which stops with reduced motion. The Animations +preference and `prefers-reduced-motion` both stop motion. + +## ASCII field + +The same field as the Desktop app, ported from its Rust source (`shared/ascii.ts`, checked line +for line against a reference generated by the Rust code, `shared/ascii-golden.json`): ramp +` .:-=+*#%@`, an accent glow from the top-right corner shaped by value noise, up to 42% of the +window width (24 to 104 columns), 40 rows, alpha 0.24 fading out below the middle. The seed is +chosen once per page session; rows are rebuilt only when the column count changes. ## Checklist before shipping a page diff --git a/README.md b/README.md index 767ada76..c4e801f4 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,12 @@ Run the full local check before pushing changes: npm run check ``` +### Bundle budget + +Production assets are limited to 3,240,000 JavaScript bytes and 165,000 CSS +bytes. The JavaScript budget includes the 40,000-byte allowance for local +game/app management. Run `npm run size` after a fresh build for current totals. + ### Linux WebHID permissions If a device appears in Chromium's picker but OpenMouse reports `Failed to open @@ -68,6 +74,28 @@ Grant access to every `hidraw` node for each product. Chromium opens the HID device before OpenMouse selects its vendor configuration collection, so access to only the `0xff02:0x0002` collection's node is insufficient. +## Local game and app profiles + +Games & apps separates OpenMouse presets from Your apps. With the updated +OpenMouse Bridge running, Add game or app lets users choose a running app or +enter an executable name/full path. Personal entries are saved in Bridge's +local config, not sent to OpenMouse for approval. They survive catalog refreshes +and Bridge restarts. Adding/editing/removing an entry does not require a mouse. + +Choose Game to keep its profile while the process runs, including after +alt-tabbing. Choose App for foreground-only switching. A full executable path +matches that installation only; a filename matches any executable with that +name. Foreground matching on Linux requires X11. Local profiles retain the +existing native/full-settings limits: Bridge can apply supported DPI/polling +settings with the browser closed; the control panel must remain open to apply +and restore full device-settings snapshots. + +Local targets use stable IDs, not display names, so a target can be renamed or +share a name with an OpenMouse preset without losing or confusing its settings. +Removing a local target also removes its linked mouse profiles; clearing its +mouse settings leaves the target available. Older Bridges show update guidance +rather than falling back to a public game request. + ## Community Questions, device requests, bug reports, or just want to follow along? Come diff --git a/build/check-bundle-size.ts b/build/check-bundle-size.ts index 24828223..d0d4fda7 100644 --- a/build/check-bundle-size.ts +++ b/build/check-bundle-size.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; // measured 3_171_143 bytes with it. const BUDGET_BYTES: Record = { ".css": 165_000, - ".js": 3_200_000, + ".js": 3_240_000, }; const ASSETS = join("dist", "assets"); diff --git a/functions/_lib/body-limit.js b/functions/_lib/body-limit.js new file mode 100644 index 00000000..79019e14 --- /dev/null +++ b/functions/_lib/body-limit.js @@ -0,0 +1,32 @@ +export class BodyTooLarge extends Error {} + +export async function boundedRequest(request, limit) { + if (Number(request.headers.get("Content-Length")) > limit) throw new BodyTooLarge(); + if (!request.body) return request; + const reader = request.body.getReader(); + const chunks = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > limit) { + await reader.cancel().catch(() => {}); + throw new BodyTooLarge(); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + const headers = new Headers(request.headers); + headers.delete("Content-Length"); + return new Request(request, { headers, body: bytes }); +} diff --git a/functions/_lib/feedback-payload.js b/functions/_lib/feedback-payload.js new file mode 100644 index 00000000..1c3779a2 --- /dev/null +++ b/functions/_lib/feedback-payload.js @@ -0,0 +1,48 @@ +function text(value, max) { + if (typeof value !== "string" || value.length > max) throw new Error("Invalid text"); + return value; +} + +export function feedbackPayload(input) { + if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("Invalid payload"); + const payload = { allowed_mentions: { parse: [] } }; + if (input.content !== undefined) payload.content = text(input.content, 2000); + if (input.embeds !== undefined) { + if (!Array.isArray(input.embeds) || input.embeds.length > 10) throw new Error("Invalid embeds"); + let total = 0; + payload.embeds = input.embeds.map((embed) => { + if (!embed || typeof embed !== "object" || Array.isArray(embed)) throw new Error("Invalid embed"); + const result = {}; + for (const [key, limit] of [["title", 256], ["description", 4096]]) { + if (embed[key] !== undefined) { result[key] = text(embed[key], limit); total += result[key].length; } + } + if (embed.color !== undefined) { + if (!Number.isInteger(embed.color) || embed.color < 0 || embed.color > 0xffffff) throw new Error("Invalid color"); + result.color = embed.color; + } + if (embed.fields !== undefined) { + if (!Array.isArray(embed.fields) || embed.fields.length > 25) throw new Error("Invalid fields"); + result.fields = embed.fields.map((field) => { + const name = text(field?.name, 256); + const value = text(field?.value, 1024); + total += name.length + value.length; + return { name, value, ...(typeof field.inline === "boolean" ? { inline: field.inline } : {}) }; + }); + } + if (embed.footer !== undefined) { + const value = text(embed.footer?.text, 2048); + total += value.length; + result.footer = { text: value }; + } + if (embed.timestamp !== undefined) { + const value = text(embed.timestamp, 64); + if (!Number.isFinite(Date.parse(value))) throw new Error("Invalid timestamp"); + result.timestamp = value; + } + return result; + }); + if (total > 6000) throw new Error("Embeds too long"); + } + if (!payload.content && !payload.embeds?.length) throw new Error("Empty feedback"); + return payload; +} diff --git a/functions/_middleware.js b/functions/_middleware.js index 4fe9a348..1972c88d 100644 --- a/functions/_middleware.js +++ b/functions/_middleware.js @@ -1,3 +1,5 @@ +import { boundedRequest, BodyTooLarge } from "./_lib/body-limit.js"; + // Site-wide security guard. Runs before every Cloudflare Pages route — static // pages, admin, and all /api/* endpoints. Tracks per-IP behavior, flags // abuse/exploit attempts, and permanently bans repeat offenders. @@ -128,6 +130,9 @@ const ASSET_DESTS = new Set([ const ASSET_EXT_RE = /\.(?:js|mjs|css|map|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf|eot|mp4|webm|mp3|ogg|wav|wasm|webmanifest)$/i; function isStaticSubresource(request, url) { + let pathname; + try { pathname = decodeURIComponent(url.pathname); } catch { return false; } + if (/^\/+api(?:\/|$)/i.test(pathname) || !ASSET_EXT_RE.test(pathname)) return false; const method = request.method.toUpperCase(); if (method !== "GET" && method !== "HEAD") return false; const dest = request.headers.get("Sec-Fetch-Dest"); @@ -176,15 +181,23 @@ export async function onRequest(context) { } async function handle({ request, env, next }) { + const pathname = new URL(request.url).pathname; + const limit = pathname === "/api/comments" ? 16 * 1024 : MAX_BODY_BYTES; + try { + request = await boundedRequest(request, limit); + } catch (error) { + return error instanceof BodyTooLarge ? tooLarge() : new Response("Invalid body", { status: 400 }); + } + const forward = () => next(request); const kv = env.SECURITY_KV; - if (!kv) return next(); + if (!kv) return forward(); const ip = clientIp(request); const url = new URL(request.url); // Subresources are the bulk of traffic and the bulk of the KV bill; skip them // before any storage work. - if (isStaticSubresource(request, url)) return next(); + if (isStaticSubresource(request, url)) return forward(); // An admin who is themselves caught by a ban (shared/NAT IP) still has to // reach the unban endpoint, so that one path is exempt — and only when the @@ -224,5 +237,5 @@ async function handle({ request, env, next }) { return tooMany(); } - return next(); + return forward(); } \ No newline at end of file diff --git a/functions/api/feedback.js b/functions/api/feedback.js index c22d5df8..0365f29e 100644 --- a/functions/api/feedback.js +++ b/functions/api/feedback.js @@ -1,14 +1,5 @@ -// Feedback relay to Discord. The webhook URL lives only in a Cloudflare -// Pages secret (env.DISCORD_FEEDBACK_WEBHOOK, set via the Pages dashboard → -// Settings → Environment variables → encrypt) — it never ships to the -// browser and never lives in this repository. The client's payload (the -// JSON embed, or the multipart form with the diagnostics attachment) is -// streamed straight to Discord. -// -// Cross-origin blocking and per-IP/global rate limiting are already handled -// site-wide by functions/_middleware.js (SECURITY_KV) before this ever runs, -// so this function only needs to relay the payload and fail closed if the -// webhook isn't configured. +import { boundedRequest, BodyTooLarge } from "../_lib/body-limit.js"; +import { feedbackPayload } from "../_lib/feedback-payload.js"; const json = (body, status = 200) => new Response(JSON.stringify(body), { status, @@ -16,24 +7,44 @@ const json = (body, status = 200) => new Response(JSON.stringify(body), { }); export async function onRequest({ request, env }) { - if (request.method !== "POST") { - return json({ message: "Method not allowed." }, 405); - } - - const webhook = env.DISCORD_FEEDBACK_WEBHOOK; - if (!webhook || !webhook.startsWith("https://discord")) { - return json({ message: "Feedback is not configured." }, 503); - } - + if (request.method !== "POST") return json({ message: "Method not allowed." }, 405); + const origin = request.headers.get("Origin"); + if (origin && origin !== new URL(request.url).origin) return json({ message: "Origin not allowed." }, 403); + let webhook; + try { webhook = new URL(env.DISCORD_FEEDBACK_WEBHOOK); } catch { return json({ message: "Feedback is not configured." }, 503); } + if (webhook.protocol !== "https:" || !["discord.com", "discordapp.com"].includes(webhook.hostname) || !webhook.pathname.startsWith("/api/webhooks/")) return json({ message: "Feedback is not configured." }, 503); + let body; + let headers; try { - const response = await fetch(webhook, { - method: "POST", - headers: { "Content-Type": request.headers.get("Content-Type") ?? "application/json" }, - body: request.body, - }); - if (!response.ok) { - return json({ message: "Discord rejected the feedback." }, response.status); + const type = request.headers.get("Content-Type") ?? ""; + const multipart = /^multipart\/form-data(?:;|$)/i.test(type); + if (!multipart && !/^application\/json(?:;|$)/i.test(type)) return json({ message: "Unsupported content type." }, 415); + request = await boundedRequest(request, multipart ? 8 * 1024 * 1024 : 64 * 1024); + if (multipart) { + const input = await request.formData(); + const raw = input.getAll("payload_json"); + if (raw.length !== 1 || typeof raw[0] !== "string" || new TextEncoder().encode(raw[0]).length > 64 * 1024) throw new Error("Invalid payload_json"); + const payload = feedbackPayload(JSON.parse(raw[0])); + const form = new FormData(); + const names = new Set(); + for (const [key, file] of input) { + if (key === "payload_json") continue; + if (!/^files\[[0-3]\]$/.test(key) || names.has(key) || typeof file === "string" || file.size > 2 * 1024 * 1024) throw new Error("Invalid attachment"); + names.add(key); + form.append(key, file, file.name); + } + form.set("payload_json", JSON.stringify(payload)); + body = form; + } else { + body = JSON.stringify(feedbackPayload(await request.json())); + headers = { "Content-Type": "application/json" }; } + } catch (error) { + return json({ message: error instanceof BodyTooLarge ? "Feedback too large." : "Invalid feedback." }, error instanceof BodyTooLarge ? 413 : 400); + } + try { + const response = await fetch(webhook.href, { method: "POST", headers, body }); + if (!response.ok) return json({ message: "Discord rejected the feedback." }, 502); return json({ ok: true }); } catch { return json({ message: "Discord unreachable." }, 502); diff --git a/index.html b/index.html index 9445cabb..143cd44f 100644 --- a/index.html +++ b/index.html @@ -7,8 +7,8 @@ - - + + diff --git a/public/fonts/DM-Sans-OFL.txt b/public/fonts/DM-Sans-OFL.txt new file mode 100644 index 00000000..441171d6 --- /dev/null +++ b/public/fonts/DM-Sans-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/DM-Sans-source.json b/public/fonts/DM-Sans-source.json new file mode 100644 index 00000000..7717e47c --- /dev/null +++ b/public/fonts/DM-Sans-source.json @@ -0,0 +1,17 @@ +{ + "revision": "5b35b7208dd4100571326fdf37f030b32a524232", + "files": [ + { + "file": "DMSans-Variable.ttf", + "source": "https://raw.githubusercontent.com/google/fonts/5b35b7208dd4100571326fdf37f030b32a524232/ofl/dmsans/DMSans%5Bopsz%2Cwght%5D.ttf", + "sha256": "8cd08d97e89c24d0aa92edd2f0f4c8ee6195eee9b7c9f154865a58b02f0c1c0d", + "bytes": 240164 + }, + { + "file": "DM-Sans-OFL.txt", + "source": "https://raw.githubusercontent.com/google/fonts/5b35b7208dd4100571326fdf37f030b32a524232/ofl/dmsans/OFL.txt", + "sha256": "9af36190332437f5ecd09974de43c1f7c77a310a996cdd8ceb25628b458840e1", + "bytes": 4482 + } + ] +} diff --git a/public/fonts/DMSans-Variable.ttf b/public/fonts/DMSans-Variable.ttf new file mode 100644 index 0000000000000000000000000000000000000000..c672f98060af9b2c85fca704ebd5ad9a8717c1df GIT binary patch literal 240164 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 z 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%0DOsst9s5938VCl(I9M4!axwi7) z?&7`0#ZUkHYZEp!){(?69Re57k-f(He>gjFZv5Q2F{Ye4|K`QZsEbQK``33(BY^%# z$0_*fPhdiSb8^yujVPP3OZWcota|F!1C)4s)uhCc{Vy?K06A0eOKs~U7HZ9CUT#TP z1o)U>OTt5(XN8N58OSlHgJDfC1VPJ>S2yofU;&;+c+Ra2R!i+H0-1r4 z?3q|h1X}DdmAA38Ix;?PNQ!SD_BNyuu4Cjj;97{!{>#oH2fjJsUBh(RKET%z){ zkVsxSS6q#?KIf%d5Oj&gU>K}nqX4dbSSMCxO*xWPce7uUAk>@n4XAkg+Q`Uoq%8Ff z&VrWA4d#tT1^z-KKK-auqrKF7wgzayM}nWkcu5$Z>0s7 zyl^u?`0oVQ8YZ~bFac@2@wSF)1S&M5qXv1X(Z#Q9{BeepVTO}|;(_wE<_^#f z&ik$KKE&$|_FEz&ELO}A+6xTxKt>P#IBd>ap7&J31;@;X? zS?tjKT`)TAjUrG)v{$2*{P(Wg+FE~tB6;a8tsg0`H~4q0pS1pjh!`PaOY2XFL9#*r zezkrY2m81d+Q*F{A+%79>s=>l_MBp`K7Opy+w7v(x72z|>wA?2VK@D=sR#pqsaD9J z$<~Br&&JM%$AhG$3d`w{;gR${DVY+JFj^j8SkHCOwKafO>A;+C1pJn4UDpBn*p{v< zyL+~Gw*k{PbzM2LegAmHf^ur*b)3uV6xS-JUTBrC z&^=w4#^85KN5j!g`|Jmd58G`VSi@|Qg*}dnYIf5<`-yDCI?&!mN5{s-&W?>~MYE-V z+B4SQplO06OQ=euUGDXgb~(6ptX=+HOZoqzrO>V$ZwJM%UU~HM0(x3>uB}@J;%%*& zLqeH0*VdXj+b2zBZdjVa8bpmnt)D9kFif?^5G*8NwsM#!xb#pXm4{RxPzb>T7D^r& zsM5=!{PpS6zkcyW(U6PV2ov{3&yYv1${sq?FQBtLLdxNy>^-Bq;^WlGk`NC;m!lZe zh;WY|l?HuM=&&@kKTyfn0|)CsRqd5=GO0e_vwjvAtd*5NuAsNutEp}~UbMUJ=Vqz! zT)RXSt`vDMG7n)o5uCtZ5n(e1_7CgxEyNYf*qy4FkS@$?g)|ClWg8Z%;3W4bM=&Wr zQaspu1s2;vU}4MNsgog&CyE0n6bSey!DCGX6jXf(G)OLMhE8jjY-@bTz75OTk z>?2RAqNMiF9EiWTyAULMh|$zEWpj^FUC*e<3A~!OneXK zxGC@?RS3QT2X~pkQP7FCv_gDB04dut~4vRG_{>m1NW$ky*-XZ~z|C115t$ zDIvF%*``K`VZzc8fdL`zp%noHr7jsVVznV&o5|j9U6B|smpZTWjdOCm4mtkzQtzcw zhciPx}O;JZ?G5$x5s;5pLG~ZoS6sarE_pz19gE_$E>-I@?ff1%Z!S>M)k_ zoeHtmL~BvEp^=eDzYqGQuRj7_Pj7z&I|q?|uN!z>3$PUvY7?;?(OOC|NYa@bm?};7 zRFWu1DS-uzKx9>L`m{To+v;$z&7QYfu!kFS&vPw!hAWL3l*VbUGzPl#H*R+ilN%j{|iv(anP^B&11hAH|4vffWratWB{0bYLIuM1qq#o;Oh1i4vKYExVRV9`XmX;iQz4$#l>wxBufsK4< z4F>;NRJ2Gz>tI`zh3_cPW-H1fSHqL>_G)%A-kv2US*K3aF`{xa<_D-w^S4Id3HT-~ z>$)MFeJ3EC@GI>ty1ZKGsnB_`f z7A0_s(+XW&8+UOfV8f5JcKgtRNPAy~NMR!JEg)&{%OELRGQI^S?R^<0g}TulDE$Cq X5WH| + } | null = null; +let gameProfileDraft: { owner: GameProfileOwner; stash: PendingChange[]; touched: Set } | null = null; let lastRenderedStatusKey: string | null = null; let activeDevice: HIDDevice | null = null; let latestReceiverFirmware: { interfaceId: string; mcus: Array<{ mcu: number; version: string | null; raw: number[] }> } | null = null; @@ -480,6 +480,25 @@ function batch(run: () => void): void { } } +export interface GameProfileOwner { + readonly client: object; + readonly device: HIDDevice | null; +} + +let gameProfileOwner: GameProfileOwner | null = null; + +export function getGameProfileOwner(): GameProfileOwner | null { + if (!active || !latestDeviceStatus) return null; + if (!gameProfileOwner || gameProfileOwner.client !== active || gameProfileOwner.device !== activeDevice) { + gameProfileOwner = { client: active, device: activeDevice }; + } + return gameProfileOwner; +} + +export function isGameProfileOwner(owner: GameProfileOwner | null): boolean { + return owner !== null && getGameProfileOwner() === owner; +} + export function getActiveDevice(): HIDDevice | null { return activeDevice; } @@ -1490,8 +1509,9 @@ function gameProfileFieldsOf(change: PendingChange): string[] | null { */ export function beginGameProfileDraft(initial: GameProfileSnapshot): boolean { if (gameProfileDraft) endGameProfileDraft(); - if (settingInProgress || !latestDeviceStatus) return false; - gameProfileDraft = { stash: stashPendingChanges(), touched: new Set() }; + const owner = getGameProfileOwner(); + if (settingInProgress || !latestDeviceStatus || !owner) return false; + gameProfileDraft = { owner, stash: stashPendingChanges(), touched: new Set() }; openGameProfileDraft(initial); emit(); return true; @@ -1524,8 +1544,8 @@ export function resetGameProfileDraft(snapshot: GameProfileSnapshot): void { emit(); } -export function endGameProfileDraft(): void { - if (!gameProfileDraft) return; +export function endGameProfileDraft(owner: GameProfileOwner | null = getGameProfileOwner()): void { + if (!gameProfileDraft || gameProfileDraft.owner !== owner || !isGameProfileOwner(owner)) return; const { stash } = gameProfileDraft; gameProfileDraft = null; restorePendingChanges(stash); @@ -1545,14 +1565,20 @@ export function isGameProfileDraftActive(): boolean { */ export async function flashGameProfileSnapshot( snapshot: GameProfileSnapshot, -): Promise<"written" | "unchanged" | "busy"> { + owner: GameProfileOwner | null = getGameProfileOwner(), +): Promise<"written" | "unchanged" | "busy" | "stale" | "failed" | "partial"> { + if (!isGameProfileOwner(owner)) return "stale"; if (gameProfileDraft || settingInProgress || !latestDeviceStatus) return "busy"; const stash = stashPendingChanges(); - applyGameProfileSnapshot(snapshot); - const staged = hasPendingChanges(); - if (staged) await flashPendingChanges(); - restorePendingChanges(stash); - return staged ? "written" : "unchanged"; + try { + applyGameProfileSnapshot(snapshot); + if (!isGameProfileOwner(owner)) return "stale"; + const result = hasPendingChanges() ? await flashPendingChanges() : "unchanged"; + if (!isGameProfileOwner(owner)) return "stale"; + return result; + } finally { + if (isGameProfileOwner(owner)) restorePendingChanges(stash); + } } /** Writes that bypass staging must not reach the mouse while a game profile is being edited. */ @@ -2183,14 +2209,16 @@ async function flashPause(milliseconds = FLASH_STEP_DELAY_MS): Promise { await wait(milliseconds); } -export async function flashPendingChanges(): Promise { - if (gameProfileDraft) return; +export async function flashPendingChanges(): Promise<"written" | "unchanged" | "busy" | "stale" | "failed" | "partial"> { + if (gameProfileDraft) return "busy"; + const owner = getGameProfileOwner(); + if (!owner) return "stale"; const batches = pendingChangeBatches(); - if (batches.length === 0) return; + if (batches.length === 0) return "unchanged"; if (settingInProgress) { flashQueued = true; setReadStatus(st("ctl.waitFlash")); - return; + return "busy"; } settingInProgress = true; pendingBusy = true; @@ -2222,8 +2250,11 @@ export async function flashPendingChanges(): Promise { readStatus = writer.progress; emit(); await flashPause(); + if (!isGameProfileOwner(owner)) break; for (const change of batch) recordDiagnosticCommand(change.command); + if (!isGameProfileOwner(owner)) break; await writer.apply(); + if (!isGameProfileOwner(owner)) break; for (const change of batch) dropPendingChange(change.key); written += batch.length; } @@ -2232,20 +2263,28 @@ export async function flashPendingChanges(): Promise { failure = error instanceof Error ? error.message : "Unable to flash the staged changes."; } await flashPause(FLASH_SETTLE_MS); - const client = activeSettingsClient(); + const client = isGameProfileOwner(owner) ? activeSettingsClient() : null; const status = client ? await statusAfterWrite(client).catch(() => null) : null; - if (status) applyStatus(status); + if (status && isGameProfileOwner(owner)) applyStatus(status); endDeviceWrite(); pendingBusy = false; - if (flashQueued) { + if (!isGameProfileOwner(owner)) { flashQueued = false; - if (hasPendingChanges()) return flashPendingChanges(); + return "stale"; } if (failure) { + flashQueued = false; pendingStatusText = failure; setReadStatus(failure); pushToast("error", st("ctl.flashFailed"), failure); - return; + return written > 0 ? "partial" : "failed"; + } + if (flashQueued) { + flashQueued = false; + if (hasPendingChanges()) { + const result = await flashPendingChanges(); + return written > 0 && result === "failed" ? "partial" : result; + } } pendingStatusText = null; const flashed = written === 1 @@ -2254,6 +2293,7 @@ export async function flashPendingChanges(): Promise { setReadStatus(flashed); pushToast("success", flashed); track("pending_changes_applied", { change_count: written }); + return "written"; } function endDeviceWrite(): void { @@ -3159,6 +3199,11 @@ function statusNameForClient(client: SupportedClient): string { } function clearActiveClients(): void { + if (gameProfileDraft) { + gameProfileDraft = null; + clearPendingChanges(); + } + gameProfileOwner = null; active = null; } diff --git a/src/device/profile-write-ownership.test.ts b/src/device/profile-write-ownership.test.ts new file mode 100644 index 00000000..eca010b9 --- /dev/null +++ b/src/device/profile-write-ownership.test.ts @@ -0,0 +1,191 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFileSync } from "node:fs"; +import vm from "node:vm"; +import ts from "typescript"; +import * as pending from "./pending-changes.ts"; +import * as snapshots from "./game-profile-snapshot.ts"; + +function controllerHarness() { + pending.clearPendingChanges(); + const timers: (() => void)[] = []; + const exports: any = {}; + const mocks = new Map(); + const source = readFileSync(new URL("./controller.ts", import.meta.url), "utf8").replaceAll("import.meta.env", "({ DEV: false })") + ` +export function testSelect(client) { clearActiveClients(); clearPendingChanges(); active = client; activeDevice = client.device; latestDeviceStatus = { name: "Same model", dpi: 800, pollingRateHz: 1000, supportedPollingRates: [500,1000] }; dpiOptions = [800,1200,1600,3200]; } +export function testDisconnect() { clearActiveClients(); clearPendingChanges(); activeDevice = null; latestDeviceStatus = null; } +export function testReading(value) { refreshInProgress = value; } +`; + const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText; + const localStorage = { getItem: () => null }; + const context = { exports, structuredClone, __BUILD_CHANNEL__: "stable", __BUILD_ID__: "test", __APP_VERSION__: "test", localStorage, window: { localStorage, setTimeout: (callback: () => void) => { timers.push(callback); return 1; }, clearTimeout() {} }, setTimeout, clearTimeout, console, require: (name: string) => { + if (name.endsWith("pending-changes")) return pending; + if (name.endsWith("game-profile-snapshot")) return snapshots; + if (name.endsWith("interface-preferences")) return { loadInterfacePreferences: () => ({ locale: "en", reducedMotion: false }) }; + if (name.endsWith("i18n/index")) return { t: (_locale: string, key: string) => key, tp: (_locale: string, key: string) => key }; + if (!mocks.has(name)) mocks.set(name, new Proxy({}, { get: (target: any, key) => target[key] ??= function () {} })); + return mocks.get(name); + } }; + vm.runInNewContext(code, context); + const a = { device: {}, readStatus: async () => { throw new Error("fake read denied"); } }; + const b = { device: {}, readStatus: async () => { throw new Error("fake read denied"); } }; + exports.testSelect(a); + return { control: exports, a, b, timers, async drain() { for (let i = 0; i < 30; i++) { timers.splice(0).forEach((callback) => callback()); await Promise.resolve(); } } }; +} + +test("an abandoned profile write never restores A's staged edits into B", async () => { + const h = controllerHarness(); + pending.stagePendingChange({ key: "user-A", label: "A", command: "A", progress: "A", apply: async () => {} }); + const running = h.control.flashGameProfileSnapshot({ pollingRateHz: 500 }, h.control.getGameProfileOwner()); + h.control.testSelect(h.b); + pending.stagePendingChange({ key: "user-B", label: "B", command: "B", progress: "B", apply: async () => {} }); + await h.drain(); + assert.equal(await running, "stale"); + assert.deepEqual(pending.pendingChanges().map((change) => change.key), ["user-B"]); +}); + +test("controller owner tokens change across an A/B/A session switch", () => { + const h = controllerHarness(); + const first = h.control.getGameProfileOwner(); + assert.equal(h.control.getGameProfileOwner(), first); + h.control.testSelect(h.b); h.control.testSelect(h.a); + assert.notEqual(h.control.getGameProfileOwner(), first); + assert.equal(h.control.isGameProfileOwner(first), false); +}); + +test("controller checks identity again after waiting on a refresh", async () => { + const h = controllerHarness(); + let writes = 0; + pending.stagePendingChange({ key: "test", label: "test", command: "test", progress: "test", apply: async () => { writes++; } }); + h.control.testReading(true); + const running = h.control.flashPendingChanges(); + await Promise.resolve(); + h.control.testSelect(h.b); h.control.testReading(false); + await h.drain(); await running; + assert.equal(writes, 0); +}); + +test("controller refuses an explicit stale owner before touching pending changes", async () => { + const h = controllerHarness(); + const owner = h.control.getGameProfileOwner?.(); + h.control.testSelect(h.b); + assert.equal(await h.control.flashGameProfileSnapshot({ dpi: 1600 }, owner), "stale"); +}); + +test("controller rechecks identity after flash delay before dispatching a writer", async () => { + const h = controllerHarness(); + let writes = 0; + pending.stagePendingChange({ key: "test", label: "test", command: "test", progress: "test", apply: async () => { writes++; } }); + const running = h.control.flashPendingChanges(); + await Promise.resolve(); + h.control.testSelect(h.b); + await h.drain(); await running; + assert.equal(writes, 0); +}); + +test("controller drops remaining batches after a writer completion changes selection", async () => { + const h = controllerHarness(); + const writes: string[] = []; + pending.stagePendingChange({ key: "one", label: "one", command: "one", progress: "one", apply: async () => { writes.push("A"); h.control.testSelect(h.b); } }); + pending.stagePendingChange({ key: "two", label: "two", command: "two", progress: "two", apply: async () => { writes.push("B"); } }); + const running = h.control.flashPendingChanges(); + await h.drain(); await running; + assert.deepEqual(writes, ["A"]); +}); + +for (const route of ["B", "disconnect", "A/B/A"]) { + test(`manual profile draft and stash cannot cross ${route}`, async () => { + const h = controllerHarness(); + const writes: string[] = []; + Object.assign(h.a, { setDpi: async (dpi: number) => { writes.push(`A:${dpi}`); } }); + Object.assign(h.b, { setDpi: async (dpi: number) => { writes.push(`B:${dpi}`); } }); + const owner = h.control.getGameProfileOwner(); + assert.equal(h.control.applyDpiValue(1600), true); + assert.equal(h.control.beginGameProfileDraft({}), true); + h.control.applyDpiValue(3200); + if (route === "disconnect") h.control.testDisconnect(); + else h.control.testSelect(h.b); + if (route === "A/B/A") h.control.testSelect(h.a); + assert.equal(h.control.isGameProfileDraftActive(), false); + h.control.endGameProfileDraft(owner); + const running = h.control.flashPendingChanges(); + await h.drain(); await running; + assert.deepEqual(writes, []); + assert.deepEqual(pending.pendingChanges(), []); + }); +} + +test("departed panel cleanup cannot end the new session's draft or replace its stash", () => { + const h = controllerHarness(); + Object.assign(h.a, { setDpi: async () => {} }); + Object.assign(h.b, { setDpi: async () => {} }); + const owner = h.control.getGameProfileOwner(); + h.control.applyDpiValue(1600); + h.control.beginGameProfileDraft({}); + h.control.testSelect(h.b); + h.control.applyDpiValue(1200); + h.control.beginGameProfileDraft({ dpi: 3200 }); + h.control.endGameProfileDraft(owner); + assert.equal(h.control.isGameProfileDraftActive(), true); + assert.equal(h.control.gameProfileDraftSnapshot().dpi, 3200); + h.control.endGameProfileDraft(); + assert.equal(pending.withPendingChanges({ dpi: 800 } as any).dpi, 1200); +}); + +test("same-session manual draft cleanup restores the original manual DPI edit", async () => { + const h = controllerHarness(); + const writes: number[] = []; + Object.assign(h.a, { setDpi: async (dpi: number) => { writes.push(dpi); } }); + h.control.applyDpiValue(1600); + h.control.beginGameProfileDraft({ dpi: 3200 }); + h.control.endGameProfileDraft(h.control.getGameProfileOwner()); + const running = h.control.flashPendingChanges(); + await h.drain(); await running; + assert.deepEqual(writes, [1600]); +}); + +test("a rejected profile setter reports failed and restores the exact manual edits", async () => { + const h = controllerHarness(); + Object.assign(h.a, { setDpi: async () => { throw new Error("refused"); } }); + h.control.applyDpiValue(1200); + const before = pending.pendingChanges(); + const running = h.control.flashGameProfileSnapshot({ dpi: 1600 }); + await h.drain(); + assert.equal(await running, "failed"); + assert.deepEqual(pending.pendingChanges(), before); +}); + +test("partial manual flash preserves failed and unsent edits and reports partial", async () => { + const h = controllerHarness(); + const writes: string[] = []; + Object.assign(h.a, { + setDpi: async () => { writes.push("dpi"); }, + setPollingRate: async () => { writes.push("rate"); throw new Error("refused"); }, + setLiftOffDistance: async () => { writes.push("lod"); }, + }); + h.control.applyDpiValue(1600); + h.control.applyPollingRate(500); + h.control.applyLiftOffDistance("High"); + const before = pending.pendingChanges(); + const running = h.control.flashPendingChanges(); + await h.drain(); + assert.equal(await running, "partial"); + assert.deepEqual(writes, ["dpi", "rate"]); + assert.deepEqual(pending.pendingChanges(), before.slice(1)); +}); + +test("a partial profile flash reports partial without leaking automatic edits into the manual stash", async () => { + const h = controllerHarness(); + const writes: string[] = []; + Object.assign(h.a, { + setDpi: async () => { writes.push("dpi"); }, + setPollingRate: async () => { writes.push("rate"); throw new Error("refused"); }, + }); + h.control.applyDpiValue(1200); + const before = pending.pendingChanges(); + const running = h.control.flashGameProfileSnapshot({ dpi: 1600, pollingRateHz: 500 }); + await h.drain(); + assert.equal(await running, "partial"); + assert.deepEqual(writes, ["dpi", "rate"]); + assert.deepEqual(pending.pendingChanges(), before); +}); diff --git a/src/features/bridge/bridge.test.ts b/src/features/bridge/bridge.test.ts index 9f414ef8..d73cd83a 100644 --- a/src/features/bridge/bridge.test.ts +++ b/src/features/bridge/bridge.test.ts @@ -1,6 +1,54 @@ import assert from "node:assert/strict"; import test from "node:test"; import { applyBridgeNativeSettings } from "./bridge.ts"; +import * as bridge from "./bridge.ts"; + +test("local application API reads and writes only the loopback batch endpoint", async () => { + assert.equal(typeof bridge.bridgeLocalApplications, "function"); + assert.equal(typeof bridge.saveBridgeLocalApplications, "function"); + const originalFetch = globalThis.fetch; + const applications = [{ id: "local:editor", name: "Editor", executable: "editor.exe", path: "C:/Tools/editor.exe", kind: "app" as const }]; + const requests: { url: string; method?: string; body?: BodyInit | null }[] = []; + globalThis.fetch = async (input, init) => { + requests.push({ url: String(input), method: init?.method, body: init?.body }); + return Response.json(init?.method === "PUT" ? { ok: true } : applications); + }; + try { + assert.deepEqual(await bridge.bridgeLocalApplications(), applications); + await bridge.saveBridgeLocalApplications(applications); + assert.deepEqual(requests, [ + { url: "http://127.0.0.1:17846/v1/local-applications", method: undefined, body: undefined }, + { url: "http://127.0.0.1:17846/v1/local-applications", method: "PUT", body: JSON.stringify({ applications }) }, + ]); + } finally { globalThis.fetch = originalFetch; } +}); + +test("local application failures expose HTTP status and never report unsupported saves as success", async () => { + assert.equal(typeof bridge.bridgeLocalApplications, "function"); + const originalFetch = globalThis.fetch; + try { + for (const status of [404, 400, 500]) { + globalThis.fetch = async () => new Response(null, { status }); + await assert.rejects(bridge.bridgeLocalApplications(), { status }); + await assert.rejects(bridge.saveBridgeLocalApplications([]), { status }); + } + globalThis.fetch = async () => { throw new TypeError("offline"); }; + await assert.rejects(bridge.saveBridgeLocalApplications([]), /offline/); + } finally { globalThis.fetch = originalFetch; } +}); + +test("local application reads forward cancellation", async () => { + assert.equal(typeof bridge.bridgeLocalApplications, "function"); + const originalFetch = globalThis.fetch; + const controller = new AbortController(); + controller.abort(); + globalThis.fetch = async (_input, init) => { + assert.equal(init?.signal?.aborted, true); + throw new DOMException("Aborted", "AbortError"); + }; + try { await assert.rejects(bridge.bridgeLocalApplications(controller.signal), { name: "AbortError" }); } + finally { globalThis.fetch = originalFetch; } +}); test("native settings are sent to the Bridge as JSON", async () => { const originalFetch = globalThis.fetch; diff --git a/src/features/bridge/bridge.ts b/src/features/bridge/bridge.ts index f6e45b4b..1026278f 100644 --- a/src/features/bridge/bridge.ts +++ b/src/features/bridge/bridge.ts @@ -23,7 +23,13 @@ export interface BridgeStatus { clientConnected: boolean; } -export interface BridgeApplication { +export interface BridgeTargetMetadata { + activation?: "running" | "focused"; + bundlePath?: string; + bundleIdentifier?: string; +} + +export interface BridgeApplication extends BridgeTargetMetadata { name: string; executable: string; path: string; @@ -36,7 +42,7 @@ export function bridgeApplicationIconUrl(application: BridgeApplication): string } export interface BridgeProfile { - application: Pick; + application: Pick & BridgeTargetMetadata & { localId?: string }; device: { id: string; name: string }; /** * False keeps the profile stored without Bridge ever matching it (the @@ -56,9 +62,41 @@ export interface BridgeProfile { }; } -export interface BridgeGame { +export interface BridgeGame extends BridgeTargetMetadata { name: string; executables: string[]; + id?: string; + source?: "preset" | "local"; + path?: string; + kind?: "game" | "app"; +} + +export interface LocalApplication extends BridgeTargetMetadata { + id: string; + name: string; + executable: string; + path: string; + kind: "game" | "app"; +} + +export class BridgeHttpError extends Error { + readonly status: number; + constructor(status: number) { + super(`Bridge returned HTTP ${status}.`); + this.status = status; + } +} + +export async function bridgeLocalApplications(signal?: AbortSignal): Promise { + return bridgeRequest("/v1/local-applications", undefined, signal); +} + +export async function saveBridgeLocalApplications(applications: LocalApplication[]): Promise { + await bridgeRequest("/v1/local-applications", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ applications }), + }); } export async function bridgeStatus(signal?: AbortSignal): Promise { @@ -158,6 +196,6 @@ async function bridgeRequest( ...init, signal: combined, }); - if (!response.ok) throw new Error(`Bridge returned HTTP ${response.status}.`); + if (!response.ok) throw new BridgeHttpError(response.status); return await response.json() as T; } diff --git a/src/features/bridge/profile-ownership.test.ts b/src/features/bridge/profile-ownership.test.ts new file mode 100644 index 00000000..3a81d84a --- /dev/null +++ b/src/features/bridge/profile-ownership.test.ts @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFileSync } from "node:fs"; +import vm from "node:vm"; +import ts from "typescript"; +import * as snapshots from "../../device/game-profile-snapshot.ts"; + +function harness() { + let owner: object | null = {}; + let listener: (value: unknown) => void = () => {}; + const refs: { current: unknown }[] = []; + let cursor = 0; + let mounted = false; + const calls: { snapshot: unknown; owner: unknown; resolve: (result: string) => void }[] = []; + const toasts: unknown[][] = []; + const control = { getGameProfileOwner: () => owner, isGameProfileOwner: (value: unknown) => value === owner, flashGameProfileSnapshot: (snapshot: unknown, target: unknown) => new Promise((resolve) => calls.push({ snapshot, owner: target, resolve })), pushToast: (...args: unknown[]) => toasts.push(args) }; + const exports = {}; + const source = readFileSync(new URL("./useBridgeProfileApplier.ts", import.meta.url), "utf8"); + const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText; + vm.runInNewContext(code, { exports, require: (name: string) => { + if (name === "react") return { useRef: (initial: unknown) => refs[cursor++] ??= { current: initial }, useEffect: (effect: () => unknown) => { if (!mounted) { effect(); mounted = true; } } }; + if (name.endsWith("controller")) return control; + if (name.endsWith("bridge-status-store")) return { subscribeBridgeStatus: (callback: typeof listener) => { listener = callback; return () => {}; } }; + if (name.endsWith("game-profile-snapshot")) return snapshots; + if (name.endsWith("i18n/index")) return { t: (_locale: string, key: string) => key }; + throw new Error(`Unexpected import ${name}`); + } }); + const render = (dpi = 800) => { cursor = 0; (exports as any).useBridgeProfileApplier({ deviceStatus: { name: "Same model", dpi }, preferences: { locale: "en" } }); }; + render(); + return { calls, toasts, render, switchDevice(renderNow = true) { owner = {}; if (renderNow) render(1200); }, owner: () => owner, notify(active = true) { listener({ activeProfile: active ? { application: { name: "Game" }, device: { name: "Same model" }, settings: { dpi: 1600 } } : null }); } }; +} +const settle = async () => { await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }; + +test("bridge events before the new render cannot capture the previous device status", async () => { + const h = harness(); + h.notify(); h.calls[0].resolve("written"); await settle(); + h.switchDevice(false); h.notify(); + assert.equal(h.calls.length, 1); + h.render(1200); h.notify(); + assert.equal(h.calls.length, 2); + h.calls[1].resolve("written"); await settle(); h.notify(false); + assert.equal((h.calls[2].snapshot as any).dpi, 1200); +}); + +test("profile originals cannot be restored onto another same-name mouse", async () => { + const h = harness(); + h.notify(); + assert.equal(h.calls[0].owner, h.owner()); + h.calls[0].resolve("written"); await settle(); + h.switchDevice(); h.notify(false); + assert.equal(h.calls.length, 1); + h.notify(); + assert.equal(h.calls.length, 2); + h.calls[1].resolve("written"); await settle(); + h.notify(false); + assert.equal((h.calls[2].snapshot as any).dpi, 1200); +}); + +test("delayed apply completion does not commit ownership or toast after selection changes", async () => { + const h = harness(); + h.notify(); h.switchDevice(); + h.calls[0].resolve("written"); await settle(); + assert.equal(h.toasts.length, 0); + h.notify(false); + assert.equal(h.calls.length, 1); + h.notify(); assert.equal(h.calls.length, 2); +}); + +test("delayed restore completion cannot clear the next device's profile state", async () => { + const h = harness(); + h.notify(); h.calls[0].resolve("written"); await settle(); + h.notify(false); assert.equal(h.calls.length, 2); + h.switchDevice(); h.notify(); + h.calls[1].resolve("written"); await settle(); + h.notify(); + assert.equal(h.calls.length, 3); + h.calls[2].resolve("written"); await settle(); + h.notify(false); + assert.equal((h.calls[3].snapshot as any).dpi, 1200); + assert.equal(h.toasts.filter((args) => args[1] === "bridge.profileRestored").length, 0); +}); + +for (const result of ["failed", "partial"]) { + test(`${result} profile writes stay retryable without a success toast`, async () => { + const h = harness(); + h.notify(); h.calls[0].resolve(result); await settle(); + assert.equal(h.toasts.length, 0); + h.notify(); + assert.equal(h.calls.length, 2); + }); +} + +test("partial apply retains the pre-profile baseline across a successful retry", async () => { + const h = harness(); + h.notify(); h.calls[0].resolve("partial"); await settle(); + h.render(1600); h.notify(); + assert.equal(h.calls.length, 2); + h.calls[1].resolve("written"); await settle(); + h.notify(false); + assert.equal((h.calls[2].snapshot as any).dpi, 800); +}); + +test("partial apply restores the original baseline even if the game stops before retry", async () => { + const h = harness(); + h.notify(); h.calls[0].resolve("partial"); await settle(); + h.render(1600); h.notify(false); + assert.equal(h.calls.length, 2); + assert.equal((h.calls[1].snapshot as any).dpi, 800); +}); + +for (const result of ["failed", "partial"]) { + test(`${result} restoration retains the original baseline and retries without a restore toast`, async () => { + const h = harness(); + h.notify(); h.calls[0].resolve("written"); await settle(); + h.notify(false); h.calls[1].resolve(result); await settle(); + assert.equal(h.toasts.filter((args) => args[1] === "bridge.profileRestored").length, 0); + h.notify(false); + assert.equal(h.calls.length, 3); + assert.equal((h.calls[2].snapshot as any).dpi, 800); + }); +} + +test("a rejected setter keeps originals when the hardware may have changed before rejecting", async () => { + const h = harness(); + h.notify(); h.calls[0].resolve("failed"); await settle(); + h.render(1600); h.notify(); + h.calls[1].resolve("written"); await settle(); + h.notify(false); + assert.equal((h.calls[2].snapshot as any).dpi, 800); +}); diff --git a/src/features/bridge/useBridgeProfileApplier.ts b/src/features/bridge/useBridgeProfileApplier.ts index 729fc499..1f5ea6a7 100644 --- a/src/features/bridge/useBridgeProfileApplier.ts +++ b/src/features/bridge/useBridgeProfileApplier.ts @@ -43,59 +43,85 @@ function signature(profile: BridgeProfile): string { } export function useBridgeProfileApplier(snapshot: ControlSnapshot): void { - const deviceStatusRef = useRef(snapshot.deviceStatus); - deviceStatusRef.current = snapshot.deviceStatus; + const deviceStatusRef = useRef({ owner: control.getGameProfileOwner(), status: snapshot.deviceStatus }); + deviceStatusRef.current = { owner: control.getGameProfileOwner(), status: snapshot.deviceStatus }; const localeRef = useRef(snapshot.preferences.locale); localeRef.current = snapshot.preferences.locale; const appliedSignature = useRef(null); // What the mouse had, before any profile, for every field a profile has // changed since. Grows as profiles switch; written back when none is active. const priorSettings = useRef(null); - const writing = useRef(false); - - useEffect(() => subscribeBridgeStatus((bridge) => { - if (writing.current) return; - const status = deviceStatusRef.current; - const profile = bridge?.activeProfile ?? null; + const writing = useRef(null); + const ownerRef = useRef(control.getGameProfileOwner()); + const currentOwner = control.getGameProfileOwner(); + if (ownerRef.current !== currentOwner) { + ownerRef.current = currentOwner; + priorSettings.current = null; + appliedSignature.current = null; + writing.current = null; + } - if (!profile || !status || profile.device.name !== status.name) { - // Nothing (recognized) is active for this mouse right now. If we were - // the one who applied a profile, put back what was there before it. - const prior = priorSettings.current; - if (appliedSignature.current === null || !prior) return; - writing.current = true; - void control.flashGameProfileSnapshot(prior).then((result) => { - if (result === "busy") return; - appliedSignature.current = null; + useEffect(() => { + let disposed = false; + const unsubscribe = subscribeBridgeStatus((bridge) => { + const owner = control.getGameProfileOwner(); + if (ownerRef.current !== owner) { + ownerRef.current = owner; priorSettings.current = null; - if (result === "written") control.pushToast("info", t(localeRef.current, "bridge.profileRestored")); - }).finally(() => { - writing.current = false; - }); - return; - } + appliedSignature.current = null; + writing.current = null; + } + if (!owner || writing.current) return; + const operation = {}; + const current = () => !disposed && ownerRef.current === owner && control.isGameProfileOwner(owner); + if (deviceStatusRef.current.owner !== owner) return; + const status = deviceStatusRef.current.status; + const profile = bridge?.activeProfile ?? null; - const sig = signature(profile); - if (sig === appliedSignature.current) return; + if (!profile || !status || profile.device.name !== status.name) { + // Nothing (recognized) is active for this mouse right now. If we were + // the one who applied a profile, put back what was there before it. + const prior = priorSettings.current; + if (!prior) return; + writing.current = operation; + void control.flashGameProfileSnapshot(prior, owner).then((result) => { + if (!current() || (result !== "written" && result !== "unchanged")) return; + appliedSignature.current = null; + priorSettings.current = null; + if (result === "written") control.pushToast("info", t(localeRef.current, "bridge.profileRestored")); + }).catch(() => {}).finally(() => { + if (writing.current === operation) writing.current = null; + }); + return; + } - const target = profileTarget(profile); - // Capture only fields not already held: when one game's profile gives way - // to another's, the originals from before the first one still win. - const prior = mergeSnapshots(pickSnapshot(status, target), priorSettings.current ?? {}); - // Fields the previous profile changed but this one doesn't go back to - // their originals, so the new game starts from the user's own settings. - const toWrite = mergeSnapshots(priorSettings.current ?? {}, target); + const sig = signature(profile); + if (sig === appliedSignature.current) return; - writing.current = true; - void control.flashGameProfileSnapshot(toWrite).then((result) => { - if (result === "busy") return; - priorSettings.current = prior; - appliedSignature.current = sig; - if (result === "written") { - control.pushToast("info", t(localeRef.current, "bridge.profileAppliedHere"), profile.application.name); - } - }).finally(() => { - writing.current = false; + const target = profileTarget(profile); + // Capture only fields not already held: when one game's profile gives way + // to another's, the originals from before the first one still win. + const prior = mergeSnapshots(pickSnapshot(status, target), priorSettings.current ?? {}); + // Fields the previous profile changed but this one doesn't go back to + // their originals, so the new game starts from the user's own settings. + const toWrite = mergeSnapshots(priorSettings.current ?? {}, target); + + writing.current = operation; + void control.flashGameProfileSnapshot(toWrite, owner).then((result) => { + if (!current() || result === "busy" || result === "stale") return; + priorSettings.current = prior; + if (result !== "written" && result !== "unchanged") { + appliedSignature.current = null; + return; + } + appliedSignature.current = sig; + if (result === "written") { + control.pushToast("info", t(localeRef.current, "bridge.profileAppliedHere"), profile.application.name); + } + }).catch(() => {}).finally(() => { + if (writing.current === operation) writing.current = null; + }); }); - }), []); + return () => { disposed = true; unsubscribe(); }; + }, []); } diff --git a/src/features/devices/DeviceHero.tsx b/src/features/devices/DeviceHero.tsx index 0fecfaea..754dcdfe 100644 --- a/src/features/devices/DeviceHero.tsx +++ b/src/features/devices/DeviceHero.tsx @@ -1,12 +1,11 @@ -import { displayText, t, tp, type I18nKey } from "../../i18n/index"; +import { connectionText, displayText, t, tp, type I18nKey } from "../../i18n/index"; import { ChevronRight, ImageUp } from "lucide-react"; import { useMemo, useState, type KeyboardEvent, type ReactNode } from "react"; import * as control from "../../device/controller"; import { cachedBatterySamples, type BatterySample } from "./battery-history"; import type { ControlSnapshot, WorkspaceTab } from "../../device/types"; -import { noirBrandLabel } from "../../device/noir.ts"; import { deviceImage, isUnknownDevice } from "../../shared/device-images"; -import { DeviceStats } from "./DeviceStats"; +import { ConnectionIcon } from "./DeviceStats"; const ARTWORK_DISCUSSION_URL = "https://github.com/OpenMouse-Project/openmouse/discussions/new?category=artwork-request"; @@ -16,11 +15,13 @@ export function DeviceHero({ tabs, activeTab, tabIcon, + children, }: { snapshot: ControlSnapshot; tabs: readonly WorkspaceTab[]; activeTab: WorkspaceTab; tabIcon: (tab: WorkspaceTab) => ReactNode; + children: ReactNode; }): ReactNode { const locale = snapshot.preferences.locale; const status = snapshot.status; @@ -29,7 +30,6 @@ export function DeviceHero({ const active = control.getActiveDevice(); const activeEntry = snapshot.devices.find((device) => device.selected) ?? null; const name = status?.name ?? keyboard?.name ?? activeEntry?.name ?? ""; - const brand = status ? noirBrandLabel(status) : null; const image = snapshot.deviceArtwork ?? (keyboard ? deviceImage({ vendorId: keyboard.vendorId, productId: keyboard.productId } as HIDDevice, keyboard.name) : null); const needsArtwork = Boolean(status && active && (imageFailed || isUnknownDevice( @@ -53,10 +53,28 @@ export function DeviceHero({ }; return ( -
-
-
- {image ? ( +
+
+ +

{name}

+

+ + + {status?.connectionType ? ( + + + {connectionText(locale, status.connectionType)} + + ) : null} +

+ {image ? ( +
{name} - ) : null} -
-
- -

{name}

-

-

- {status ? : null} -
- {needsArtwork ? ( - ) : null} + {needsArtwork ? ( + + + ) : null} +
+
+ {tabs.length > 1 ? ( + + ) : null} + {children}
- {tabs.length > 1 ? ( - - ) : null} -
+ ); } diff --git a/src/features/devices/OverviewPage.tsx b/src/features/devices/OverviewPage.tsx index a2ffe46b..a98b6e2a 100644 --- a/src/features/devices/OverviewPage.tsx +++ b/src/features/devices/OverviewPage.tsx @@ -613,14 +613,15 @@ export function OverviewPage({ tabs={status === null && snapshot.keyboard === null ? [] : visibleTabs} activeTab={workspaceTab} tabIcon={(tab) => } - /> -
- {status === null && snapshot.keyboard !== null ? ( - - ) : ( - - )} -
+ > +
+ {status === null && snapshot.keyboard !== null ? ( + + ) : ( + + )} +
+ ) : ( diff --git a/src/features/games/GameProfilePanel.tsx b/src/features/games/GameProfilePanel.tsx index 2db3573e..9cc79851 100644 --- a/src/features/games/GameProfilePanel.tsx +++ b/src/features/games/GameProfilePanel.tsx @@ -27,8 +27,10 @@ import { cardAvailability } from "../devices/cards/availability"; import { TabIcon, Workspace } from "../devices/OverviewPage"; import { profileTarget } from "../bridge/useBridgeProfileApplier"; import { availableWorkspaceTabs } from "../devices/workspace-tabs"; +import { gameIdentity, matchesGame, profileApplication, replaceGameProfile, targetActivation } from "./local-applications"; const SAVE_DEBOUNCE_MS = 600; +let profileWrites: Promise = Promise.resolve(); // Overview is read-only device information, and Profiles manages the mouse's // onboard memory itself — neither is a per-game setting. @@ -64,10 +66,6 @@ function deviceId(device: SidebarDevice): string { return `${deviceBrand(device)}:${device.name}`; } -function matchesGame(profile: BridgeProfile, game: BridgeGame): boolean { - return profile.application.name.toLowerCase() === game.name.toLowerCase(); -} - function selectedIndex(devices: SidebarDevice[]): number | null { if (devices.length === 0) return null; const selected = devices.findIndex((device) => device.selected); @@ -90,15 +88,27 @@ export function GameProfilePanel({ const [targetIndex, setTargetIndex] = useState(null); const [autoApply, setAutoApply] = useState(false); const [saving, setSaving] = useState(false); + const [saveFailed, setSaveFailed] = useState(false); const [loaded, setLoaded] = useState(false); + const [loadError, setLoadError] = useState(false); const [tab, setTab] = useState("performance"); // The profile as Bridge last stored it; the draft opens on these values. const savedRef = useRef({}); const savedKey = useRef("{}"); + const targetProfiles = useRef(new Map()); + const pendingWrites = useRef(0); + const pendingTimer = useRef | null>(null); + const activeTarget = useRef(null); // The draft as of the last render. A draft that has to restart (a write was // still running when it began, or the target mouse changed) reopens on // these values, so edits not yet saved survive it. const lastDraft = useRef(null); + const draftOwner = control.getGameProfileOwner(); + const lastDraftOwner = useRef(draftOwner); + if (lastDraftOwner.current !== draftOwner) { + lastDraftOwner.current = draftOwner; + lastDraft.current = null; + } // Set when a draft opens on the saved profile. Opening can fill in fields // the saved copy leaves implicit (a DPI on a stage-aware mouse also sets // its stage table), so the first draft is adopted as "saved" instead of @@ -117,6 +127,10 @@ export function GameProfilePanel({ useEffect(() => { const controller = new AbortController(); + if (game.source === "local") { + setCatalogEntry(null); + return; + } void fetchGamesCatalog(controller.signal) .then((catalog) => { const match = catalog.find((entry) => entry.name.toLowerCase() === game.name.toLowerCase()); @@ -124,13 +138,22 @@ export function GameProfilePanel({ }) .catch(() => setCatalogEntry(null)); return () => controller.abort(); - }, [game.name]); + }, [game.name, game.source]); useEffect(() => { setLoaded(false); + setLoadError(false); const controller = new AbortController(); - void bridgeProfiles(controller.signal).then((profiles) => { - const existing = profiles.find((profile) => matchesGame(profile, game)); + void profileWrites.then(() => bridgeProfiles(controller.signal)).then((profiles) => { + if (controller.signal.aborted) return; + targetProfiles.current.clear(); + for (const profile of profiles.filter((entry) => matchesGame(entry, game))) { + targetProfiles.current.set(profile.device.id, { settings: profileTarget(profile), enabled: profile.enabled !== false }); + } + const selected = devices.find((device) => device.selected); + const candidates = profiles.filter((profile) => matchesGame(profile, game)); + const existing = candidates.find((profile) => selected && profile.device.id === deviceId(selected)) + ?? candidates.find((profile) => devices.some((device) => deviceId(device) === profile.device.id)); savedRef.current = existing ? profileTarget(existing) : {}; savedKey.current = snapshotKey(savedRef.current); setAutoApply(existing !== undefined && existing.enabled !== false); @@ -139,12 +162,13 @@ export function GameProfilePanel({ : -1; setTargetIndex(matchedDevice !== -1 ? matchedDevice : selectedIndex(devices)); setLoaded(true); - }).catch(() => undefined); + }).catch(() => { if (!controller.signal.aborted) setLoadError(true); }); return () => controller.abort(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [game.name]); + }, [gameIdentity(game)]); const target = targetIndex !== null ? devices[targetIndex] : undefined; + activeTarget.current = target ? deviceId(target) : null; const editing = Boolean(target?.selected) && snapshot.deviceStatus !== null; // The cards edit whichever mouse is connected, so the draft only runs while @@ -155,9 +179,9 @@ export function GameProfilePanel({ const reopening = lastDraft.current !== null; if (!control.beginGameProfileDraft(lastDraft.current ?? savedRef.current)) return; adoptDraft.current = !reopening; - return () => control.endGameProfileDraft(); + return () => control.endGameProfileDraft(draftOwner); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loaded, editing, target?.name, snapshot.settingInProgress]); + }, [loaded, editing, draftOwner, targetIndex, snapshot.settingInProgress]); const draft = snapshot.gameProfileDraft ? control.gameProfileDraftSnapshot() : null; if (draft !== null) lastDraft.current = draft; @@ -174,40 +198,60 @@ export function GameProfilePanel({ notify: NotifyKind, ): Promise { const device = next.targetIndex !== null ? devices[next.targetIndex] : undefined; + if (!device || !loaded) return; + const owner = draftOwner; + const id = deviceId(device); + const stored = { settings: next.settings, enabled: next.autoApply, pending: true, error: false }; + targetProfiles.current.set(id, stored); + pendingWrites.current++; setSaving(true); - let ok = true; - try { - const profiles = await bridgeProfiles(); - const withoutThis = profiles.filter((profile) => !matchesGame(profile, game)); - // A profile is stored whenever it sets anything or is on; the toggle - // only decides whether Bridge applies it. - const keep = next.autoApply || Object.keys(next.settings).length > 0; - if (keep && device) { - const profile: BridgeProfile = { - application: { name: game.name, executable: game.executables[0] ?? "", path: "" }, - device: { id: deviceId(device), name: device.name }, - enabled: next.autoApply, - settings: { - dpi: next.settings.dpi ?? null, - pollingRateHz: next.settings.pollingRateHz ?? null, - snapshot: next.settings, - }, - }; - await saveBridgeProfiles([...withoutThis, profile]); - } else { - await saveBridgeProfiles(withoutThis); + setSaveFailed(false); + const write = async (): Promise => { + let ok = true; + try { + const profiles = await bridgeProfiles(); + // A profile is stored whenever it sets anything or is on; the toggle + // only decides whether Bridge applies it. + const keep = next.autoApply || Object.keys(next.settings).length > 0; + if (keep) { + const profile: BridgeProfile = { + application: profileApplication(game), + device: { id: deviceId(device), name: device.name }, + enabled: next.autoApply, + settings: { + dpi: next.settings.dpi ?? null, + pollingRateHz: next.settings.pollingRateHz ?? null, + snapshot: next.settings, + }, + }; + await saveBridgeProfiles(replaceGameProfile(profiles, game, deviceId(device), profile)); + } else { + await saveBridgeProfiles(replaceGameProfile(profiles, game, deviceId(device), null)); + } + const acknowledged = (await bridgeProfiles()).find((profile) => matchesGame(profile, game) && profile.device.id === id); + if (keep ? !acknowledged || (acknowledged.enabled !== false) !== next.autoApply || snapshotKey(profileTarget(acknowledged)) !== snapshotKey(next.settings) : acknowledged) throw new Error("Profile verification failed"); + if (activeTarget.current === id && control.getGameProfileOwner() === owner && targetProfiles.current.get(id) === stored) { + savedRef.current = next.settings; + savedKey.current = snapshotKey(next.settings); + } + } catch { + // Bridge being briefly unreachable shouldn't block the form; the next + // successful save (or the next page load's re-fetch) reconciles state + // — but the user still needs to know THIS attempt didn't land. + ok = false; + stored.error = true; + } finally { + stored.pending = false; + pendingWrites.current--; + setSaving(pendingWrites.current > 0); } - savedRef.current = next.settings; - savedKey.current = snapshotKey(next.settings); - } catch { - // Bridge being briefly unreachable shouldn't block the form; the next - // successful save (or the next page load's re-fetch) reconciles state - // — but the user still needs to know THIS attempt didn't land. - ok = false; - } finally { - setSaving(false); - } - notifyResult(locale, game.name, notify, ok); + if (activeTarget.current === id && control.getGameProfileOwner() === owner && targetProfiles.current.get(id) === stored) { + setSaveFailed(!ok); + notifyResult(locale, game.name, notify, ok); + } + }; + profileWrites = profileWrites.then(write, write); + await profileWrites; } // Draft edits save themselves, on or off, so leaving the page never loses @@ -217,42 +261,55 @@ export function GameProfilePanel({ const autoApplyRef = useRef(autoApply); autoApplyRef.current = autoApply; const pendingSave = useRef<(() => void) | null>(null); - useEffect(() => { + function cancelPending(): void { pendingSave.current = null; - if (!loaded || draft === null || currentKey === savedKey.current) return; + if (pendingTimer.current !== null) clearTimeout(pendingTimer.current); + pendingTimer.current = null; + } + useEffect(() => { + cancelPending(); + const stored = target ? targetProfiles.current.get(deviceId(target)) : undefined; + if (!loaded || draft === null || currentKey === savedKey.current || (stored?.pending && snapshotKey(stored.settings) === currentKey)) return; const save = (): void => { - pendingSave.current = null; + cancelPending(); + if (control.getGameProfileOwner() !== draftOwner) return; const on = autoApplyRef.current; void persist({ settings: current, targetIndex, autoApply: on }, on ? "updated" : null); }; pendingSave.current = save; - const timer = setTimeout(save, SAVE_DEBOUNCE_MS); - return () => clearTimeout(timer); + pendingTimer.current = setTimeout(save, SAVE_DEBOUNCE_MS); + const timer = pendingTimer.current; + return () => { if (timer !== null) clearTimeout(timer); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentKey, loaded]); + }, [currentKey, loaded, targetIndex, draftOwner]); // Leaving the page mid-debounce still saves the last edit. useEffect(() => () => pendingSave.current?.(), []); function selectTarget(index: number): void { const device = devices[index]; if (!device) return; + if (index === targetIndex) return; + pendingSave.current?.(); + control.endGameProfileDraft(draftOwner); + const stored = targetProfiles.current.get(deviceId(device)); + savedRef.current = stored?.settings ?? {}; + savedKey.current = snapshotKey(savedRef.current); + lastDraft.current = null; + setAutoApply(stored?.enabled ?? false); + setSaveFailed(stored?.error ?? false); setTargetIndex(index); - // The cards can only edit the connected mouse; switching ends this draft - // (the effect above starts a new one once the new mouse has been read). - if (!device.selected) { - control.endGameProfileDraft(); - void control.selectAuthorizedDevice(device.index); - } - void persist({ settings: current, targetIndex: index, autoApply }, autoApply ? "updated" : null); + if (!device.selected) void control.selectAuthorizedDevice(device.index); } function toggleAutoApply(): void { + cancelPending(); const next = !autoApply; setAutoApply(next); void persist({ settings: current, targetIndex, autoApply: next }, next ? "enabled" : "disabled"); } function clearProfile(): void { + cancelPending(); const wasEnabled = autoApply; setAutoApply(false); control.resetGameProfileDraft({}); @@ -260,7 +317,7 @@ export function GameProfilePanel({ void persist({ settings: {}, targetIndex, autoApply: false }, wasEnabled ? "disabled" : null); } - const artwork = catalogEntry ? gameArtwork(catalogEntry) : null; + const artwork = game.source !== "local" && catalogEntry ? gameArtwork(catalogEntry) : null; const tabs = editing ? availableWorkspaceTabs(true, cardAvailability(snapshot)).filter((entry) => GAME_PROFILE_TABS.includes(entry)) : []; @@ -273,6 +330,9 @@ export function GameProfilePanel({ {t(locale, "nav.games")} + {saveFailed ?

{t(locale, "bridge.profileSaveFailed")}

: null} + {!loaded ?

{t(locale, loadError ? "localApps.loadFailed" : "localApps.loading")}

: null} +
@@ -288,6 +348,7 @@ export function GameProfilePanel({

{game.name}

+ {game.source === "local" ? <>{t(locale, "localApps.local")}{game.bundlePath || game.path || game.executables[0]} : null} {displayText(locale, changedCount === 0 ? t(locale, "games.noChanges") @@ -297,7 +358,7 @@ export function GameProfilePanel({
@@ -318,6 +379,7 @@ export function GameProfilePanel({ - ) : ( -
{body}
- )} - - ); - })} -
  • - -
  • - + function tile(game: BridgeGame): ReactNode { + const art = game.source === "local" ? undefined : catalog.artwork.get(game.name.toLowerCase()); + return <> + + {art ? {displayText(locale, : + {game.name} + ; + } - setRequestOpen(false)} - locale={locale} - bridgeActive={active} - knownGames={games} - /> + return
    +
    +

    {t(locale, "localApps.title")}

    {t(locale, "games.subtitle")}

    + + {t(locale, "bridge.title")} · {t(locale, active ? "bridge.connected" : "bridge.notRunning")} + {displayText(locale, active && status?.version ? ` · v${status.version}` : "")} +
    - ); + {!active || loadState === "unsupported" ?
    + +

    {t(locale, !active ? "games.needsBridgeTitle" : "localApps.updateBridge")}

    {t(locale, !active ? "games.needsBridgeBody" : "localApps.privacy")}

    + +
    : null} +
    +
    +

    {t(locale, "localApps.yours")}

    + +
    + {guidance ?
    +

    {t(locale, guidance)}

    + {active && loadState === "error" ? : null} +
    : applications.length === 0 ?

    {t(locale, "localApps.empty")}

    : null} + {active && applications.length > 0 ?
      {applications.map((application) => { + const game = localGame(application); + return
    • + +
      {t(locale, "localApps.local")}{t(locale, application.kind === "game" ? "localApps.game" : "localApps.app")}
      +

      {application.bundlePath || application.path || application.executable}

      +
      + + +
      +
    • ; + })}
    : null} +
    +
    +

    {t(locale, "localApps.presets")}

    +
      {presets.map((game) =>
    • + {active ? :
      {tile(game)}
      } +
    • )}
    +
    + {dialog ? {t(locale, "localApps.loading")}

    }> + setDialog(null)} + onSaved={(apps, created) => { + setApplications(apps); + setLoadState("ready"); + setDialog(null); + if (created) setSelectedGame(localGame(created)); + }} /> +
    : null} +
    ; } diff --git a/src/features/games/LocalApplicationDialog.tsx b/src/features/games/LocalApplicationDialog.tsx new file mode 100644 index 00000000..e89b35b3 --- /dev/null +++ b/src/features/games/LocalApplicationDialog.tsx @@ -0,0 +1,130 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { bridgeLocalApplications, saveBridgeLocalApplications, BridgeHttpError, type LocalApplication } from "../bridge/bridge"; +import { t, tp, type I18nKey } from "../../i18n/index"; +import type { InterfaceLocale } from "../settings/interface-preferences"; +import { localTargetIdentity, parseLocalTarget, targetActivation } from "./local-applications"; + +export function LocalApplicationDialog({ locale, application, remove = false, active, onClose, onSaved }: { + locale: InterfaceLocale; + application?: LocalApplication; + remove?: boolean; + active: boolean; + onClose: () => void; + onSaved: (applications: LocalApplication[], created?: LocalApplication) => void; +}): ReactNode { + const dialog = useRef(null); + const saving = useRef(false); + const mounted = useRef(true); + const [name, setName] = useState(application?.name ?? ""); + const [target, setTarget] = useState(application?.bundlePath || application?.path || application?.executable || ""); + const [kind, setKind] = useState<"game" | "app">(application?.kind ?? "game"); + const [activation, setActivation] = useState<"running" | "focused">(application ? targetActivation(application) : "running"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const id = useRef(application?.id ?? `local:${crypto.randomUUID()}`); + const parsed = parseLocalTarget(target); + const valid = Boolean(parsed?.path && !/\.(?:desktop|appimage|sh|bash|zsh|fish|py|pl|rb|js|bat|cmd|ps1|lnk)$/i.test(parsed.executable) && name.trim() && [...name.trim()].length <= 80 && !/[\x00-\x1f\x7f]/.test(name)); + + useEffect(() => { + const element = dialog.current; + const previous = document.activeElement; + mounted.current = true; + element?.showModal(); + return () => { + mounted.current = false; + element?.close(); + if (previous instanceof HTMLElement && previous.isConnected) previous.focus(); + }; + }, []); + + function close(): void { + if (mounted.current && !saving.current) onClose(); + } + + async function save(): Promise { + if (saving.current || !active || (!remove && (!valid || !parsed))) return; + saving.current = true; + setBusy(true); + setError(null); + try { + const current = await bridgeLocalApplications(); + if (!mounted.current) return; + if (application && !current.some((app) => app.id === application.id)) throw new Error("Missing local target"); + const next: LocalApplication | null = remove ? null : { id: id.current, name: name.trim(), ...parsed!, kind, activation }; + const remaining = current.filter((app) => app.id !== id.current); + if (next && remaining.some((app) => localTargetIdentity(app) === localTargetIdentity(next))) { + setError("localApps.duplicate"); + return; + } + if (next && remaining.length >= 256) { + setError("localApps.failed"); + return; + } + await saveBridgeLocalApplications(next ? [...remaining, next] : remaining); + const stored = await bridgeLocalApplications(); + if (!mounted.current) return; + const saved = stored.find((app) => app.id === id.current); + if (next && saved && saved.activation !== next.activation) { + setError("localApps.updateBridge"); + return; + } + const bundle = next?.path.startsWith("/") && next.path.endsWith(".app"); + const canonical = saved ? parseLocalTarget(saved.path) : null; + const pathMatches = next && saved && (bundle + ? saved.bundlePath === next.path && saved.path.startsWith(`${next.path}/`) && canonical?.executable === saved.executable + : saved.executable === next.executable && saved.path === next.path); + if (next ? !saved || saved.name !== next.name || !pathMatches || saved.kind !== next.kind : saved) throw new Error("Local target verification failed"); + onSaved(stored, !application ? saved : undefined); + } catch (cause) { + if (mounted.current) setError(cause instanceof BridgeHttpError && cause.status === 404 ? "localApps.updateBridge" : "localApps.failed"); + } finally { + saving.current = false; + if (mounted.current) setBusy(false); + } + } + + return ( + { event.preventDefault(); close(); }} onClose={close} + onClick={(event) => { if (event.target === dialog.current) close(); }}> +
    { event.preventDefault(); void save(); }}> +
    +

    {t(locale, remove ? "localApps.remove" : application ? "localApps.editTitle" : "localApps.addTitle")}

    + +
    + {remove ?

    {tp(locale, "localApps.removeConfirm", { name: application?.name ?? "" })}

    : <> + +
    + {(["game", "app"] as const).map((value) => )} +
    +
    + {(["running", "focused"] as const).map((value) => )} +
    +

    {t(locale, activation === "running" ? "localApps.gameHint" : "localApps.appHint")}

    + {activation === "focused" ?

    {t(locale, "localApps.focusedLimit")}

    : null} + +

    {t(locale, "localApps.manualHint")}

    + {target && !valid ?

    {t(locale, "localApps.invalid")}

    : null} + } +

    {t(locale, "localApps.privacy")}

    + {!active ?

    {t(locale, "localApps.bridgeRequired")}

    : null} + {error ?

    {t(locale, error)}

    : null} +
    + + +
    +
    +
    + ); +} diff --git a/src/features/games/local-application-dialog.test.ts b/src/features/games/local-application-dialog.test.ts new file mode 100644 index 00000000..945e05e1 --- /dev/null +++ b/src/features/games/local-application-dialog.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFileSync } from "node:fs"; +import vm from "node:vm"; +import ts from "typescript"; +import * as local from "./local-applications.ts"; + +const settle = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); }; +const plain = (value: any) => JSON.parse(JSON.stringify(value)); +function harness(application?: any, transform: (value: any[]) => any[] = (value) => value) { + const slots: any[] = []; + let cursor = 0; + let effects: (() => void)[] = []; + let tree: any; + let stored = application ? [application] : []; + const writes: any[][] = []; + const saved: any[][] = []; + let processCalls = 0; + const react = { + useRef(initial: unknown) { return slots[cursor++] ??= { current: initial }; }, + useState(initial: unknown) { const i = cursor++; slots[i] ??= { value: initial }; return [slots[i].value, (value: any) => { slots[i].value = value; }]; }, + useEffect(effect: () => any, deps: unknown[] = []) { const i = cursor++; if (slots[i]) return; effects.push(() => { slots[i] = { cleanup: effect() }; }); }, + }; + const exports: any = {}; + const code = ts.transpileModule(readFileSync(new URL("./LocalApplicationDialog.tsx", import.meta.url), "utf8"), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText; + const jsx = (type: any, props: any) => { if (type === "dialog") props.ref.current = { showModal() {}, close() {} }; return { type, props }; }; + vm.runInNewContext(code, { exports, crypto: { randomUUID: () => "id" }, document: { activeElement: null }, HTMLElement: class {}, require: (name: string) => { + if (name === "react") return react; + if (name === "react/jsx-runtime") return { jsx, jsxs: jsx }; + if (name.endsWith("/bridge")) return { bridgeLocalApplications: async () => plain(stored), saveBridgeLocalApplications: async (value: any[]) => { writes.push(plain(value)); stored = transform(plain(value)); }, bridgeRunningApplications: async () => { processCalls++; return []; }, BridgeHttpError: class extends Error {} }; + if (name.endsWith("local-applications")) return local; + if (name.endsWith("i18n/index")) return { t: (_: unknown, key: string) => key, tp: (_: unknown, key: string) => key }; + throw new Error(name); + } }); + function render() { cursor = 0; effects = []; tree = exports.LocalApplicationDialog({ locale: "en", application, active: true, onClose() {}, onSaved: (...args: any[]) => saved.push(args) }); effects.forEach((effect) => effect()); } + function nodes(node: any): any[] { if (!node || typeof node !== "object") return []; if (Array.isArray(node)) return node.flatMap(nodes); return [node, ...nodes(node.props?.children)]; } + render(); + return { render, writes, saved, processCalls: () => processCalls, nodes: () => nodes(tree), input(id: string, value: string) { nodes(tree).find((node) => node.props?.id === id).props.onChange({ currentTarget: { value } }); render(); }, choose(value: string) { nodes(tree).find((node) => node.props?.role === "radio" && node.props.children === value).props.onClick(); render(); }, async submit() { nodes(tree).find((node) => node.type === "form").props.onSubmit({ preventDefault() {} }); await settle(); render(); }, unmount() { slots.forEach((slot) => slot.cleanup?.()); } }; +} + +test("legacy app edits show focused activation independently of category and allow explicit conversion", async () => { + const application = { id: "local:old", name: "Editor", executable: "editor", path: "/usr/bin/editor", kind: "app" }; + const h = harness(application); + assert.equal(h.nodes().find((node) => node.props?.role === "radio" && node.props.children === "localApps.focused").props["aria-checked"], true); + h.choose("localApps.game"); await h.submit(); assert.equal(h.writes[0][0].activation, "focused"); + h.choose("localApps.running"); await h.submit(); assert.equal(h.writes[1][0].activation, "running"); + assert.equal(h.writes[1][0].id, application.id); +}); + +test("manual form requires a full path and rejects unsupported launchers", async () => { + for (const path of ["editor.exe", "/usr/share/applications/editor.desktop", "/opt/editor.AppImage", "/bin/editor.sh", "C:/editor.cmd"]) { + const h = harness(); h.input("local-app-name", "Editor"); h.input("local-app-target", path); await h.submit(); assert.equal(h.writes.length, 0, path); + } +}); + +test("bundle selection accepts canonical executable metadata and reopens the bundle path", async () => { + const canonical = { id: "local:id", name: "Editor", kind: "app", activation: "running", executable: "Editor", path: "/Applications/Editor.app/Contents/MacOS/Editor", bundlePath: "/Applications/Editor.app", bundleIdentifier: "org.editor" }; + const h = harness(undefined, (value) => value.map((entry) => ({ ...entry, ...canonical }))); + h.input("local-app-name", "Editor"); h.input("local-app-target", canonical.bundlePath); h.choose("localApps.app"); await h.submit(); + assert.equal(h.saved.length, 1); assert.deepEqual(h.saved[0][1], canonical); + const edit = harness(canonical); assert.equal(edit.nodes().find((node) => node.props?.id === "local-app-target").props.value, canonical.bundlePath); +}); + +test("old Bridge dropping activation is reported as requiring an update, not a saved target", async () => { + const h = harness(undefined, (value) => value.map(({ activation, ...entry }) => entry)); + h.input("local-app-name", "Editor"); h.input("local-app-target", "/usr/bin/editor"); h.choose("localApps.app"); await h.submit(); + assert.equal(h.saved.length, 0); assert.equal(h.nodes().some((node) => node.props?.role === "alert" && node.props.children === "localApps.updateBridge"), true); +}); + +test("bundle metadata cannot grant a canonical path outside the selected bundle", async () => { + for (const path of ["/other/Editor", "/Applications/Editor.app/../other/Editor", "/Applications/Editor.app2/Editor", "/Applications/Editor.app"]) { + const h = harness(undefined, (value) => value.map((entry) => ({ ...entry, path, executable: path.split("/").pop(), bundlePath: "/Applications/Editor.app" }))); + h.input("local-app-name", "Editor"); h.input("local-app-target", "/Applications/Editor.app"); await h.submit(); assert.equal(h.saved.length, 0, path); + } +}); + +test("closing before the initial read completes cannot mutate a closed form's target", async () => { + const h = harness(); h.input("local-app-name", "Editor"); h.input("local-app-target", "/usr/bin/editor"); + const pending = h.submit(); h.unmount(); await pending; assert.equal(h.writes.length, 0); assert.equal(h.saved.length, 0); +}); + +test("late write response cannot call a closed form's completion callback", async () => { + const h = harness(undefined, (value) => { h.unmount(); return value; }); + h.input("local-app-name", "Editor"); h.input("local-app-target", "/usr/bin/editor"); await h.submit(); assert.equal(h.writes.length, 1); assert.equal(h.saved.length, 0); +}); + +test("manual full-path app creation explicitly requests running activation without a process chooser", async () => { + const h = harness(); h.input("local-app-name", "Editor"); h.input("local-app-target", "/usr/bin/editor"); h.choose("localApps.app"); await h.submit(); + assert.equal(h.writes[0][0].activation, "running"); assert.equal(h.saved.length, 1); + assert.equal(h.nodes().some((node) => node.props?.children === "localApps.pick"), false); assert.equal(h.processCalls(), 0); +}); diff --git a/src/features/games/local-applications.test.ts b/src/features/games/local-applications.test.ts new file mode 100644 index 00000000..2f712339 --- /dev/null +++ b/src/features/games/local-applications.test.ts @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFileSync } from "node:fs"; +import { ensureLocale, t, tp } from "../../i18n/index.ts"; +import { parseLocalTarget, presetGames, gameIdentity, matchesGame, profileApplication, localTargetIdentity, replaceGameProfile } from "./local-applications.ts"; +import * as targets from "./local-applications.ts"; +import type { BridgeGame, BridgeProfile } from "../bridge/bridge.ts"; + +const local: BridgeGame = { name: "Editor", executables: ["editor.exe"], id: "local:editor", source: "local", path: "C:\\Tools\\editor.exe", kind: "app" }; +const preset: BridgeGame = { name: "Editor", executables: ["editor.exe"] }; +const profile = (localId?: string, device = "mouse:one"): BridgeProfile => ({ application: { name: "Editor", executable: "editor.exe", path: "", ...(localId ? { localId } : {}) }, device: { id: device, name: device }, settings: { dpi: 800, pollingRateHz: null } }); + +test("local-only Bridge caches do not hide available OpenMouse presets", () => { + assert.deepEqual(presetGames([local], [preset]), [preset]); + assert.deepEqual(presetGames([local, { ...preset, source: "preset" }], []), [{ ...preset, source: "preset" }]); + assert.deepEqual(presetGames([], [preset]), [preset]); + assert.deepEqual(presetGames([preset], []), [preset]); +}); + +test("profile auto-apply switch has a localized programmatic name", () => { + const source = readFileSync(new URL("./GameProfilePanel.tsx", import.meta.url), "utf8"); + const switches = source.match(/]*role="switch"[^>]*>/g) ?? []; + assert.equal(switches.length, 1); + assert.match(switches[0], /aria-label=\{t\(locale, "games.autoApply"\)\}/); +}); + +test("game and app profile copy describes local targets and profile activation", async () => { + assert.equal(t("en", "games.subtitle"), "Choose a preset or add your own games and apps, saved locally in Bridge."); + assert.equal(t("en", "games.settingsHint"), "Saved only to the profile for {name}. These settings apply when the profile is active, and your previous settings return when it stops."); + for (const locale of ["en", "ar", "de", "es", "fr", "ja", "ko", "pt", "ru", "tr", "vi", "zh"] as const) { + await ensureLocale(locale); + const subtitle = t(locale, "games.subtitle"); + const hint = t(locale, "games.settingsHint"); + assert.ok(!subtitle.includes("\u2014") && !hint.includes("\u2014"), locale); + assert.equal((hint.match(/\{name\}/g) ?? []).length, 1, locale); + assert.ok(tp(locale, "games.settingsHint", { name: "Editor" }).includes("Editor"), locale); + if (locale !== "en") { + assert.notEqual(subtitle, t("en", "games.subtitle"), locale); + assert.notEqual(hint, t("en", "games.settingsHint"), locale); + } + } +}); + +test("manual targets preserve exact paths and derive executable leaves", () => { + assert.deepEqual(parseLocalTarget(' "C:\\Program Files\\Editor\\editor.exe" '), { executable: "editor.exe", path: "C:\\Program Files\\Editor\\editor.exe" }); + assert.deepEqual(parseLocalTarget("/usr/bin/editor"), { executable: "editor", path: "/usr/bin/editor" }); + assert.deepEqual(parseLocalTarget("editor.exe"), { executable: "editor.exe", path: "" }); + assert.deepEqual(parseLocalTarget("\\\\server\\share\\editor.exe"), { executable: "editor.exe", path: "\\\\server\\share\\editor.exe" }); +}); + +test("manual targets reject commands, wildcards, relative paths and malformed quotes", () => { + for (const input of ["", "../editor", "folder/editor.exe", "C:editor.exe", "C:\\Tools\\", "editor.exe --flag", "editor.exe;calc", "*.exe", "https://host/app.exe", "\"editor.exe", "editor\n.exe", "a".repeat(256), "/" + "a".repeat(1024)]) assert.equal(parseLocalTarget(input), null, input); +}); + +test("manual targets reject Windows executable arguments without requiring flags", () => { + for (const input of ["editor.exe file.txt", "EDITOR.EXE document", "C:/Tools/editor.exe file.txt"]) assert.equal(parseLocalTarget(input), null, input); +}); + +test("manual targets reject C1 control characters", () => { + for (const code of [0x80, 0x85, 0x9f]) assert.equal(parseLocalTarget(`C:/Tools/editor${String.fromCharCode(code)}.exe`), null); +}); + +test("manual UNC targets require a server, share and executable", () => { + for (const input of ["//server/editor", "//server", "\\\\server\\editor"]) assert.equal(parseLocalTarget(input), null, input); + for (const path of ["//server/share/editor", "\\\\server\\share\\editor"]) assert.deepEqual(parseLocalTarget(path), { executable: "editor", path }); +}); + +test("manual targets preserve literal punctuation in installation paths and names", () => { + for (const path of ["C:/Users/O'Connor/Apps & Tools/editor.exe", "C:/Apps/$Tools/editor.exe", "C:/Apps/Tools - Stable/editor.exe", "//server/share$/Tom Clancy's Game.exe"]) assert.deepEqual(parseLocalTarget(path), { executable: path.split("/").pop(), path }); + for (const executable of ["Tom Clancy's Game.exe", "Tools & Games.exe", "$Editor.exe"]) assert.deepEqual(parseLocalTarget(executable), { executable, path: "" }); +}); + +test("local identities are stable through rename and cannot collide with presets", () => { + assert.equal(gameIdentity(local), gameIdentity({ ...local, name: "Renamed" })); + assert.notEqual(gameIdentity(local), gameIdentity(preset)); + assert.equal(matchesGame(profile("local:editor"), { ...local, name: "Renamed" }), true); + assert.equal(matchesGame(profile(), local), false); + assert.equal(matchesGame(profile("local:other"), local), false); + assert.equal(matchesGame(profile("local:editor"), preset), false); + assert.equal(matchesGame(profile(), preset), true); +}); + +test("saved local profiles carry canonical metadata without changing preset schema", () => { + assert.deepEqual(profileApplication(local), { name: "Editor", executable: "editor.exe", path: "C:\\Tools\\editor.exe", localId: "local:editor" }); + assert.deepEqual(profileApplication(preset), { name: "Editor", executable: "editor.exe", path: "" }); +}); + +test("canonical local game and profile metadata retain activation, bundles and stable links", () => { + const target = { id: "local:editor", name: "Editor", executable: "Editor", path: "/Applications/Editor.app/Contents/MacOS/Editor", kind: "app" as const, activation: "running" as const, bundlePath: "/Applications/Editor.app", bundleIdentifier: "org.editor" }; + assert.equal(typeof targets.localGame, "function"); + const game = targets.localGame(target); + assert.deepEqual(game, { name: target.name, executables: [target.executable], id: target.id, source: "local", path: target.path, kind: target.kind, activation: target.activation, bundlePath: target.bundlePath, bundleIdentifier: target.bundleIdentifier }); + assert.deepEqual(profileApplication(game), { name: target.name, executable: target.executable, path: target.path, localId: target.id, activation: target.activation, bundlePath: target.bundlePath, bundleIdentifier: target.bundleIdentifier }); +}); + +test("activation defaults preserve legacy category semantics without overriding explicit choice", () => { + assert.equal(typeof targets.targetActivation, "function"); + assert.equal(targets.targetActivation({ kind: "app" }), "focused"); + assert.equal(targets.targetActivation({ kind: "game" }), "running"); + assert.equal(targets.targetActivation({ kind: "app", activation: "running" }), "running"); + assert.equal(targets.targetActivation({ kind: "game", activation: "focused" }), "focused"); +}); + +test("duplicate targets respect Windows normalization and POSIX case", () => { + assert.equal(localTargetIdentity({ executable: "Editor.EXE", path: "" }), localTargetIdentity({ executable: "editor.exe", path: "" })); + assert.equal(localTargetIdentity({ executable: "editor.exe", path: "C:\\Tools\\Editor.exe" }), localTargetIdentity({ executable: "editor.exe", path: "c:/tools/editor.exe" })); + assert.notEqual(localTargetIdentity({ executable: "Editor", path: "/bin/Editor" }), localTargetIdentity({ executable: "Editor", path: "/bin/editor" })); + assert.notEqual(localTargetIdentity({ executable: "editor.exe", path: "" }), localTargetIdentity({ executable: "editor.exe", path: "C:/editor.exe" })); +}); + +test("clearing local settings preserves preset and other device profiles", () => { + const others = [profile(), profile("local:other"), profile("local:editor", "mouse:two")]; + assert.deepEqual(replaceGameProfile([profile("local:editor"), ...others], local, "mouse:one", null), others); + const changed = { ...profile("local:editor"), settings: { dpi: 1600, pollingRateHz: null } }; + assert.deepEqual(replaceGameProfile([profile("local:editor"), ...others], local, "mouse:one", changed), [...others, changed]); +}); + + +test("manual target limits count Unicode characters without silently truncating", () => { + assert.deepEqual(parseLocalTarget("a".repeat(255)), { executable: "a".repeat(255), path: "" }); + assert.equal(parseLocalTarget("a".repeat(256)), null); + assert.deepEqual(parseLocalTarget("é".repeat(255)), { executable: "é".repeat(255), path: "" }); + const path = "/" + "d".repeat(767) + "/" + "e".repeat(255); + assert.equal([...path].length, 1024); + assert.deepEqual(parseLocalTarget(path), { executable: "e".repeat(255), path }); + assert.equal(parseLocalTarget("/" + path), null); +}); + +test("manual target paths cannot contain dot-segment aliases or Windows streams", () => { + for (const value of ["C:/Tools/../editor.exe", "/bin/./editor", "C:/Tools:stream/editor.exe", "C:/Tools/editor.exe:stream", "//", "./editor.exe"]) assert.equal(parseLocalTarget(value), null, value); +}); diff --git a/src/features/games/local-applications.ts b/src/features/games/local-applications.ts new file mode 100644 index 00000000..bc63b2de --- /dev/null +++ b/src/features/games/local-applications.ts @@ -0,0 +1,63 @@ +import type { BridgeGame, BridgeProfile, BridgeTargetMetadata, LocalApplication } from "../bridge/bridge"; + +export function targetActivation(target: BridgeTargetMetadata & { kind?: "game" | "app" }): "running" | "focused" { + return target.activation ?? (target.kind === "app" ? "focused" : "running"); +} + +function targetMetadata(target: BridgeTargetMetadata): BridgeTargetMetadata { + return { + ...(target.activation !== undefined ? { activation: target.activation } : {}), + ...(target.bundlePath !== undefined ? { bundlePath: target.bundlePath } : {}), + ...(target.bundleIdentifier !== undefined ? { bundleIdentifier: target.bundleIdentifier } : {}), + }; +} + +export function localGame(application: LocalApplication): BridgeGame { + return { name: application.name, executables: [application.executable], id: application.id, source: "local", path: application.path, kind: application.kind, ...targetMetadata(application) }; +} + +export function parseLocalTarget(input: string): { executable: string; path: string } | null { + let value = input.trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1).trim(); + if (!value || [...value].length > 1024 || /[\x00-\x1f\x7f-\x9f"<>|?*;`]/.test(value)) return null; + const unc = /^[\\/]{2}/.test(value); + if (unc && !/^[\\/]{2}[^\\/]+[\\/][^\\/]+[\\/][^\\/]/.test(value)) return null; + const windows = /^[a-z]:[\\/]/i.test(value) || unc; + const absolute = windows || value.startsWith("/"); + if (!absolute && /[\\/:]/.test(value)) return null; + const parts = value.split(/[\\/]/); + if (parts.some((part) => part === "." || part === "..") || /:/.test(windows ? value.slice(2) : value)) return null; + const executable = parts.pop() ?? ""; + if (!executable || executable === "." || executable === ".." || [...executable].length > 255 || /:/.test(executable) || /\.exe\s|\s[-/]/i.test(executable)) return null; + return { executable, path: absolute ? value : "" }; +} + +export function presetGames(bridge: BridgeGame[], catalog: BridgeGame[]): BridgeGame[] { + const presets = bridge.filter((game) => game.source !== "local"); + return presets.length ? presets : catalog; +} + +export function gameIdentity(game: BridgeGame): string { + return game.source === "local" && game.id ? game.id : `preset:${game.name.toLowerCase()}`; +} + +export function matchesGame(profile: BridgeProfile, game: BridgeGame): boolean { + return game.source === "local" + ? Boolean(game.id) && profile.application.localId === game.id + : !profile.application.localId && profile.application.name.toLowerCase() === game.name.toLowerCase(); +} + +export function profileApplication(game: BridgeGame): BridgeProfile["application"] { + return { name: game.name, executable: game.executables[0] ?? "", path: game.source === "local" ? game.path ?? "" : "", ...(game.source === "local" && game.id ? { localId: game.id, ...targetMetadata(game) } : {}) }; +} + +export function localTargetIdentity(target: { executable: string; path: string }): string { + if (!target.path) return `exe:${target.executable.toLowerCase()}`; + const path = target.path.replace(/\\/g, "/"); + return `path:${/^[a-z]:\//i.test(path) || path.startsWith("//") ? path.toLowerCase() : path}`; +} + +export function replaceGameProfile(profiles: BridgeProfile[], game: BridgeGame, deviceId: string, next: BridgeProfile | null): BridgeProfile[] { + const remaining = profiles.filter((profile) => !(matchesGame(profile, game) && profile.device.id === deviceId)); + return next ? [...remaining, next] : remaining; +} diff --git a/src/features/games/profile-draft-ownership.test.ts b/src/features/games/profile-draft-ownership.test.ts new file mode 100644 index 00000000..47c4c664 --- /dev/null +++ b/src/features/games/profile-draft-ownership.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFileSync } from "node:fs"; +import vm from "node:vm"; +import ts from "typescript"; +import * as snapshots from "../../device/game-profile-snapshot.ts"; +import * as targets from "./local-applications.ts"; + +function panelHarness() { + let owner: object | null = {}; + let draft: snapshots.GameProfileSnapshot | null = null; + const opened: { owner: unknown; snapshot: unknown }[] = []; + const ended: unknown[] = []; + const slots: any[] = []; + let cursor = 0; + let effects: (() => void)[] = []; + const react = { + useRef(initial: unknown) { return slots[cursor++] ??= { current: initial }; }, + useState(initial: unknown) { const index = cursor++; slots[index] ??= { value: initial }; return [slots[index].value, (value: unknown) => { slots[index].value = value; }]; }, + useEffect(effect: () => any, deps: unknown[]) { + const index = cursor++; + const prior = slots[index]; + if (prior && deps.every((value, i) => Object.is(value, prior.deps[i]))) return; + effects.push(() => { prior?.cleanup?.(); slots[index] = { deps, cleanup: effect() }; }); + }, + }; + const control = { + getGameProfileOwner: () => owner, + beginGameProfileDraft(snapshot: snapshots.GameProfileSnapshot) { opened.push({ owner, snapshot }); draft = snapshot; return true; }, + endGameProfileDraft(target: unknown) { ended.push(target); }, + gameProfileDraftSnapshot: () => draft, + }; + const exports: any = {}; + const source = readFileSync(new URL("./GameProfilePanel.tsx", import.meta.url), "utf8"); + const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText; + vm.runInNewContext(code, { exports, AbortController, setTimeout: () => 1, clearTimeout() {}, require: (name: string) => { + if (name === "react") return react; + if (name === "react/jsx-runtime") return { jsx: () => null, jsxs: () => null }; + if (name.endsWith("controller")) return control; + if (name.endsWith("game-profile-snapshot")) return snapshots; + if (name.endsWith("/bridge")) return { bridgeProfiles: async () => [], saveBridgeProfiles: async () => {} }; + if (name.endsWith("bridge-hid")) return { subscribeBridgeHidActive: () => () => {} }; + if (name.endsWith("local-applications")) return targets; + if (name.endsWith("workspace-tabs")) return { availableWorkspaceTabs: () => [] }; + if (name.endsWith("availability")) return { cardAvailability: () => ({}) }; + if (name.endsWith("i18n/index")) return { t: (_: unknown, key: string) => key, tp: (_: unknown, key: string) => key, displayText: (_: unknown, text: string) => text }; + return {}; + } }); + function render() { + cursor = 0; effects = []; + exports.GameProfilePanel({ snapshot: { devices: [{ selected: true, index: 0, name: "Same model", detail: "Fake" }], preferences: { locale: "en" }, deviceStatus: owner ? { name: "Same model", dpi: 800 } : null, settingInProgress: false, gameProfileDraft: draft !== null }, game: { source: "local", name: "Test", executables: ["test.exe"] }, onBack() {} }); + effects.forEach((effect) => effect()); + } + return { opened, ended, render, owner: () => owner, edit() { draft = { dpi: 3200 }; }, switchDevice() { owner = {}; draft = null; }, unmount() { slots.forEach((slot) => slot?.cleanup?.()); } }; +} + +test("panel reopens same-name sessions without carrying the prior session's unsaved draft", async () => { + const h = panelHarness(); + h.render(); for (let i = 0; i < 8; i++) await Promise.resolve(); h.render(); h.edit(); h.render(); + const first = h.owner(); + h.switchDevice(); h.render(); + assert.equal(h.opened.length, 2); + assert.deepEqual(JSON.parse(JSON.stringify(h.opened[1].snapshot)), {}); + assert.equal(h.ended[0], first); + h.unmount(); + assert.equal(h.ended.at(-1), h.owner()); +}); diff --git a/src/features/games/profile-target-switch.test.ts b/src/features/games/profile-target-switch.test.ts new file mode 100644 index 00000000..63c1646a --- /dev/null +++ b/src/features/games/profile-target-switch.test.ts @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFileSync } from "node:fs"; +import vm from "node:vm"; +import ts from "typescript"; +import * as snapshots from "../../device/game-profile-snapshot.ts"; +import * as local from "./local-applications.ts"; + +const settle = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); }; +const plain = (value: unknown) => JSON.parse(JSON.stringify(value)); +function harness(initialProfiles?: any[], gameMetadata: object = {}) { + let owner: object = {}; + let selected = 0; + let draft: snapshots.GameProfileSnapshot | null = null; + let profiles: any[] = initialProfiles ?? [800, 1600].map((dpi, i) => ({ application: { name: "Test", localId: "local:test", executable: "test.exe", path: "C:/test.exe" }, device: { id: `Fake:${i ? "B" : "A"}`, name: i ? "B" : "A" }, enabled: i === 1, settings: { dpi, snapshot: { dpi }, pollingRateHz: null } })); + const slots: any[] = []; + let cursor = 0; + let effects: (() => void)[] = []; + let tree: any; + const timers = new Map void>(); + let timerId = 0; + const requests: { profiles: any[]; resolve: () => void; reject: () => void }[] = []; + let delayed = false; + let ignoreWrites = false; + const toasts: unknown[][] = []; + const react = { + useRef(initial: unknown) { return slots[cursor++] ??= { current: initial }; }, + useState(initial: unknown) { const i = cursor++; const state = slots[i] ??= { value: initial }; return [state.value, (value: any) => { state.value = typeof value === "function" ? value(state.value) : value; }]; }, + useEffect(effect: () => any, deps: unknown[] = []) { + const i = cursor++; const prior = slots[i]; + if (prior && deps.length === prior.deps.length && deps.every((value, j) => Object.is(value, prior.deps[j]))) return; + effects.push(() => { prior?.cleanup?.(); slots[i] = { deps, cleanup: effect() }; }); + }, + }; + const control = { + getGameProfileOwner: () => owner, + beginGameProfileDraft(value: any) { draft = plain(value); return true; }, + endGameProfileDraft(target = owner) { if (target === owner) draft = null; }, + resetGameProfileDraft(value: any) { draft = plain(value); }, + gameProfileDraftSnapshot: () => draft, + selectAuthorizedDevice(index: number) { selected = index; owner = {}; draft = null; }, + pushToast: (...args: unknown[]) => toasts.push(args), + }; + const exports: any = {}; + const code = ts.transpileModule(readFileSync(new URL("./GameProfilePanel.tsx", import.meta.url), "utf8"), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText; + vm.runInNewContext(code, { exports, AbortController, setTimeout: (callback: () => void) => { timers.set(++timerId, callback); return timerId; }, clearTimeout: (id: number) => timers.delete(id), require: (name: string) => { + if (name === "react") return react; + if (name === "react/jsx-runtime") return { jsx: (type: unknown, props: unknown) => ({ type, props }), jsxs: (type: unknown, props: unknown) => ({ type, props }) }; + if (name.endsWith("controller")) return control; + if (name.endsWith("game-profile-snapshot")) return snapshots; + if (name.endsWith("/bridge")) return { bridgeProfiles: async () => plain(profiles), saveBridgeProfiles: async (next: any[]) => { if (delayed) await new Promise((resolve, reject) => requests.push({ profiles: plain(next), resolve: () => { profiles = plain(next); resolve(); }, reject: () => reject(new Error("offline")) })); else { if (!ignoreWrites) profiles = plain(next); requests.push({ profiles: plain(next), resolve() {}, reject() {} }); } } }; + if (name.endsWith("bridge-hid")) return { subscribeBridgeHidActive: () => () => {} }; + if (name.endsWith("local-applications")) return local; + if (name.endsWith("useBridgeProfileApplier")) return { profileTarget: (profile: any) => profile.settings.snapshot }; + if (name.endsWith("workspace-tabs")) return { availableWorkspaceTabs: () => [] }; + if (name.endsWith("availability")) return { cardAvailability: () => ({}) }; + if (name.endsWith("i18n/index")) return { t: (_: unknown, key: string) => key, tp: (_: unknown, key: string) => key, displayText: (_: unknown, text: string) => text }; + return {}; + } }); + function render() { + cursor = 0; effects = []; + tree = exports.GameProfilePanel({ snapshot: { devices: ["A", "B"].map((name, index) => ({ selected: selected === index, index, name, detail: "Fake" })), preferences: { locale: "en" }, deviceStatus: { name: selected ? "B" : "A" }, settingInProgress: false, gameProfileDraft: draft !== null }, game: { id: "local:test", source: "local", name: "Test", executables: ["test.exe"], ...gameMetadata }, onBack() {} }); + effects.forEach((effect) => effect()); + } + function nodes(node: any): any[] { if (!node || typeof node !== "object") return []; if (Array.isArray(node)) return node.flatMap((entry) => nodes(entry)); return [node, ...nodes(node.props?.children)]; } + return { render, hasText(text: string) { return nodes(tree).some((node) => node.props?.children === text); }, async ready() { render(); await settle(); render(); render(); }, select(index: number) { nodes(tree).filter((node) => node.type === "button" && node.props.className?.includes("game-profile-select"))[index].props.onClick(); }, toggle() { nodes(tree).find((node) => node.props?.role === "switch").props.onClick(); }, clear() { nodes(tree).find((node) => node.props?.className?.includes("game-profile-clear")).props.onClick(); }, retry() { nodes(tree).find((node) => node.props?.className?.includes("game-profile-retry")).props.onClick(); }, enabled: () => nodes(tree).find((node) => node.props?.role === "switch").props["aria-checked"], edit(dpi: number) { draft = { dpi }; render(); }, tick() { const pending = [...timers.values()]; timers.clear(); pending.forEach((callback) => callback()); }, requests, profiles: () => plain(profiles), draft: () => plain(draft), ignoreWrites(value: boolean) { ignoreWrites = value; }, delay() { delayed = true; }, toasts, changeOwnerBeforeRender() { owner = {}; draft = null; }, newSession() { owner = {}; draft = null; render(); render(); }, remount() { slots.length = 0; draft = null; render(); }, unmount() { slots.forEach((slot) => slot?.cleanup?.()); } }; +} + +test("switch mid-debounce flushes only A and old completion cannot change B or a later A session", async () => { + const h = harness(); await h.ready(); h.delay(); + h.edit(900); h.select(1); h.render(); h.render(); await settle(); + assert.equal(h.requests.length, 1); + assert.equal(h.requests[0].profiles.find((entry) => entry.device.name === "A").settings.dpi, 900); + assert.deepEqual(h.draft(), { dpi: 1600 }); + h.requests[0].resolve(); await settle(); h.render(); + h.select(0); h.render(); h.render(); + assert.deepEqual(h.draft(), { dpi: 900 }); + h.newSession(); assert.deepEqual(h.draft(), { dpi: 900 }); +}); + +test("autosave, toggle and clear are serialized and stale debounce cannot undo clear", async () => { + const h = harness(); await h.ready(); h.delay(); + h.edit(900); h.tick(); await settle(); + h.edit(1000); h.toggle(); h.render(); h.clear(); h.render(); h.tick(); await settle(); + assert.equal(h.requests.length, 1); + h.requests[0].resolve(); await settle(); + assert.equal(h.requests.length, 2); + assert.equal(h.requests[1].profiles.find((entry) => entry.device.name === "A").settings.dpi, 1000); + assert.equal(h.requests[1].profiles.find((entry) => entry.device.name === "A").enabled, true); + h.requests[1].resolve(); await settle(); + assert.equal(h.requests.length, 3); + assert.equal(h.requests[2].profiles.some((entry) => entry.device.name === "A"), false); + assert.equal(h.requests[2].profiles.find((entry) => entry.device.name === "B").settings.dpi, 1600); + h.requests[2].resolve(); await settle(); h.render(); h.tick(); await settle(); + assert.equal(h.requests.length, 3); assert.deepEqual(h.draft(), {}); assert.equal(h.enabled(), false); +}); + +test("failed autosave keeps draft and offers an explicit successful retry", async () => { + const h = harness(); await h.ready(); h.delay(); + h.edit(900); h.tick(); await settle(); h.requests[0].reject(); await settle(); h.render(); + assert.deepEqual(h.draft(), { dpi: 900 }); assert.equal(h.profiles()[0].settings.dpi, 800); + assert.equal(h.toasts.some((entry) => entry[0] === "error"), true); + h.retry(); await settle(); assert.equal(h.requests.length, 2); + h.requests[1].resolve(); await settle(); h.render(); h.tick(); await settle(); + assert.equal(h.profiles().find((entry: any) => entry.device.name === "A").settings.dpi, 900); + assert.equal(h.requests.length, 2); +}); + +test("unmount flushes a pending edit once", async () => { + const h = harness(); await h.ready(); h.edit(900); h.unmount(); await settle(); h.tick(); await settle(); + assert.equal(h.requests.length, 1); + assert.equal(h.profiles().find((entry: any) => entry.device.name === "A").settings.dpi, 900); +}); + +test("same-model session ownership is checked before a delayed dispatch, even before render", async () => { + const h = harness(); await h.ready(); h.edit(900); h.changeOwnerBeforeRender(); h.tick(); await settle(); + assert.equal(h.requests.length, 0); + h.render(); h.render(); assert.deepEqual(h.draft(), { dpi: 800 }); +}); + +test("old A completion cannot acknowledge B edits and B saves read the completed A write", async () => { + const h = harness(); await h.ready(); h.delay(); h.edit(900); h.tick(); await settle(); + h.select(1); h.render(); h.render(); h.edit(1700); h.tick(); await settle(); + assert.equal(h.requests.length, 1); + h.requests[0].resolve(); await settle(); h.render(); + assert.deepEqual(h.draft(), { dpi: 1700 }); assert.equal(h.requests.length, 2); + assert.equal(h.requests[1].profiles.find((entry) => entry.device.name === "A").settings.dpi, 900); + assert.equal(h.requests[1].profiles.find((entry) => entry.device.name === "B").settings.dpi, 1700); + h.requests[1].resolve(); await settle(); h.render(); h.tick(); await settle(); assert.equal(h.requests.length, 2); +}); + +test("a disconnected stored target cannot donate its settings to the selected mouse", async () => { + const h = harness([{ application: { localId: "local:test" }, device: { id: "Fake:Absent", name: "Absent" }, enabled: true, settings: { dpi: 6400, snapshot: { dpi: 6400 } } }]); + await h.ready(); assert.deepEqual(h.draft(), {}); assert.equal(h.enabled(), false); h.tick(); await settle(); assert.equal(h.requests.length, 0); +}); + +test("reopening a panel waits for its previous queued write before loading saved values", async () => { + const h = harness(); await h.ready(); h.delay(); h.edit(900); h.tick(); await settle(); + h.unmount(); h.remount(); await settle(); h.render(); h.render(); assert.equal(h.draft(), null); + h.requests[0].resolve(); await settle(); h.render(); h.render(); assert.deepEqual(h.draft(), { dpi: 900 }); +}); + +test("a fulfilled but unacknowledged profile save is not committed and stays retryable", async () => { + const h = harness(); await h.ready(); h.ignoreWrites(true); h.edit(900); h.tick(); await settle(); h.render(); + assert.equal(h.toasts.some((entry) => entry[0] === "error"), true); assert.deepEqual(h.draft(), { dpi: 900 }); + h.ignoreWrites(false); h.retry(); await settle(); h.render(); assert.equal(h.requests.length, 2); + assert.equal(h.profiles().find((entry: any) => entry.device.name === "A").settings.dpi, 900); +}); + +test("legacy focused panels explain unsupported focus without relabeling running apps", async () => { + const focused = harness(undefined, { kind: "app" }); await focused.ready(); assert.equal(focused.hasText("localApps.focusedLimit"), true); + const running = harness(undefined, { kind: "app", activation: "running" }); await running.ready(); assert.equal(running.hasText("localApps.focusedLimit"), false); assert.equal(running.hasText("localApps.gameHint"), true); +}); + +test("bundle panels display the selected bundle while profile metadata keeps the actual executable", async () => { + const h = harness(undefined, { path: "/Applications/Editor.app/Contents/MacOS/Editor", bundlePath: "/Applications/Editor.app" }); + await h.ready(); assert.equal(h.hasText("/Applications/Editor.app"), true); +}); + +test("select B loads B1600 and enabled state without persisting A800 into B", async () => { + const h = harness(); await h.ready(); + assert.deepEqual(h.draft(), { dpi: 800 }); assert.equal(h.enabled(), false); + h.select(1); h.render(); h.render(); await settle(); + assert.deepEqual(h.draft(), { dpi: 1600 }); assert.equal(h.enabled(), true); + assert.equal(h.requests.length, 0); assert.equal(h.profiles()[1].settings.dpi, 1600); +}); diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 8664ca9a..47e95cd4 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -93,16 +93,52 @@ const en = { "side.connectedDevices": "CONNECTED DEVICES", "bridge.title": "OpenMouse Bridge", "bridge.connected": "Connected", - "games.subtitle": "Pick a game to give it its own mouse settings — they're applied the moment the game launches.", + "games.subtitle": "Choose a preset or add your own games and apps, saved locally in Bridge.", "games.autoApply": "Auto", "games.needsBridgeTitle": "Game profiles need OpenMouse Bridge", "games.needsBridgeBody": "Bridge runs next to your browser, notices when a game starts and switches your mouse to that game's settings. Start Bridge, or download it, to set up a profile.", "games.settings": "Settings", - "games.settingsHint": "Saved to this game's profile only. Your mouse keeps its current settings until {name} launches, and gets them back when it closes.", + "games.settingsHint": "Saved only to the profile for {name}. These settings apply when the profile is active, and your previous settings return when it stops.", "games.changedCount": "Custom settings for this game: {n}", "games.noChanges": "Uses your current mouse settings", "games.connectTarget": "Connect {name} to edit its settings for this game.", "games.noTarget": "Pick a target device to edit this game's settings.", + "localApps.title": "Games & apps", + "localApps.presets": "OpenMouse presets", + "localApps.yours": "Your apps", + "localApps.add": "Add game or app", + "localApps.addTitle": "Add a local game or app", + "localApps.editTitle": "Edit local app", + "localApps.local": "Local", + "localApps.name": "Name", + "localApps.target": "Full application path", + "localApps.kind": "Type", + "localApps.activation": "Activation", + "localApps.running": "While running", + "localApps.focused": "While focused", + "localApps.focusedLimit": "If Bridge cannot detect focus on this system, this mode stays inactive. Choose While running instead.", + "localApps.game": "Game", + "localApps.app": "App", + "localApps.gameHint": "Use this profile while the target is running.", + "localApps.appHint": "Use this profile only while the app is in front.", + "localApps.privacy": "Saved on this computer by Bridge. Nothing is submitted to OpenMouse.", + "localApps.manualHint": "Enter a full path to a Windows .exe, macOS .app, or Linux executable. Scripts and launchers are not supported.", + "localApps.save": "Save locally", + "localApps.saving": "Saving…", + "localApps.edit": "Edit", + "localApps.remove": "Remove", + "localApps.removeConfirm": "Remove {name} and its saved mouse profiles from this computer?", + "localApps.invalid": "Enter a name and a supported full application path.", + "localApps.failed": "Could not save local apps. Check Bridge and try again.", + "localApps.loadFailed": "Could not load apps from Bridge. Try again.", + "localApps.updateBridge": "Update OpenMouse Bridge to add local games and apps.", + "localApps.bridgeRequired": "Run OpenMouse Bridge to manage local games and apps.", + "localApps.empty": "Add any game or app. No request or approval needed.", + "localApps.loading": "Loading local apps…", + "localApps.openProfile": "Configure profile", + "localApps.duplicate": "This executable or path is already in your local apps.", + "localApps.confirmRemove": "Remove locally", + "localApps.cancel": "Cancel", "bridge.profileEnabled": "Automatic apply turned on", "bridge.profileEnabledDetail": "Bridge will push this profile to your mouse the moment {name} launches.", "bridge.profileUpdated": "Profile updated", diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts index 04abbb35..b6d93fa6 100644 --- a/src/i18n/locales/ar.ts +++ b/src/i18n/locales/ar.ts @@ -38,16 +38,52 @@ export const ar: Record = { "side.connectedDevices": "الأجهزة المتصلة", "bridge.title": "OpenMouse Bridge", "bridge.connected": "متصل", - "games.subtitle": "اختر لعبة لتمنحها إعدادات ماوس خاصة بها — تُطبَّق فور تشغيل اللعبة.", + "games.subtitle": "اختر إعدادًا جاهزًا أو أضف ألعابك وتطبيقاتك الخاصة لحفظها محليًا في Bridge.", "games.autoApply": "تلقائي", "games.needsBridgeTitle": "ملفات الألعاب تحتاج إلى OpenMouse Bridge", "games.needsBridgeBody": "يعمل Bridge بجانب متصفحك، ويلاحظ بدء اللعبة ويحوّل ماوسك إلى إعدادات تلك اللعبة. شغّل Bridge أو نزّله لإعداد ملف.", "games.settings": "الإعدادات", - "games.settingsHint": "تُحفظ في ملف هذه اللعبة فقط. يحتفظ ماوسك بإعداداته الحالية حتى تشغيل {name}، ثم يستعيدها عند إغلاقها.", + "games.settingsHint": "تُحفظ فقط في ملف تعريف {name}. تُطبَّق هذه الإعدادات عندما يكون الملف نشطًا، وتعود إعداداتك السابقة عند إيقافه.", "games.changedCount": "إعدادات مخصّصة لهذه اللعبة: {n}", "games.noChanges": "يستخدم إعدادات ماوسك الحالية", "games.connectTarget": "وصّل {name} لتعديل إعداداته لهذه اللعبة.", "games.noTarget": "اختر جهازًا مستهدفًا لتعديل إعدادات هذه اللعبة.", + "localApps.title": "الألعاب والتطبيقات", + "localApps.presets": "إعدادات OpenMouse المسبقة", + "localApps.yours": "تطبيقاتك", + "localApps.add": "إضافة لعبة أو تطبيق", + "localApps.addTitle": "إضافة لعبة أو تطبيق محلي", + "localApps.editTitle": "تعديل تطبيق محلي", + "localApps.local": "محلي", + "localApps.name": "الاسم", + "localApps.target": "المسار الكامل للتطبيق", + "localApps.kind": "النوع", + "localApps.activation": "التفعيل", + "localApps.running": "أثناء التشغيل", + "localApps.focused": "عند التركيز", + "localApps.focusedLimit": "إذا تعذّر على Bridge اكتشاف التركيز على هذا النظام، يبقى هذا الوضع غير نشط. اختر أثناء التشغيل بدلاً منه.", + "localApps.game": "لعبة", + "localApps.app": "تطبيق", + "localApps.gameHint": "استخدم هذا الملف الشخصي أثناء تشغيل التطبيق المحدد.", + "localApps.appHint": "استخدم هذا الملف الشخصي فقط عندما يكون التطبيق في المقدمة.", + "localApps.privacy": "يحفظها Bridge على هذا الكمبيوتر. لا يُرسل أي شيء إلى OpenMouse.", + "localApps.manualHint": "أدخل المسار الكامل لملف ‎.exe في Windows أو ‎.app في macOS أو ملف تنفيذي في Linux. البرامج النصية والمشغّلات غير مدعومة.", + "localApps.save": "حفظ محليًا", + "localApps.saving": "جارٍ الحفظ…", + "localApps.edit": "تعديل", + "localApps.remove": "إزالة", + "localApps.removeConfirm": "هل تريد إزالة {name} وملفات الماوس الشخصية المحفوظة له من هذا الكمبيوتر؟", + "localApps.invalid": "أدخل اسماً ومساراً كاملاً لتطبيق مدعوم.", + "localApps.failed": "تعذّر حفظ التطبيقات المحلية. تحقّق من Bridge وحاول مجددًا.", + "localApps.loadFailed": "تعذّر تحميل التطبيقات من Bridge. حاول مجددًا.", + "localApps.updateBridge": "حدّث OpenMouse Bridge لإضافة ألعاب وتطبيقات محلية.", + "localApps.bridgeRequired": "شغّل OpenMouse Bridge لإدارة الألعاب والتطبيقات المحلية.", + "localApps.empty": "أضف أي لعبة أو تطبيق. لا حاجة إلى طلب أو موافقة.", + "localApps.loading": "جارٍ تحميل التطبيقات المحلية…", + "localApps.openProfile": "ضبط الملف الشخصي", + "localApps.duplicate": "هذا الملف التنفيذي أو المسار موجود بالفعل في تطبيقاتك المحلية.", + "localApps.confirmRemove": "إزالة محليًا", + "localApps.cancel": "إلغاء", "bridge.profileEnabled": "تم تفعيل التطبيق التلقائي", "bridge.profileEnabledDetail": "سيدفع Bridge هذا الملف إلى ماوسك فور تشغيل {name}.", "bridge.profileUpdated": "تم تحديث الملف", diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 9a6d5839..917aff43 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -38,16 +38,52 @@ export const de: Record = { "side.connectedDevices": "VERBUNDENE GERÄTE", "bridge.title": "OpenMouse Bridge", "bridge.connected": "Verbunden", - "games.subtitle": "Wähle ein Spiel für eigene Mauseinstellungen – sie werden angewendet, sobald das Spiel startet.", + "games.subtitle": "Wähle eine Vorlage oder füge eigene Spiele und Apps hinzu. Sie werden lokal in Bridge gespeichert.", "games.autoApply": "Auto", "games.needsBridgeTitle": "Spielprofile brauchen OpenMouse Bridge", "games.needsBridgeBody": "Bridge läuft neben deinem Browser, erkennt den Start eines Spiels und schaltet deine Maus auf die Einstellungen dieses Spiels um. Starte Bridge oder lade es herunter, um ein Profil einzurichten.", "games.settings": "Einstellungen", - "games.settingsHint": "Nur im Profil dieses Spiels gespeichert. Deine Maus behält ihre aktuellen Einstellungen, bis {name} startet, und bekommt sie zurück, wenn es beendet wird.", + "games.settingsHint": "Wird nur im Profil für {name} gespeichert. Diese Einstellungen gelten, solange das Profil aktiv ist. Danach werden deine vorherigen Einstellungen wiederhergestellt.", "games.changedCount": "Eigene Einstellungen für dieses Spiel: {n}", "games.noChanges": "Verwendet deine aktuellen Mauseinstellungen", "games.connectTarget": "Verbinde {name}, um die Einstellungen für dieses Spiel zu bearbeiten.", "games.noTarget": "Wähle ein Zielgerät, um die Einstellungen dieses Spiels zu bearbeiten.", + "localApps.title": "Spiele & Apps", + "localApps.presets": "OpenMouse-Voreinstellungen", + "localApps.yours": "Deine Apps", + "localApps.add": "Spiel oder App hinzufügen", + "localApps.addTitle": "Lokales Spiel oder lokale App hinzufügen", + "localApps.editTitle": "Lokale App bearbeiten", + "localApps.local": "Lokal", + "localApps.name": "Name", + "localApps.target": "Vollständiger Anwendungspfad", + "localApps.kind": "Typ", + "localApps.activation": "Aktivierung", + "localApps.running": "Während der Ausführung", + "localApps.focused": "Im Vordergrund", + "localApps.focusedLimit": "Wenn Bridge den Fokus auf diesem System nicht erkennen kann, bleibt dieser Modus inaktiv. Wähle stattdessen Während der Ausführung.", + "localApps.game": "Spiel", + "localApps.app": "App", + "localApps.gameHint": "Dieses Profil verwenden, solange das Ziel läuft.", + "localApps.appHint": "Dieses Profil nur verwenden, wenn die App im Vordergrund ist.", + "localApps.privacy": "Von Bridge auf diesem Computer gespeichert. Es wird nichts an OpenMouse übermittelt.", + "localApps.manualHint": "Gib den vollständigen Pfad zu einer Windows-.exe, macOS-.app oder ausführbaren Linux-Datei ein. Skripte und Starter werden nicht unterstützt.", + "localApps.save": "Lokal speichern", + "localApps.saving": "Wird gespeichert…", + "localApps.edit": "Bearbeiten", + "localApps.remove": "Entfernen", + "localApps.removeConfirm": "{name} und die zugehörigen gespeicherten Mausprofile von diesem Computer entfernen?", + "localApps.invalid": "Gib einen Namen und einen unterstützten vollständigen Anwendungspfad ein.", + "localApps.failed": "Lokale Apps konnten nicht gespeichert werden. Prüfe Bridge und versuche es erneut.", + "localApps.loadFailed": "Apps konnten nicht von Bridge geladen werden. Versuche es erneut.", + "localApps.updateBridge": "Aktualisiere OpenMouse Bridge, um lokale Spiele und Apps hinzuzufügen.", + "localApps.bridgeRequired": "Starte OpenMouse Bridge, um lokale Spiele und Apps zu verwalten.", + "localApps.empty": "Füge beliebige Spiele oder Apps hinzu. Keine Anfrage oder Genehmigung nötig.", + "localApps.loading": "Lokale Apps werden geladen…", + "localApps.openProfile": "Profil konfigurieren", + "localApps.duplicate": "Diese Programmdatei oder dieser Pfad ist bereits in deinen lokalen Apps vorhanden.", + "localApps.confirmRemove": "Lokal entfernen", + "localApps.cancel": "Abbrechen", "bridge.profileEnabled": "Automatische Anwendung aktiviert", "bridge.profileEnabledDetail": "Bridge überträgt dieses Profil auf deine Maus, sobald {name} startet.", "bridge.profileUpdated": "Profil aktualisiert", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 71d25e6f..884f368e 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -38,16 +38,52 @@ export const es: Record = { "side.connectedDevices": "DISPOSITIVOS CONECTADOS", "bridge.title": "OpenMouse Bridge", "bridge.connected": "Conectado", - "games.subtitle": "Elige un juego para darle sus propios ajustes de ratón: se aplican en cuanto se inicia el juego.", + "games.subtitle": "Elige una configuración predefinida o añade tus propios juegos y aplicaciones, guardados localmente en Bridge.", "games.autoApply": "Auto", "games.needsBridgeTitle": "Los perfiles de juego necesitan OpenMouse Bridge", "games.needsBridgeBody": "Bridge se ejecuta junto a tu navegador, detecta cuándo empieza un juego y cambia tu ratón a la configuración de ese juego. Inicia Bridge o descárgalo para crear un perfil.", "games.settings": "Ajustes", - "games.settingsHint": "Se guarda solo en el perfil de este juego. Tu ratón conserva sus ajustes actuales hasta que se inicie {name} y los recupera al cerrarlo.", + "games.settingsHint": "Se guarda solo en el perfil de {name}. Estos ajustes se aplican mientras el perfil está activo y se restauran los anteriores cuando se desactiva.", "games.changedCount": "Ajustes propios para este juego: {n}", "games.noChanges": "Usa los ajustes actuales de tu ratón", "games.connectTarget": "Conecta {name} para editar sus ajustes para este juego.", "games.noTarget": "Elige un dispositivo de destino para editar los ajustes de este juego.", + "localApps.title": "Juegos y aplicaciones", + "localApps.presets": "Preajustes de OpenMouse", + "localApps.yours": "Tus aplicaciones", + "localApps.add": "Añadir juego o aplicación", + "localApps.addTitle": "Añadir un juego o aplicación local", + "localApps.editTitle": "Editar aplicación local", + "localApps.local": "Local", + "localApps.name": "Nombre", + "localApps.target": "Ruta completa de la aplicación", + "localApps.kind": "Tipo", + "localApps.activation": "Activación", + "localApps.running": "Mientras se ejecuta", + "localApps.focused": "En primer plano", + "localApps.focusedLimit": "Si Bridge no puede detectar el foco en este sistema, este modo permanece inactivo. Elige Mientras se ejecuta.", + "localApps.game": "Juego", + "localApps.app": "Aplicación", + "localApps.gameHint": "Usar este perfil mientras el destino esté en ejecución.", + "localApps.appHint": "Usar este perfil solo mientras la aplicación esté en primer plano.", + "localApps.privacy": "Bridge lo guarda en este equipo. No se envía nada a OpenMouse.", + "localApps.manualHint": "Introduce la ruta completa a un .exe de Windows, una .app de macOS o un ejecutable de Linux. No se admiten scripts ni lanzadores.", + "localApps.save": "Guardar localmente", + "localApps.saving": "Guardando…", + "localApps.edit": "Editar", + "localApps.remove": "Eliminar", + "localApps.removeConfirm": "¿Eliminar {name} y sus perfiles de ratón guardados de este equipo?", + "localApps.invalid": "Introduce un nombre y una ruta completa de aplicación compatible.", + "localApps.failed": "No se pudieron guardar las aplicaciones locales. Comprueba Bridge e inténtalo de nuevo.", + "localApps.loadFailed": "No se pudieron cargar las aplicaciones desde Bridge. Inténtalo de nuevo.", + "localApps.updateBridge": "Actualiza OpenMouse Bridge para añadir juegos y aplicaciones locales.", + "localApps.bridgeRequired": "Ejecuta OpenMouse Bridge para gestionar juegos y aplicaciones locales.", + "localApps.empty": "Añade cualquier juego o aplicación. No hace falta solicitarlo ni obtener aprobación.", + "localApps.loading": "Cargando aplicaciones locales…", + "localApps.openProfile": "Configurar perfil", + "localApps.duplicate": "Este ejecutable o ruta ya está en tus aplicaciones locales.", + "localApps.confirmRemove": "Eliminar localmente", + "localApps.cancel": "Cancelar", "bridge.profileEnabled": "Aplicación automática activada", "bridge.profileEnabledDetail": "Bridge enviará este perfil a tu ratón en cuanto se inicie {name}.", "bridge.profileUpdated": "Perfil actualizado", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 826a0c2b..f24d34dc 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -38,16 +38,52 @@ export const fr: Record = { "side.connectedDevices": "APPAREILS CONNECTÉS", "bridge.title": "OpenMouse Bridge", "bridge.connected": "Connecté", - "games.subtitle": "Choisis un jeu pour lui donner ses propres réglages de souris — ils s'appliquent dès le lancement du jeu.", + "games.subtitle": "Choisis un préréglage ou ajoute tes propres jeux et applications, enregistrés localement dans Bridge.", "games.autoApply": "Auto", "games.needsBridgeTitle": "Les profils de jeu nécessitent OpenMouse Bridge", "games.needsBridgeBody": "Bridge tourne à côté de ton navigateur, repère le lancement d'un jeu et applique à ta souris les réglages de ce jeu. Lance Bridge, ou télécharge-le, pour créer un profil.", "games.settings": "Réglages", - "games.settingsHint": "Enregistré uniquement dans le profil de ce jeu. Ta souris garde ses réglages actuels jusqu'au lancement de {name} et les retrouve à sa fermeture.", + "games.settingsHint": "Enregistré uniquement dans le profil de {name}. Ces réglages s'appliquent tant que le profil est actif. Tes réglages précédents sont rétablis à sa désactivation.", "games.changedCount": "Réglages propres à ce jeu : {n}", "games.noChanges": "Utilise les réglages actuels de ta souris", "games.connectTarget": "Connecte {name} pour modifier ses réglages pour ce jeu.", "games.noTarget": "Choisis un appareil cible pour modifier les réglages de ce jeu.", + "localApps.title": "Jeux et applications", + "localApps.presets": "Préréglages OpenMouse", + "localApps.yours": "Tes applications", + "localApps.add": "Ajouter un jeu ou une application", + "localApps.addTitle": "Ajouter un jeu ou une application locale", + "localApps.editTitle": "Modifier une application locale", + "localApps.local": "Local", + "localApps.name": "Nom", + "localApps.target": "Chemin complet de l’application", + "localApps.kind": "Type", + "localApps.activation": "Activation", + "localApps.running": "Pendant l’exécution", + "localApps.focused": "Au premier plan", + "localApps.focusedLimit": "Si Bridge ne peut pas détecter le focus sur ce système, ce mode reste inactif. Choisis Pendant l’exécution à la place.", + "localApps.game": "Jeu", + "localApps.app": "Application", + "localApps.gameHint": "Utiliser ce profil tant que la cible est en cours d’exécution.", + "localApps.appHint": "Utiliser ce profil uniquement lorsque l’application est au premier plan.", + "localApps.privacy": "Enregistré sur cet ordinateur par Bridge. Rien n’est envoyé à OpenMouse.", + "localApps.manualHint": "Saisis le chemin complet d’un .exe Windows, d’une .app macOS ou d’un exécutable Linux. Les scripts et lanceurs ne sont pas pris en charge.", + "localApps.save": "Enregistrer localement", + "localApps.saving": "Enregistrement…", + "localApps.edit": "Modifier", + "localApps.remove": "Supprimer", + "localApps.removeConfirm": "Supprimer {name} et ses profils de souris enregistrés de cet ordinateur ?", + "localApps.invalid": "Saisis un nom et un chemin complet d’application compatible.", + "localApps.failed": "Impossible d’enregistrer les applications locales. Vérifie Bridge et réessaie.", + "localApps.loadFailed": "Impossible de charger les applications depuis Bridge. Réessaie.", + "localApps.updateBridge": "Mets à jour OpenMouse Bridge pour ajouter des jeux et applications locales.", + "localApps.bridgeRequired": "Lance OpenMouse Bridge pour gérer les jeux et applications locales.", + "localApps.empty": "Ajoute n’importe quel jeu ou application. Aucune demande ni autorisation nécessaire.", + "localApps.loading": "Chargement des applications locales…", + "localApps.openProfile": "Configurer le profil", + "localApps.duplicate": "Cet exécutable ou ce chemin figure déjà dans tes applications locales.", + "localApps.confirmRemove": "Supprimer localement", + "localApps.cancel": "Annuler", "bridge.profileEnabled": "Application automatique activée", "bridge.profileEnabledDetail": "Bridge enverra ce profil à votre souris dès que {name} sera lancé.", "bridge.profileUpdated": "Profil mis à jour", diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index 75bf05ca..3456fa10 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -38,16 +38,52 @@ export const ja: Record = { "side.connectedDevices": "接続中のデバイス", "bridge.title": "OpenMouse Bridge", "bridge.connected": "接続済み", - "games.subtitle": "ゲームを選んで専用のマウス設定を作成すると、ゲームの起動時にすぐ適用されます。", + "games.subtitle": "プリセットを選ぶか、自分のゲームやアプリを追加して、Bridge にローカル保存できます。", "games.autoApply": "自動", "games.needsBridgeTitle": "ゲームプロファイルには OpenMouse Bridge が必要です", "games.needsBridgeBody": "Bridge はブラウザと並行して動作し、ゲームの起動を検知してマウスをそのゲームの設定に切り替えます。プロファイルを設定するには Bridge を起動するか、ダウンロードしてください。", "games.settings": "設定", - "games.settingsHint": "このゲームのプロファイルにのみ保存されます。{name} が起動するまでマウスは現在の設定のままで、終了すると元に戻ります。", + "games.settingsHint": "{name} のプロファイルにのみ保存されます。プロファイルが有効な間はこの設定が適用され、無効になると以前の設定に戻ります。", "games.changedCount": "このゲーム用に {n} 件の設定", "games.noChanges": "現在のマウス設定を使用", "games.connectTarget": "このゲーム用の設定を編集するには {name} を接続してください。", "games.noTarget": "このゲームの設定を編集するには対象デバイスを選んでください。", + "localApps.title": "ゲームとアプリ", + "localApps.presets": "OpenMouse のプリセット", + "localApps.yours": "自分のアプリ", + "localApps.add": "ゲームやアプリを追加", + "localApps.addTitle": "ローカルのゲームやアプリを追加", + "localApps.editTitle": "ローカルアプリを編集", + "localApps.local": "ローカル", + "localApps.name": "名前", + "localApps.target": "アプリのフルパス", + "localApps.kind": "種類", + "localApps.activation": "有効化", + "localApps.running": "実行中", + "localApps.focused": "最前面のとき", + "localApps.focusedLimit": "このシステムでBridgeがフォーカスを検出できない場合、このモードは有効になりません。代わりに「実行中」を選んでください。", + "localApps.game": "ゲーム", + "localApps.app": "アプリ", + "localApps.gameHint": "対象の実行中にこのプロファイルを使用します。", + "localApps.appHint": "アプリが最前面にある間だけこのプロファイルを使用します。", + "localApps.privacy": "Bridge がこのコンピューターに保存します。OpenMouse には何も送信されません。", + "localApps.manualHint": "Windowsの.exe、macOSの.app、またはLinuxの実行ファイルのフルパスを入力してください。スクリプトやランチャーには対応していません。", + "localApps.save": "ローカルに保存", + "localApps.saving": "保存中…", + "localApps.edit": "編集", + "localApps.remove": "削除", + "localApps.removeConfirm": "このコンピューターから {name} と保存済みのマウスプロファイルを削除しますか?", + "localApps.invalid": "名前と対応するアプリのフルパスを入力してください。", + "localApps.failed": "ローカルアプリを保存できませんでした。Bridge を確認して、もう一度お試しください。", + "localApps.loadFailed": "Bridge からアプリを読み込めませんでした。もう一度お試しください。", + "localApps.updateBridge": "ローカルのゲームやアプリを追加するには、OpenMouse Bridge を更新してください。", + "localApps.bridgeRequired": "ローカルのゲームやアプリを管理するには、OpenMouse Bridge を起動してください。", + "localApps.empty": "どんなゲームやアプリでも追加できます。リクエストや承認は不要です。", + "localApps.loading": "ローカルアプリを読み込み中…", + "localApps.openProfile": "プロファイルを設定", + "localApps.duplicate": "この実行ファイルまたはパスは、すでにローカルアプリに登録されています。", + "localApps.confirmRemove": "ローカルから削除", + "localApps.cancel": "キャンセル", "bridge.profileEnabled": "自動適用をオンにしました", "bridge.profileEnabledDetail": "{name} が起動すると、Bridge がこのプロファイルをマウスに送信します。", "bridge.profileUpdated": "プロファイルを更新しました", diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index a7c114b5..e529e7e7 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -38,16 +38,52 @@ export const ko: Record = { "side.connectedDevices": "연결된 장치", "bridge.title": "OpenMouse Bridge", "bridge.connected": "연결됨", - "games.subtitle": "게임을 선택해 전용 마우스 설정을 지정하면 게임이 실행되는 즉시 적용됩니다.", + "games.subtitle": "프리셋을 선택하거나 나만의 게임과 앱을 추가해 Bridge에 로컬로 저장하세요.", "games.autoApply": "자동", "games.needsBridgeTitle": "게임 프로필에는 OpenMouse Bridge가 필요합니다", "games.needsBridgeBody": "Bridge는 브라우저 옆에서 실행되며, 게임이 시작되면 이를 감지해 마우스를 해당 게임의 설정으로 전환합니다. 프로필을 만들려면 Bridge를 실행하거나 다운로드하세요.", "games.settings": "설정", - "games.settingsHint": "이 게임의 프로필에만 저장됩니다. {name}이(가) 실행될 때까지 마우스는 현재 설정을 유지하고, 종료되면 원래 설정으로 돌아갑니다.", + "games.settingsHint": "{name} 프로필에만 저장됩니다. 프로필이 활성화된 동안 이 설정이 적용되며, 비활성화되면 이전 설정으로 돌아갑니다.", "games.changedCount": "이 게임에 설정된 항목 {n}개", "games.noChanges": "현재 마우스 설정 사용", "games.connectTarget": "이 게임의 설정을 편집하려면 {name}을(를) 연결하세요.", "games.noTarget": "이 게임의 설정을 편집하려면 대상 장치를 선택하세요.", + "localApps.title": "게임 및 앱", + "localApps.presets": "OpenMouse 프리셋", + "localApps.yours": "내 앱", + "localApps.add": "게임 또는 앱 추가", + "localApps.addTitle": "로컬 게임 또는 앱 추가", + "localApps.editTitle": "로컬 앱 편집", + "localApps.local": "로컬", + "localApps.name": "이름", + "localApps.target": "앱 전체 경로", + "localApps.kind": "유형", + "localApps.activation": "활성화", + "localApps.running": "실행 중", + "localApps.focused": "맨 앞에 있을 때", + "localApps.focusedLimit": "이 시스템에서 Bridge가 포커스를 감지할 수 없으면 이 모드는 비활성 상태로 유지됩니다. 대신 실행 중을 선택하세요.", + "localApps.game": "게임", + "localApps.app": "앱", + "localApps.gameHint": "대상이 실행되는 동안 이 프로필을 사용합니다.", + "localApps.appHint": "앱이 맨 앞에 있을 때만 이 프로필을 사용합니다.", + "localApps.privacy": "Bridge가 이 컴퓨터에 저장합니다. OpenMouse로 전송되는 정보는 없습니다.", + "localApps.manualHint": "Windows .exe, macOS .app 또는 Linux 실행 파일의 전체 경로를 입력하세요. 스크립트와 실행기는 지원하지 않습니다.", + "localApps.save": "로컬에 저장", + "localApps.saving": "저장 중…", + "localApps.edit": "편집", + "localApps.remove": "제거", + "localApps.removeConfirm": "이 컴퓨터에서 {name} 및 저장된 마우스 프로필을 제거할까요?", + "localApps.invalid": "이름과 지원되는 앱의 전체 경로를 입력하세요.", + "localApps.failed": "로컬 앱을 저장하지 못했습니다. Bridge를 확인한 후 다시 시도하세요.", + "localApps.loadFailed": "Bridge에서 앱을 불러오지 못했습니다. 다시 시도하세요.", + "localApps.updateBridge": "로컬 게임과 앱을 추가하려면 OpenMouse Bridge를 업데이트하세요.", + "localApps.bridgeRequired": "로컬 게임과 앱을 관리하려면 OpenMouse Bridge를 실행하세요.", + "localApps.empty": "어떤 게임이나 앱이든 추가하세요. 요청이나 승인이 필요하지 않습니다.", + "localApps.loading": "로컬 앱 불러오는 중…", + "localApps.openProfile": "프로필 설정", + "localApps.duplicate": "이 실행 파일 또는 경로는 이미 로컬 앱에 있습니다.", + "localApps.confirmRemove": "로컬에서 제거", + "localApps.cancel": "취소", "bridge.profileEnabled": "자동 적용이 켜졌습니다", "bridge.profileEnabledDetail": "{name}이(가) 실행되는 즉시 Bridge가 이 프로필을 마우스에 적용합니다.", "bridge.profileUpdated": "프로필이 업데이트되었습니다", diff --git a/src/i18n/locales/pt.ts b/src/i18n/locales/pt.ts index 74442585..1230fa4c 100644 --- a/src/i18n/locales/pt.ts +++ b/src/i18n/locales/pt.ts @@ -38,16 +38,52 @@ export const pt: Record = { "side.connectedDevices": "DISPOSITIVOS CONECTADOS", "bridge.title": "OpenMouse Bridge", "bridge.connected": "Conectado", - "games.subtitle": "Escolha um jogo para dar a ele suas próprias configurações de mouse — elas são aplicadas assim que o jogo abre.", + "games.subtitle": "Escolha uma predefinição ou adicione seus próprios jogos e aplicativos, salvos localmente no Bridge.", "games.autoApply": "Auto", "games.needsBridgeTitle": "Os perfis de jogo precisam do OpenMouse Bridge", "games.needsBridgeBody": "O Bridge roda ao lado do seu navegador, percebe quando um jogo começa e muda o seu mouse para as configurações desse jogo. Inicie o Bridge, ou baixe-o, para criar um perfil.", "games.settings": "Configurações", - "games.settingsHint": "Salvo só no perfil deste jogo. Seu mouse mantém as configurações atuais até {name} abrir e volta a elas quando ele fecha.", + "games.settingsHint": "Salvo apenas no perfil de {name}. Estas configurações são aplicadas enquanto o perfil está ativo. As configurações anteriores são restauradas quando ele é desativado.", "games.changedCount": "Configurações próprias deste jogo: {n}", "games.noChanges": "Usa as configurações atuais do seu mouse", "games.connectTarget": "Conecte {name} para editar as configurações dele para este jogo.", "games.noTarget": "Escolha um dispositivo de destino para editar as configurações deste jogo.", + "localApps.title": "Jogos e apps", + "localApps.presets": "Predefinições do OpenMouse", + "localApps.yours": "Seus apps", + "localApps.add": "Adicionar jogo ou app", + "localApps.addTitle": "Adicionar um jogo ou app local", + "localApps.editTitle": "Editar app local", + "localApps.local": "Local", + "localApps.name": "Nome", + "localApps.target": "Caminho completo do aplicativo", + "localApps.kind": "Tipo", + "localApps.activation": "Ativação", + "localApps.running": "Durante a execução", + "localApps.focused": "Em primeiro plano", + "localApps.focusedLimit": "Se o Bridge não conseguir detectar o foco neste sistema, este modo permanece inativo. Escolha Durante a execução.", + "localApps.game": "Jogo", + "localApps.app": "App", + "localApps.gameHint": "Usar este perfil enquanto o destino estiver em execução.", + "localApps.appHint": "Usar este perfil apenas enquanto o app estiver em primeiro plano.", + "localApps.privacy": "Salvo neste computador pelo Bridge. Nada é enviado ao OpenMouse.", + "localApps.manualHint": "Digite o caminho completo de um .exe do Windows, .app do macOS ou executável do Linux. Scripts e inicializadores não são compatíveis.", + "localApps.save": "Salvar localmente", + "localApps.saving": "Salvando…", + "localApps.edit": "Editar", + "localApps.remove": "Remover", + "localApps.removeConfirm": "Remover {name} e seus perfis de mouse salvos deste computador?", + "localApps.invalid": "Digite um nome e um caminho completo de aplicativo compatível.", + "localApps.failed": "Não foi possível salvar os apps locais. Verifique o Bridge e tente novamente.", + "localApps.loadFailed": "Não foi possível carregar os apps do Bridge. Tente novamente.", + "localApps.updateBridge": "Atualize o OpenMouse Bridge para adicionar jogos e apps locais.", + "localApps.bridgeRequired": "Execute o OpenMouse Bridge para gerenciar jogos e apps locais.", + "localApps.empty": "Adicione qualquer jogo ou app. Não é preciso solicitar nem obter aprovação.", + "localApps.loading": "Carregando apps locais…", + "localApps.openProfile": "Configurar perfil", + "localApps.duplicate": "Este executável ou caminho já está nos seus apps locais.", + "localApps.confirmRemove": "Remover localmente", + "localApps.cancel": "Cancelar", "bridge.profileEnabled": "Aplicação automática ativada", "bridge.profileEnabledDetail": "O Bridge enviará este perfil para o seu mouse assim que {name} for iniciado.", "bridge.profileUpdated": "Perfil atualizado", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 45c26415..a498339f 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -38,16 +38,52 @@ export const ru: Record = { "side.connectedDevices": "ПОДКЛЮЧЁННЫЕ УСТРОЙСТВА", "bridge.title": "OpenMouse Bridge", "bridge.connected": "Подключено", - "games.subtitle": "Выберите игру, чтобы задать ей собственные настройки мыши — они применяются сразу при запуске игры.", + "games.subtitle": "Выберите готовый профиль или добавьте свои игры и приложения. Они сохраняются локально в Bridge.", "games.autoApply": "Авто", "games.needsBridgeTitle": "Для игровых профилей нужен OpenMouse Bridge", "games.needsBridgeBody": "Bridge работает рядом с браузером, замечает запуск игры и переключает мышь на настройки этой игры. Запустите Bridge или скачайте его, чтобы создать профиль.", "games.settings": "Настройки", - "games.settingsHint": "Сохраняется только в профиле этой игры. Мышь сохраняет текущие настройки до запуска {name} и возвращается к ним после закрытия.", + "games.settingsHint": "Сохраняется только в профиле {name}. Эти настройки применяются, пока профиль активен. После его отключения восстанавливаются предыдущие настройки.", "games.changedCount": "Настроек для этой игры: {n}", "games.noChanges": "Используются текущие настройки мыши", "games.connectTarget": "Подключите {name}, чтобы изменить его настройки для этой игры.", "games.noTarget": "Выберите целевое устройство, чтобы изменить настройки этой игры.", + "localApps.title": "Игры и приложения", + "localApps.presets": "Готовые настройки OpenMouse", + "localApps.yours": "Ваши приложения", + "localApps.add": "Добавить игру или приложение", + "localApps.addTitle": "Добавить локальную игру или приложение", + "localApps.editTitle": "Изменить локальное приложение", + "localApps.local": "Локально", + "localApps.name": "Название", + "localApps.target": "Полный путь к приложению", + "localApps.kind": "Тип", + "localApps.activation": "Активация", + "localApps.running": "Во время работы", + "localApps.focused": "На переднем плане", + "localApps.focusedLimit": "Если Bridge не может определить фокус в этой системе, этот режим остаётся неактивным. Выберите «Во время работы».", + "localApps.game": "Игра", + "localApps.app": "Приложение", + "localApps.gameHint": "Использовать этот профиль, пока выбранная программа запущена.", + "localApps.appHint": "Использовать этот профиль, только пока приложение на переднем плане.", + "localApps.privacy": "Bridge сохраняет данные на этом компьютере. В OpenMouse ничего не отправляется.", + "localApps.manualHint": "Введите полный путь к .exe в Windows, .app в macOS или исполняемому файлу Linux. Скрипты и программы запуска не поддерживаются.", + "localApps.save": "Сохранить локально", + "localApps.saving": "Сохранение…", + "localApps.edit": "Изменить", + "localApps.remove": "Удалить", + "localApps.removeConfirm": "Удалить {name} и сохранённые для него профили мыши с этого компьютера?", + "localApps.invalid": "Введите название и полный путь к поддерживаемому приложению.", + "localApps.failed": "Не удалось сохранить локальные приложения. Проверьте Bridge и повторите попытку.", + "localApps.loadFailed": "Не удалось загрузить приложения из Bridge. Повторите попытку.", + "localApps.updateBridge": "Обновите OpenMouse Bridge, чтобы добавлять локальные игры и приложения.", + "localApps.bridgeRequired": "Запустите OpenMouse Bridge для управления локальными играми и приложениями.", + "localApps.empty": "Добавьте любую игру или приложение. Заявка и одобрение не нужны.", + "localApps.loading": "Загрузка локальных приложений…", + "localApps.openProfile": "Настроить профиль", + "localApps.duplicate": "Этот исполняемый файл или путь уже есть в ваших локальных приложениях.", + "localApps.confirmRemove": "Удалить локально", + "localApps.cancel": "Отмена", "bridge.profileEnabled": "Автоприменение включено", "bridge.profileEnabledDetail": "Bridge применит этот профиль к мыши сразу после запуска {name}.", "bridge.profileUpdated": "Профиль обновлён", diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts index f77c6ec5..fcaf14a1 100644 --- a/src/i18n/locales/tr.ts +++ b/src/i18n/locales/tr.ts @@ -37,16 +37,52 @@ export const tr: Record = { "side.connectedDevices": "BAĞLI CİHAZLAR", "bridge.title": "OpenMouse Bridge", "bridge.connected": "Bağlı", - "games.subtitle": "Bir oyun seçip ona özel fare ayarları oluşturun. Ayarlar, oyun açıldığında uygulanır.", + "games.subtitle": "Bir hazır ayar seçin veya kendi oyun ve uygulamalarınızı ekleyin. Bunlar Bridge'de yerel olarak kaydedilir.", "games.autoApply": "Otomatik", "games.needsBridgeTitle": "Oyun profilleri için OpenMouse Bridge gerekir", "games.needsBridgeBody": "Bridge tarayıcınızın yanında çalışır, bir oyun başladığında bunu fark eder ve farenizi o oyunun ayarlarına geçirir. Profil oluşturmak için Bridge'i başlatın veya indirin.", "games.settings": "Ayarlar", - "games.settingsHint": "Yalnızca bu oyunun profiline kaydedilir. Fareniz, {name} açılana kadar mevcut ayarlarını korur; oyun kapandığında bu ayarlara geri döner.", + "games.settingsHint": "Yalnızca {name} profiline kaydedilir. Bu ayarlar profil etkinken uygulanır. Profil devre dışı kaldığında önceki ayarlarınız geri yüklenir.", "games.changedCount": "Bu oyuna özel ayarlar: {n}", "games.noChanges": "Mevcut fare ayarlarınızı kullanır", "games.connectTarget": "Bu oyun için ayarlarını düzenlemek üzere {name} cihazını bağlayın.", "games.noTarget": "Bu oyunun ayarlarını düzenlemek için bir hedef cihaz seçin.", + "localApps.title": "Oyunlar ve uygulamalar", + "localApps.presets": "OpenMouse hazır ayarları", + "localApps.yours": "Uygulamalarınız", + "localApps.add": "Oyun veya uygulama ekle", + "localApps.addTitle": "Yerel oyun veya uygulama ekle", + "localApps.editTitle": "Yerel uygulamayı düzenle", + "localApps.local": "Yerel", + "localApps.name": "Ad", + "localApps.target": "Uygulamanın tam yolu", + "localApps.kind": "Tür", + "localApps.activation": "Etkinleştirme", + "localApps.running": "Çalışırken", + "localApps.focused": "Ön plandayken", + "localApps.focusedLimit": "Bridge bu sistemde odağı algılayamıyorsa bu mod etkinleşmez. Bunun yerine Çalışırken seçeneğini seçin.", + "localApps.game": "Oyun", + "localApps.app": "Uygulama", + "localApps.gameHint": "Hedef çalışırken bu profili kullan.", + "localApps.appHint": "Bu profili yalnızca uygulama ön plandayken kullan.", + "localApps.privacy": "Bridge tarafından bu bilgisayara kaydedilir. OpenMouse’a hiçbir şey gönderilmez.", + "localApps.manualHint": "Windows .exe, macOS .app veya Linux çalıştırılabilir dosyasının tam yolunu girin. Betikler ve başlatıcılar desteklenmez.", + "localApps.save": "Yerel olarak kaydet", + "localApps.saving": "Kaydediliyor…", + "localApps.edit": "Düzenle", + "localApps.remove": "Kaldır", + "localApps.removeConfirm": "{name} ve kayıtlı fare profilleri bu bilgisayardan kaldırılsın mı?", + "localApps.invalid": "Bir ad ve desteklenen bir uygulamanın tam yolunu girin.", + "localApps.failed": "Yerel uygulamalar kaydedilemedi. Bridge’i kontrol edip tekrar deneyin.", + "localApps.loadFailed": "Uygulamalar Bridge’den yüklenemedi. Tekrar deneyin.", + "localApps.updateBridge": "Yerel oyun ve uygulama eklemek için OpenMouse Bridge’i güncelleyin.", + "localApps.bridgeRequired": "Yerel oyun ve uygulamaları yönetmek için OpenMouse Bridge’i çalıştırın.", + "localApps.empty": "İstediğiniz oyunu veya uygulamayı ekleyin. Talep veya onay gerekmez.", + "localApps.loading": "Yerel uygulamalar yükleniyor…", + "localApps.openProfile": "Profili yapılandır", + "localApps.duplicate": "Bu çalıştırılabilir dosya veya yol zaten yerel uygulamalarınızda var.", + "localApps.confirmRemove": "Yerel olarak kaldır", + "localApps.cancel": "İptal", "bridge.profileEnabled": "Otomatik uygulama açıldı", "bridge.profileEnabledDetail": "Bridge, {name} açıldığında bu profili farenize uygular.", "bridge.profileUpdated": "Profil güncellendi", diff --git a/src/i18n/locales/vi.ts b/src/i18n/locales/vi.ts index d5e89d0a..30700e46 100644 --- a/src/i18n/locales/vi.ts +++ b/src/i18n/locales/vi.ts @@ -38,16 +38,52 @@ export const vi: Record = { "side.connectedDevices": "THIẾT BỊ ĐÃ KẾT NỐI", "bridge.title": "OpenMouse Bridge", "bridge.connected": "Đã kết nối", - "games.subtitle": "Chọn một trò chơi để đặt cài đặt chuột riêng — chúng được áp dụng ngay khi trò chơi khởi động.", + "games.subtitle": "Chọn cấu hình có sẵn hoặc thêm trò chơi và ứng dụng của riêng bạn để lưu cục bộ trong Bridge.", "games.autoApply": "Tự động", "games.needsBridgeTitle": "Hồ sơ trò chơi cần OpenMouse Bridge", "games.needsBridgeBody": "Bridge chạy cạnh trình duyệt, nhận biết khi trò chơi bắt đầu và chuyển chuột sang cài đặt của trò chơi đó. Hãy khởi động Bridge hoặc tải về để tạo hồ sơ.", "games.settings": "Cài đặt", - "games.settingsHint": "Chỉ lưu vào hồ sơ của trò chơi này. Chuột giữ cài đặt hiện tại cho đến khi {name} khởi động và trở lại như cũ khi trò chơi đóng.", + "games.settingsHint": "Chỉ lưu vào hồ sơ của {name}. Các cài đặt này được áp dụng khi hồ sơ đang hoạt động. Cài đặt trước đó sẽ được khôi phục khi hồ sơ ngừng hoạt động.", "games.changedCount": "Cài đặt riêng cho trò chơi này: {n}", "games.noChanges": "Dùng cài đặt chuột hiện tại", "games.connectTarget": "Kết nối {name} để chỉnh cài đặt của nó cho trò chơi này.", "games.noTarget": "Chọn thiết bị đích để chỉnh cài đặt của trò chơi này.", + "localApps.title": "Trò chơi và ứng dụng", + "localApps.presets": "Thiết lập sẵn của OpenMouse", + "localApps.yours": "Ứng dụng của bạn", + "localApps.add": "Thêm trò chơi hoặc ứng dụng", + "localApps.addTitle": "Thêm trò chơi hoặc ứng dụng cục bộ", + "localApps.editTitle": "Sửa ứng dụng cục bộ", + "localApps.local": "Cục bộ", + "localApps.name": "Tên", + "localApps.target": "Đường dẫn đầy đủ của ứng dụng", + "localApps.kind": "Loại", + "localApps.activation": "Kích hoạt", + "localApps.running": "Khi đang chạy", + "localApps.focused": "Khi ở phía trước", + "localApps.focusedLimit": "Nếu Bridge không thể phát hiện tiêu điểm trên hệ thống này, chế độ này sẽ không hoạt động. Hãy chọn Khi đang chạy.", + "localApps.game": "Trò chơi", + "localApps.app": "Ứng dụng", + "localApps.gameHint": "Dùng hồ sơ này khi ứng dụng đích đang chạy.", + "localApps.appHint": "Chỉ dùng hồ sơ này khi ứng dụng ở phía trước.", + "localApps.privacy": "Được Bridge lưu trên máy tính này. Không có gì được gửi đến OpenMouse.", + "localApps.manualHint": "Nhập đường dẫn đầy đủ đến .exe trên Windows, .app trên macOS hoặc tệp thực thi Linux. Không hỗ trợ tập lệnh và trình khởi chạy.", + "localApps.save": "Lưu cục bộ", + "localApps.saving": "Đang lưu…", + "localApps.edit": "Sửa", + "localApps.remove": "Xóa", + "localApps.removeConfirm": "Xóa {name} và các hồ sơ chuột đã lưu của ứng dụng này khỏi máy tính này?", + "localApps.invalid": "Nhập tên và đường dẫn đầy đủ đến ứng dụng được hỗ trợ.", + "localApps.failed": "Không thể lưu ứng dụng cục bộ. Kiểm tra Bridge rồi thử lại.", + "localApps.loadFailed": "Không thể tải ứng dụng từ Bridge. Hãy thử lại.", + "localApps.updateBridge": "Cập nhật OpenMouse Bridge để thêm trò chơi và ứng dụng cục bộ.", + "localApps.bridgeRequired": "Chạy OpenMouse Bridge để quản lý trò chơi và ứng dụng cục bộ.", + "localApps.empty": "Thêm bất kỳ trò chơi hoặc ứng dụng nào. Không cần gửi yêu cầu hay chờ phê duyệt.", + "localApps.loading": "Đang tải ứng dụng cục bộ…", + "localApps.openProfile": "Cấu hình hồ sơ", + "localApps.duplicate": "Tệp thực thi hoặc đường dẫn này đã có trong danh sách ứng dụng cục bộ của bạn.", + "localApps.confirmRemove": "Xóa cục bộ", + "localApps.cancel": "Hủy", "bridge.profileEnabled": "Đã bật áp dụng tự động", "bridge.profileEnabledDetail": "Bridge sẽ đẩy hồ sơ này đến chuột của bạn ngay khi {name} khởi chạy.", "bridge.profileUpdated": "Đã cập nhật hồ sơ", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 925c43fc..03da23a6 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -38,16 +38,52 @@ export const zh: Record = { "side.connectedDevices": "已连接设备", "bridge.title": "OpenMouse Bridge", "bridge.connected": "已连接", - "games.subtitle": "选择一个游戏为其设置专属鼠标设置——游戏启动时立即应用。", + "games.subtitle": "选择预设,或添加自己的游戏和应用,并将它们保存在本地 Bridge 中。", "games.autoApply": "自动", "games.needsBridgeTitle": "游戏配置需要 OpenMouse Bridge", "games.needsBridgeBody": "Bridge 在浏览器旁运行,检测到游戏启动时会把鼠标切换到该游戏的设置。启动或下载 Bridge 即可创建配置。", "games.settings": "设置", - "games.settingsHint": "仅保存到此游戏的配置中。在 {name} 启动前,鼠标保持当前设置,游戏关闭后恢复原样。", + "games.settingsHint": "仅保存到 {name} 的配置中。此配置启用时会应用这些设置,停用后将恢复之前的设置。", "games.changedCount": "此游戏已设置 {n} 项", "games.noChanges": "使用当前鼠标设置", "games.connectTarget": "连接 {name} 以编辑它在此游戏中的设置。", "games.noTarget": "选择目标设备以编辑此游戏的设置。", + "localApps.title": "游戏与应用", + "localApps.presets": "OpenMouse 预设", + "localApps.yours": "你的应用", + "localApps.add": "添加游戏或应用", + "localApps.addTitle": "添加本地游戏或应用", + "localApps.editTitle": "编辑本地应用", + "localApps.local": "本地", + "localApps.name": "名称", + "localApps.target": "应用完整路径", + "localApps.kind": "类型", + "localApps.activation": "激活方式", + "localApps.running": "运行时", + "localApps.focused": "位于前台时", + "localApps.focusedLimit": "如果 Bridge 无法在此系统上检测焦点,此模式将保持未激活状态。请改选“运行时”。", + "localApps.game": "游戏", + "localApps.app": "应用", + "localApps.gameHint": "在目标运行时使用此配置。", + "localApps.appHint": "仅在应用位于前台时使用此配置。", + "localApps.privacy": "由 Bridge 保存在此计算机上,不会向 OpenMouse 提交任何内容。", + "localApps.manualHint": "输入 Windows .exe、macOS .app 或 Linux 可执行文件的完整路径。不支持脚本和启动器。", + "localApps.save": "保存到本地", + "localApps.saving": "正在保存…", + "localApps.edit": "编辑", + "localApps.remove": "移除", + "localApps.removeConfirm": "要从此计算机移除 {name} 及其已保存的鼠标配置吗?", + "localApps.invalid": "请输入名称和受支持应用的完整路径。", + "localApps.failed": "无法保存本地应用。请检查 Bridge 后重试。", + "localApps.loadFailed": "无法从 Bridge 加载应用。请重试。", + "localApps.updateBridge": "请更新 OpenMouse Bridge 以添加本地游戏和应用。", + "localApps.bridgeRequired": "请运行 OpenMouse Bridge 以管理本地游戏和应用。", + "localApps.empty": "添加任意游戏或应用,无需申请或审批。", + "localApps.loading": "正在加载本地应用…", + "localApps.openProfile": "设置配置", + "localApps.duplicate": "此可执行文件或路径已在你的本地应用中。", + "localApps.confirmRemove": "从本地移除", + "localApps.cancel": "取消", "bridge.profileEnabled": "已开启自动应用", "bridge.profileEnabledDetail": "一旦检测到 {name} 启动,Bridge 就会将此配置推送到你的鼠标。", "bridge.profileUpdated": "配置已更新", diff --git a/src/shared/AsciiField.tsx b/src/shared/AsciiField.tsx new file mode 100644 index 00000000..f49f5517 --- /dev/null +++ b/src/shared/AsciiField.tsx @@ -0,0 +1,44 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { asciiRowTiming, asciiSessionSeed, asciiSweepRuns, nextAsciiField } from "./ascii"; + +function prefersReducedMotion(): boolean { + try { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; + } catch { + return false; + } +} + +export function AsciiField({ theme, reducedMotion }: { theme: string; reducedMotion: boolean }): ReactNode { + const seed = asciiSessionSeed(); + const [field, setField] = useState(() => nextAsciiField(null, window.innerWidth, seed)); + + useEffect(() => { + let frame = 0; + const onResize = (): void => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => setField((previous) => nextAsciiField(previous, window.innerWidth, seed))); + }; + window.addEventListener("resize", onResize); + return () => { + window.removeEventListener("resize", onResize); + cancelAnimationFrame(frame); + }; + }, [seed]); + + const sweeping = asciiSweepRuns({ reducedMotion, prefersReducedMotion: prefersReducedMotion() }); + return ( + + ); +} diff --git a/src/shared/ascii-golden.json b/src/shared/ascii-golden.json new file mode 100644 index 00000000..ea6d8f56 --- /dev/null +++ b/src/shared/ascii-golden.json @@ -0,0 +1,593 @@ +{ + "rows": 40, + "cases": [ + { + "cols": 24, + "seed": [ + 0.0, + 0.0 + ], + "lines": [ + " .::--==++***", + " ...:--==++=", + " ....::--==", + " ..:::::", + " . .", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ] + }, + { + "cols": 60, + "seed": [ + 0.3100000023841858, + 0.7699999809265137 + ], + "lines": [ + " ....::::::---==+++***+***####%##", + " .::::::::--===++++++***#*#####", + " ..::::::::--====++*******+**##", + " ..:..::-----====+++******###", + " ......:::::------==++**###*#*", + " . ...:.::-::---=-=+++++**+***", + " . ..::::------==+=+====+++", + " .....::::-----------=--=", + " ...::::-::::::--::---", + " . .....:.::::::-----:-", + " ......:.::::::::", + " . ............:.", + " .. .. ...", + " . .", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ] + }, + { + "cols": 91, + "seed": [ + 1365.8900146484375, + 723.4299926757812 + ], + "lines": [ + " .......:::::-------=====+++**####%%%%%%%%%%%%@@@", + " ......:::-:------=======+***######%#%%%@@@@@@@@", + " . ......:::-:-----======+++**#########%%%@@@@@%#", + " .....:.:::::-----=-=---==+++****####%%%%@@@@%%%", + " . ...::::::::::--------===++****#####%%@@%%%%#", + " . . ....:::::::::--------==++++*****####%%%%###", + " ......:::::::--:------==++++++*+************#", + " .......::::-:::---=-=====+++++++**+++++++##", + " . .........:::-:------=====+++=+==+++=+==++**", + " . .....:::::-:::-:---=-===========--===++*", + " . .....::::::::::::---=-=---------=---=+=", + " ....:.:::::::::::-------------=-=====", + " . .. ......:..::::::::-:----=========--", + " .. .....:.::::.::::::----========--", + " ............:.:::.::::-------=----", + " . .........::.:::::::---------:", + " . ............::.::-:----:-", + " . ...............:::::::", + " . . ... .:.:::..:", + " .. .........", + " .. .", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ] + }, + { + "cols": 104, + "seed": [ + 512.5, + 12.25 + ], + "lines": [ + " ......:::::::-::----==++++************###%%%%%%%#####%", + " .....:.:::::------====++=+++++*+****##*########%%%%%%%", + " .......:::::----===++++++++++=+++***********##%%%@%%%", + " . . ....:.::::-:--===+=+=======++++***********###%%%%%%", + " ..........::--:---==-========+=++=+++****####%%%%%%@@@", + " ........:::------------===========+++*###%%%%@@@@@@@", + " . .......::::----------=--===========++**#%%%%%%%%%@%%", + " . ....:.:::::-------========+====+++*****##*##*#*##", + " ......::.::::-:--=-========+======++++**+**+**++***", + " ........:.:::::----=-=---==========++++++*++++++=+*", + " ......::::::--------------=====++==++++++++=+=+", + " . ....:::::::----:-:-:---=+=+++=====+=+======+", + " ... ....::::::::-:::::---=====================", + " .......::::::-::::::::----==-===-==--=-==-=-=", + " .........::::::::.:::::::---:-----=--------=", + " ........::::::.:::::::::---:-:--:-----:-=", + " . ............::.::::----:-:-:::--:-::--", + " . ...........:.::::-::::::.:::::::::-", + " .. . . .....:::::::::...::::::::::", + " .. . .......::.:..::.:::::::", + " . ........::...:.:.:..::", + " .. ...............:.", + " ..... . . . ..", + " . .", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ] + }, + { + "cols": 104, + "seed": [ + 3.700000047683716, + 690.0999755859375 + ], + "lines": [ + " . ....::::-----====+=++=====+**##%%%##**+**#%@@@@@@@@", + " .....::::::-----===+==++=+++*+***###########%%@@@@@@@@", + " ...:.::::::-----=====++++***********###%%%%%@@@%%@@@@", + " ....::::::-:-:--=====++++*******+***##%%%@%%@%#%%%%@@", + " ........::::--:------==++++*+****+***##%%%%%%%%####%%@", + " ..::.:.:::::::-:::----==++++*+****####%##%%####%%###", + " . .......:::::::::::---=====+++++******#############**", + " ......:...:::-:--::--======++++++**+******#*####*#*", + " . ....:.::::::---------=--====++=++++++++**#####**", + " ...........:::-----------=-=======+=====++**###***", + " .. .....:::::---:-:-----===+=====--===+******+*", + " . . .....:::::::::::---===+=++==-=====+++++++*", + " .. ....:..:::::::::::-===+====-----===+++++++", + " .........::.::.:..:::---=-==--=------===+===+", + " .........:.:::.::..::::::---:-----=-==---===", + " ........::.....:..::::::---:-----------==", + " . .. .........::.:::::::::---:--------==", + " . ... ...:..::::::::::::::::::-------", + " ...:::..::::::..:..::::-------", + " .......:........:..:::::::-::", + " . ................:.:.:::::", + " . .. ...............:.", + " ..... . .......", + " . . . .", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ] + } + ], + "seeds": [ + { + "n": 0, + "seed": [ + 0.0, + 0.0 + ] + }, + { + "n": 1, + "seed": [ + 1.3700000047683716, + 0.0 + ] + }, + { + "n": 996, + "seed": [ + 1364.52001953125, + 0.0 + ] + }, + { + "n": 997, + "seed": [ + 0.0, + 0.7300000190734863 + ] + }, + { + "n": 123456789, + "seed": [ + 374.010009765625, + 689.1199951171875 + ] + }, + { + "n": 4294967295, + "seed": [ + 1322.050048828125, + 9.489999771118164 + ] + } + ], + "columns": [ + { + "width": 0.0, + "cols": 24 + }, + { + "width": 320.0, + "cols": 24 + }, + { + "width": 900.0, + "cols": 57 + }, + { + "width": 1440.0, + "cols": 91 + }, + { + "width": 1633.0, + "cols": 103 + }, + { + "width": 1920.0, + "cols": 104 + }, + { + "width": 2560.0, + "cols": 104 + } + ], + "alpha": [ + { + "position": 0.012500000186264515, + "alpha": 0.23999999463558197 + }, + { + "position": 0.03750000149011612, + "alpha": 0.23999999463558197 + }, + { + "position": 0.0625, + "alpha": 0.23999999463558197 + }, + { + "position": 0.08749999850988388, + "alpha": 0.23999999463558197 + }, + { + "position": 0.11249999701976776, + "alpha": 0.23999999463558197 + }, + { + "position": 0.13750000298023224, + "alpha": 0.23999999463558197 + }, + { + "position": 0.16249999403953552, + "alpha": 0.23999999463558197 + }, + { + "position": 0.1875, + "alpha": 0.23999999463558197 + }, + { + "position": 0.21250000596046448, + "alpha": 0.23999999463558197 + }, + { + "position": 0.23749999701976776, + "alpha": 0.23999999463558197 + }, + { + "position": 0.26249998807907104, + "alpha": 0.23999999463558197 + }, + { + "position": 0.2874999940395355, + "alpha": 0.23999999463558197 + }, + { + "position": 0.3125, + "alpha": 0.23999999463558197 + }, + { + "position": 0.3375000059604645, + "alpha": 0.23999999463558197 + }, + { + "position": 0.36250001192092896, + "alpha": 0.23999999463558197 + }, + { + "position": 0.38749998807907104, + "alpha": 0.23999999463558197 + }, + { + "position": 0.4124999940395355, + "alpha": 0.23999999463558197 + }, + { + "position": 0.4375, + "alpha": 0.23999999463558197 + }, + { + "position": 0.4625000059604645, + "alpha": 0.23999999463558197 + }, + { + "position": 0.48750001192092896, + "alpha": 0.23999999463558197 + }, + { + "position": 0.512499988079071, + "alpha": 0.23999999463558197 + }, + { + "position": 0.5375000238418579, + "alpha": 0.23999999463558197 + }, + { + "position": 0.5625, + "alpha": 0.2394547313451767 + }, + { + "position": 0.5874999761581421, + "alpha": 0.23527778685092926 + }, + { + "position": 0.612500011920929, + "alpha": 0.2273971140384674 + }, + { + "position": 0.637499988079071, + "alpha": 0.21630659699440002 + }, + { + "position": 0.6625000238418579, + "alpha": 0.20249998569488525 + }, + { + "position": 0.6875, + "alpha": 0.18647119402885437 + }, + { + "position": 0.7124999761581421, + "alpha": 0.1687140166759491 + }, + { + "position": 0.737500011920929, + "alpha": 0.14972220361232758 + }, + { + "position": 0.762499988079071, + "alpha": 0.12998971343040466 + }, + { + "position": 0.7875000238418579, + "alpha": 0.11001026630401611 + }, + { + "position": 0.8125, + "alpha": 0.0902777761220932 + }, + { + "position": 0.8374999761581421, + "alpha": 0.07128602266311646 + }, + { + "position": 0.862500011920929, + "alpha": 0.053528789430856705 + }, + { + "position": 0.887499988079071, + "alpha": 0.037500008940696716 + }, + { + "position": 0.9125000238418579, + "alpha": 0.023693405091762543 + }, + { + "position": 0.9375, + "alpha": 0.012602882459759712 + }, + { + "position": 0.9624999761581421, + "alpha": 0.0047222282737493515 + }, + { + "position": 0.987500011920929, + "alpha": 0.0005452664336189628 + } + ], + "sweep": [ + { + "delta": 0.0, + "position": 0.012500000186264515, + "offset": 220.0 + }, + { + "delta": 0.0, + "position": 0.5, + "offset": 220.0 + }, + { + "delta": 0.0, + "position": 0.987500011920929, + "offset": 220.0 + }, + { + "delta": 0.10000000149011612, + "position": 0.012500000186264515, + "offset": 109.73204803466797 + }, + { + "delta": 0.10000000149011612, + "position": 0.5, + "offset": 220.0 + }, + { + "delta": 0.10000000149011612, + "position": 0.987500011920929, + "offset": 220.0 + }, + { + "delta": 0.30000001192092896, + "position": 0.012500000186264515, + "offset": 9.32064437866211 + }, + { + "delta": 0.30000001192092896, + "position": 0.5, + "offset": 185.33262634277344 + }, + { + "delta": 0.30000001192092896, + "position": 0.987500011920929, + "offset": 220.0 + }, + { + "delta": 0.550000011920929, + "position": 0.012500000186264515, + "offset": 0.0 + }, + { + "delta": 0.550000011920929, + "position": 0.5, + "offset": 12.938946723937988 + }, + { + "delta": 0.550000011920929, + "position": 0.987500011920929, + "offset": 210.06996154785156 + }, + { + "delta": 0.699999988079071, + "position": 0.012500000186264515, + "offset": 0.0 + }, + { + "delta": 0.699999988079071, + "position": 0.5, + "offset": 0.03772616386413574 + }, + { + "delta": 0.699999988079071, + "position": 0.987500011920929, + "offset": 60.805660247802734 + }, + { + "delta": 0.8999999761581421, + "position": 0.012500000186264515, + "offset": 0.0 + }, + { + "delta": 0.8999999761581421, + "position": 0.5, + "offset": 0.0 + }, + { + "delta": 0.8999999761581421, + "position": 0.987500011920929, + "offset": 1.949775218963623 + }, + { + "delta": 1.0, + "position": 0.012500000186264515, + "offset": 0.0 + }, + { + "delta": 1.0, + "position": 0.5, + "offset": 0.0 + }, + { + "delta": 1.0, + "position": 0.987500011920929, + "offset": 0.0 + } + ] +} \ No newline at end of file diff --git a/src/shared/ascii.test.ts b/src/shared/ascii.test.ts new file mode 100644 index 00000000..b19cb36f --- /dev/null +++ b/src/shared/ascii.test.ts @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { + ASCII_ROWS, + asciiColumns, + asciiGlowRows, + asciiRowAlpha, + asciiRowTiming, + asciiSeedFrom, + asciiSessionSeed, + asciiSweepOffset, + asciiSweepRuns, + nextAsciiField, +} from "./ascii.ts"; + +type Golden = { + rows: number; + cases: Array<{ cols: number; seed: [number, number]; lines: string[] }>; + seeds: Array<{ n: number; seed: [number, number] }>; + columns: Array<{ width: number; cols: number }>; + alpha: Array<{ position: number; alpha: number }>; + sweep: Array<{ delta: number; position: number; offset: number }>; +}; + +const golden = JSON.parse(readFileSync(new URL("./ascii-golden.json", import.meta.url), "utf8")) as Golden; +const f32 = Math.fround; + +test("the field matches the native Rust output line for line", () => { + assert.equal(golden.rows, ASCII_ROWS); + for (const sample of golden.cases) { + const seed: [number, number] = [f32(sample.seed[0]), f32(sample.seed[1])]; + assert.deepEqual(asciiGlowRows(sample.cols, ASCII_ROWS, seed), sample.lines, `cols ${sample.cols} seed ${seed}`); + } +}); + +test("seeds, columns, fade and sweep match the native recipe", () => { + for (const { n, seed } of golden.seeds) { + assert.deepEqual(asciiSeedFrom(n), [f32(seed[0]), f32(seed[1])], `seed ${n}`); + } + for (const { width, cols } of golden.columns) assert.equal(asciiColumns(width), cols, `width ${width}`); + for (const { position, alpha } of golden.alpha) assert.equal(asciiRowAlpha(f32(position)), f32(alpha)); + for (const { delta, position, offset } of golden.sweep) { + assert.equal(asciiSweepOffset(f32(delta), f32(position)), f32(offset), `delta ${delta} position ${position}`); + } +}); + +test("the seed is chosen once per page session", () => { + const first = asciiSessionSeed(); + assert.deepEqual(asciiSessionSeed(), first); + assert.ok(Number.isFinite(first[0]) && Number.isFinite(first[1])); +}); + +test("rows regenerate only when the column count changes", () => { + const seed: [number, number] = [f32(0.31), f32(0.77)]; + const a = nextAsciiField(null, 1440, seed); + assert.equal(a.cols, 91); + assert.equal(a.rows.length, ASCII_ROWS); + assert.equal(nextAsciiField(a, 1441, seed), a, "same column count keeps the same field"); + const b = nextAsciiField(a, 1920, seed); + assert.notEqual(b, a); + assert.equal(b.cols, 104); + assert.deepEqual(b.rows, asciiGlowRows(104, ASCII_ROWS, seed)); +}); + +test("each row sweeps in once, top rows first, inside 520 ms", () => { + const top = asciiRowTiming(0); + const bottom = asciiRowTiming(ASCII_ROWS - 1); + assert.ok(top.delayMs < bottom.delayMs); + assert.ok(Math.abs(top.durationMs - 0.45 * 520) < 1e-9); + assert.ok(bottom.delayMs + bottom.durationMs <= 520 + 1e-9); + assert.ok(Math.abs(top.alpha - asciiRowAlpha(f32(0.5 / ASCII_ROWS))) < 1e-9); +}); + +test("reduced motion and the animations preference stop the sweep", () => { + assert.equal(asciiSweepRuns({ reducedMotion: false, prefersReducedMotion: false }), true); + assert.equal(asciiSweepRuns({ reducedMotion: true, prefersReducedMotion: false }), false); + assert.equal(asciiSweepRuns({ reducedMotion: false, prefersReducedMotion: true }), false); +}); + +test("OS reduced motion can override the sweep without a component rerender", () => { + const css = readFileSync(new URL("../styles/shell.css", import.meta.url), "utf8"); + const selector = css.match(/([^\n{}]+)\{\s*animation:\s*om-ascii-sweep/)?.[1]; + assert.ok(selector, "sweep rule"); + const specificity = (selector.replace(/:where\([^)]*\)/g, "").match(/\.[\w-]+/g) ?? []).length; + assert.ok(specificity <= 1, "the later media rule must win the cascade"); +}); + +test("the rendered sweep uses the native cubic curve", () => { + const css = readFileSync(new URL("../styles/shell.css", import.meta.url), "utf8"); + const curve = css.match(/animation:\s*om-ascii-sweep\s+234ms\s+cubic-bezier\(([^)]+)\)/); + assert.ok(curve, "one-shot row sweep"); + const [x1, y1, x2, y2] = curve[1]!.split(",").map(Number); + for (const t of [0.1, 0.25, 0.5, 0.75, 0.9]) { + const rest = 1 - t; + const x = 3 * rest * rest * t * x1! + 3 * rest * t * t * x2! + t * t * t; + const y = 3 * rest * rest * t * y1! + 3 * rest * t * t * y2! + t * t * t; + assert.ok(Math.abs(x - t) < 1e-11, `native linear time at ${t}: ${x}`); + assert.ok(Math.abs(y - (1 - rest ** 3)) < 1e-11, `native ease-out at ${t}: ${y}`); + } +}); diff --git a/src/shared/ascii.ts b/src/shared/ascii.ts new file mode 100644 index 00000000..a52c9223 --- /dev/null +++ b/src/shared/ascii.ts @@ -0,0 +1,134 @@ +const f32 = Math.fround; + +export const ASCII_RAMP = " .:-=+*#%@"; +export const ASCII_ROWS = 40; +export const ASCII_MIN_COLS = 24; +export const ASCII_MAX_COLS = 104; +export const ASCII_FONT_PX = 11; +export const ASCII_LINE = 1.4; +export const ASCII_SWEEP_MS = 520; +export const ASCII_SWEEP_OFFSET = 220; +export const ASCII_SWEEP_STAGGER = 0.55; + +const SHARE = f32(0.42); +const CELL_WIDTH = f32(f32(ASCII_FONT_PX) * f32(0.6)); +const ALPHA = f32(0.24); +const FADE_SPAN = f32(1 - f32(0.55)); +const STAGGER = f32(ASCII_SWEEP_STAGGER); +const SWEEP_SPAN = f32(1 - STAGGER); +const CELL_ASPECT = f32(0.43); +const ORIGIN_X = f32(1.02); +const ORIGIN_Y = f32(-0.04); +const REACH = f32(0.62); +const GLOW_POWER = f32(1.1); +const GRID_COLS = f32(14); + +export type AsciiSeed = readonly [number, number]; +export type AsciiField = { cols: number; seed: AsciiSeed; rows: string[] }; + +function lattice(x: number, y: number): number { + let h = (Math.imul(x | 0, 0x9e3779b1) ^ Math.imul(y | 0, 0x85ebca77)) >>> 0; + h = (h ^ (h >>> 15)) >>> 0; + h = Math.imul(h, 0x2c1b3c6d) >>> 0; + h = (h ^ (h >>> 12)) >>> 0; + h = Math.imul(h, 0x297a2d39) >>> 0; + h = (h ^ (h >>> 15)) >>> 0; + return f32((h & 0xffff) / 65535); +} + +function smooth(t: number): number { + return f32(f32(t * t) * f32(3 - f32(2 * t))); +} + +function lerp(a: number, b: number, t: number): number { + return f32(a + f32(f32(b - a) * t)); +} + +function valueNoise(x: number, y: number): number { + const xf = Math.floor(x); + const yf = Math.floor(y); + const fx = smooth(f32(x - xf)); + const fy = smooth(f32(y - yf)); + const top = lerp(lattice(xf, yf), lattice(xf + 1, yf), fx); + const bottom = lerp(lattice(xf, yf + 1), lattice(xf + 1, yf + 1), fx); + return lerp(top, bottom, fy); +} + +export function asciiGlowRows(cols: number, rows: number, seed: AsciiSeed): string[] { + const len = ASCII_RAMP.length; + const gridRows = Math.max(2, Math.round(f32(f32(GRID_COLS * rows) / f32(Math.max(cols, 1) * CELL_ASPECT)))); + const ox = f32(ORIGIN_X * cols); + const oy = f32(ORIGIN_Y * rows); + const radius = f32(f32(REACH * cols) * CELL_ASPECT); + const lines: string[] = []; + for (let y = 0; y < rows; y++) { + let line = ""; + for (let x = 0; x < cols; x++) { + const d = f32(f32(Math.hypot(f32(f32(x - ox) * CELL_ASPECT), f32(y - oy))) / radius); + const glow = f32(Math.pow(Math.max(f32(1 - d), 0), GLOW_POWER)); + const noise = valueNoise( + f32(f32(f32(x / cols) * GRID_COLS) + seed[0]), + f32(f32(f32(y / rows) * gridRows) + seed[1]), + ); + const jitter = f32(f32(lattice(x + 7919, y - 104_729) - 0.5) * f32(0.06)); + const level = f32(f32(glow * f32(f32(0.6) + f32(f32(0.4) * noise))) + jitter); + const index = Math.min(Math.max(Math.floor(f32(f32(level * len) * f32(1.35))), 0), len - 1); + line += ASCII_RAMP[index]; + } + lines.push(line.trimEnd()); + } + return lines; +} + +export function asciiColumns(viewportWidth: number): number { + const raw = Math.trunc(f32(f32(f32(viewportWidth) * SHARE) / CELL_WIDTH)); + return Math.min(Math.max(Number.isFinite(raw) ? Math.max(raw, 0) : 0, ASCII_MIN_COLS), ASCII_MAX_COLS); +} + +export function asciiRowAlpha(position: number): number { + const s = Math.min(Math.max(f32(f32(1 - position) / FADE_SPAN), 0), 1); + return f32(f32(f32(ALPHA * s) * s) * f32(3 - f32(2 * s))); +} + +export function asciiSweepOffset(delta: number, position: number): number { + const local = Math.min(Math.max(f32(f32(delta - f32(position * STAGGER)) / SWEEP_SPAN), 0), 1); + const rest = f32(1 - local); + const eased = f32(1 - f32(f32(rest * rest) * rest)); + return f32(f32(1 - eased) * ASCII_SWEEP_OFFSET); +} + +export function asciiSeedFrom(nanos: number): [number, number] { + const n = nanos >>> 0; + return [f32(f32(n % 997) * f32(1.37)), f32(f32(Math.floor(n / 997) % 991) * f32(0.73))]; +} + +let sessionSeed: [number, number] | null = null; + +export function asciiSessionSeed(): [number, number] { + if (!sessionSeed) { + const bits = new Uint32Array(1); + globalThis.crypto.getRandomValues(bits); + sessionSeed = asciiSeedFrom(bits[0]!); + } + return sessionSeed; +} + +export function nextAsciiField(previous: AsciiField | null, viewportWidth: number, seed: AsciiSeed): AsciiField { + const cols = asciiColumns(viewportWidth); + if (previous && previous.cols === cols && previous.seed === seed) return previous; + return { cols, seed, rows: asciiGlowRows(cols, ASCII_ROWS, seed) }; +} + +export function asciiRowTiming(index: number): { position: number; alpha: number; delayMs: number; durationMs: number } { + const position = f32(f32(index + 0.5) / ASCII_ROWS); + return { + position, + alpha: asciiRowAlpha(position), + delayMs: position * ASCII_SWEEP_STAGGER * ASCII_SWEEP_MS, + durationMs: (1 - ASCII_SWEEP_STAGGER) * ASCII_SWEEP_MS, + }; +} + +export function asciiSweepRuns({ reducedMotion, prefersReducedMotion }: { reducedMotion: boolean; prefersReducedMotion: boolean }): boolean { + return !reducedMotion && !prefersReducedMotion; +} diff --git a/src/styles/areas/pages.css b/src/styles/areas/pages.css index a9cd2d1e..b71a3fe5 100644 --- a/src/styles/areas/pages.css +++ b/src/styles/areas/pages.css @@ -73,6 +73,19 @@ @keyframes hardware-test-spin { to { transform: rotate(360deg); } } @keyframes hardware-test-writing { from { transform: translateX(-100%); } to { transform: translateX(440%); } } +.games-section { display: grid; gap: 16px; min-width: 0; } +.games-section h2 { font-size: var(--fs-h2); font-weight: 600; } +.games-section-head, .games-local-guidance { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; } +.games-local-target { display: flex; flex-direction: column; min-width: 0; border-radius: var(--r-md); background: var(--sheen), var(--surface-1); box-shadow: var(--lit); } +.games-local-target .games-tile { height: auto; background: transparent; box-shadow: none; } +.games-local-target .games-tile:hover { background: var(--glint); } +.games-local-target .games-tile:focus-visible { box-shadow: var(--focus); } +.games-local-meta, .games-local-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 4px 14px; } +.games-local-meta { font-size: var(--fs-small); color: var(--text-2); } +.games-local-path { min-width: 0; overflow-wrap: anywhere; font-size: var(--fs-small); color: var(--text-3); } +.games-local-target > .games-local-path { padding: 4px 14px 12px; } +.games-local-actions { margin-top: auto; padding: 8px; border-top: 1px solid var(--line-1); } +.local-app-dialog .om-dialog-head h2 { min-width: 0; } .games-header { align-items: flex-start; } .games-subtitle { max-width: 65ch; } .games-bridge-status { flex: none; height: auto; min-height: 26px; padding-block: 4px; margin-top: 4px; } diff --git a/src/styles/controls.css b/src/styles/controls.css index a1a78016..2b0f095d 100644 --- a/src/styles/controls.css +++ b/src/styles/controls.css @@ -1,29 +1,29 @@ .om-panel { position: relative; - background: var(--sheen), var(--surface-1); + background: var(--sheen), linear-gradient(var(--surface-1), var(--surface-1)), var(--ink-1); box-shadow: var(--lit); border-radius: var(--r-md); min-width: 0; } .om-panel.is-lit { background: - radial-gradient(70% 90% at 100% 0%, rgb(var(--accent-rgb) / 0.07), transparent 62%), - var(--sheen), var(--surface-1); + linear-gradient(180deg, rgb(var(--accent-rgb) / 0.07), transparent 50%), + linear-gradient(var(--surface-1), var(--surface-1)), var(--ink-1); } .om-panel-head { display: flex; align-items: center; gap: 10px; min-height: 52px; - padding: 14px 18px 8px; + padding: 14px var(--panel-pad) 8px; } -.om-panel-head h2 { font-size: var(--fs-h2); font-weight: 600; } +.om-panel-head h2 { font-size: var(--fs-h2); font-weight: 600; letter-spacing: 0; } .om-panel-head .om-meta { color: var(--text-3); font-size: var(--fs-meta); } .om-panel-head .om-end { margin-inline-start: auto; display: flex; align-items: center; gap: 8px; } -.om-panel-body { padding: 6px 18px 18px; } +.om-panel-body { padding: 6px var(--panel-pad) var(--panel-pad); } .om-panel-foot { display: flex; align-items: center; gap: 8px; - padding: 12px 18px 14px; + padding: 12px var(--panel-pad) 14px; border-top: 1px solid var(--line-1); color: var(--text-3); font-size: var(--fs-meta); @@ -34,7 +34,7 @@ .om-btn, .connect-button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; - height: 32px; padding-inline: 13px; + height: var(--control-h); padding-inline: 13px; border-radius: var(--r-sm); background: var(--btn); box-shadow: var(--btn-shadow); @@ -95,15 +95,15 @@ .om-dot.is-bad { background: var(--bad); } .om-fig { - font-size: 28px; font-weight: 650; line-height: 1.05; - letter-spacing: -0.02em; + font-size: 28px; font-weight: 600; line-height: 1.05; + letter-spacing: 0; font-variant-numeric: tabular-nums; color: var(--text-1); } .om-fig .om-unit { margin-inline-start: 4px; color: var(--text-3); font-size: 13px; font-weight: 500; letter-spacing: 0; } .om-fig.is-sm { font-size: 18px; } -.om-kv { list-style: none; padding: 0 18px 8px; } +.om-kv { list-style: none; padding: 0 var(--panel-pad) 8px; } .om-kv > li { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 38px; @@ -113,7 +113,7 @@ .om-kv > li > span:first-child { color: var(--text-3); } .om-kv > li > :last-child { font-weight: 550; text-align: end; font-variant-numeric: tabular-nums; } -.om-empty { display: grid; gap: 6px; padding: 8px 18px 20px; } +.om-empty { display: grid; gap: 6px; padding: 8px var(--panel-pad) 20px; } .om-empty strong { font-weight: 600; } .om-empty p { color: var(--text-3); max-width: 52ch; } @@ -154,16 +154,16 @@ .switch-button { position: relative; display: inline-flex; align-items: center; - width: 38px; height: 22px; flex: none; + width: var(--switch-w); height: var(--switch-h); flex: none; padding: 2px; - border-radius: 11px; + border-radius: calc(var(--switch-h) / 2); background: var(--well); box-shadow: var(--inset-shadow); transition: background var(--t-base) var(--ease); } .switch-button .switch-thumb { display: grid; place-items: center; - width: 18px; height: 18px; + width: var(--switch-thumb); height: var(--switch-thumb); border-radius: 50%; background: var(--key); box-shadow: var(--key-shadow); @@ -180,7 +180,7 @@ .switch-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; - min-height: 40px; + min-height: var(--row-min); color: var(--text-1); font-weight: 500; } @@ -375,9 +375,9 @@ details[open].egg-collapsible > summary > i, details[open].egg-experimental > su position: relative; display: flex; flex-direction: column; gap: 12px; min-width: 0; - padding: 16px 18px 18px; + padding: var(--panel-pad); border-radius: var(--r-md); - background: var(--sheen), var(--surface-1); + background: var(--sheen), linear-gradient(var(--surface-1), var(--surface-1)), var(--ink-1); box-shadow: var(--lit); } .setting-card.is-staged { box-shadow: var(--lit), inset 0 0 0 1px var(--accent-line); } diff --git a/src/styles/device.css b/src/styles/device.css index 8f056473..9d4d8461 100644 --- a/src/styles/device.css +++ b/src/styles/device.css @@ -1,64 +1,59 @@ -.om-device-page { gap: 18px; } +.om-device-page { gap: var(--panel-gap); container: om-device / inline-size; } -.om-hero { +.om-device-layout { display: grid; - border-radius: var(--r-lg); - background: - radial-gradient(60% 120% at 0% 0%, rgb(var(--accent-rgb) / 0.07), transparent 60%), - var(--sheen), var(--surface-1); - box-shadow: var(--lit); + grid-template-columns: minmax(0, 1fr); + gap: var(--rail-gap); + align-items: start; } -.om-hero-top { - display: grid; - grid-template-columns: 176px minmax(0, 1fr) auto; - align-items: center; - gap: 24px; - padding: 16px 22px 16px 16px; +.om-device-rail { + display: grid; gap: 8px; align-content: start; justify-items: start; + min-width: 0; } -.om-hero-stage { - position: relative; - aspect-ratio: 1 / 1; - border-radius: var(--r-md); - background: var(--stage-light), var(--well); - box-shadow: var(--inset-shadow); +.om-device-name { font-size: var(--fs-title); font-weight: 600; letter-spacing: 0; line-height: 28px; overflow-wrap: anywhere; } +.om-device-status { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 14px; color: var(--text-2); font-size: var(--fs-connection); } +.om-device-fact { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; font-variant-numeric: tabular-nums; } +.om-device-fact > svg { width: 15px; height: 15px; color: var(--text-3); } +.om-device-art { position: relative; width: var(--rail-art-stacked); aspect-ratio: 1 / 1; margin-top: 12px; } +.om-device-art img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; } +.om-device-artreq { justify-self: start; } +.om-device-workspace { display: grid; gap: var(--panel-gap); min-width: 0; align-content: start; } +.om-device-workspace > .om-tabs { justify-self: start; } +@container om-device (min-width: 760px) { + .om-device-layout { grid-template-columns: var(--rail-w-medium) minmax(0, 1fr); } + .om-device-rail { position: sticky; top: var(--page-top); justify-items: stretch; } + .om-device-art { width: calc(100% - var(--rail-art-inset)); } } -.om-hero-stage img { - position: absolute; inset: 8%; - width: 84%; height: 84%; - object-fit: contain; - filter: drop-shadow(0 14px 18px rgb(var(--shade-rgb) / 0.5)); +@container om-device (min-width: 1000px) { + .om-device-layout { grid-template-columns: var(--rail-w) minmax(0, 1fr); } } -.om-hero-main { display: grid; gap: 8px; min-width: 0; align-content: center; } -.om-crumbs { display: flex; align-items: center; gap: 4px; color: var(--text-3); font-size: var(--fs-meta); } +.om-crumbs { display: flex; align-items: center; gap: 4px; color: var(--text-3); font-size: var(--fs-crumb); } .om-crumbs button { padding: 2px 4px; margin-inline-start: -4px; border-radius: var(--r-xs); color: var(--text-3); font-weight: 500; } .om-crumbs button:hover { color: var(--text-1); background: var(--glint); } .om-crumbs svg { width: 13px; height: 13px; color: var(--text-4); } [dir="rtl"] .om-crumbs svg { transform: scaleX(-1); } .om-crumbs span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.om-hero-name { font-size: 28px; font-weight: 650; letter-spacing: -0.025em; line-height: 1.1; overflow-wrap: anywhere; } -.om-hero-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; color: var(--text-2); font-size: 12.5px; } -.om-hero-meta .om-sep { color: var(--text-4); } -.om-hero .om-stats { margin-top: 6px; gap: 8px 18px; } -.om-hero .om-stat { font-size: 13px; } -.om-hero-actions { display: flex; align-items: flex-start; gap: 6px; align-self: start; } .om-tabs { - display: flex; flex-wrap: wrap; gap: 2px; - padding: 6px 8px; - border-top: 1px solid var(--line-1); + display: flex; flex-wrap: wrap; gap: var(--tab-gap); + min-height: var(--tab-well-h); padding: var(--tab-pad); + border-radius: var(--r-md); + background: linear-gradient(var(--well), var(--well)), var(--ink-1); + box-shadow: var(--inset-shadow); } .om-tab { display: inline-flex; align-items: center; gap: 8px; - height: 34px; padding-inline: 12px; + height: var(--tab-h); padding-inline: 12px; border-radius: var(--r-sm); color: var(--text-2); font-weight: 600; font-size: 12.5px; transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease); } -.om-tab svg { color: var(--text-3); } +.om-tab svg { width: 15px; height: 15px; color: var(--text-3); } .om-tab:hover { background: var(--glint); color: var(--text-1); } -.om-tab.is-active { background: var(--surface-3); color: var(--text-1); box-shadow: var(--lit); } +.om-tab.is-active { background: var(--key); color: var(--text-1); box-shadow: var(--key-shadow); } .om-tab.is-active svg { color: var(--accent); } +.om-tab:focus-visible { box-shadow: var(--focus); } .om-device-body { display: grid; gap: 16px; min-width: 0; animation: om-fade var(--t-base) var(--ease) both; } .device-data { min-width: 0; } @@ -68,14 +63,15 @@ .om-setup-figures { display: grid; grid-template-columns: repeat(var(--cols, 4), minmax(0, 1fr)); - padding: 6px 18px 18px; + padding: 6px var(--panel-pad) var(--panel-pad); } -.om-setup-figure { display: grid; gap: 6px; align-content: start; padding-inline: 18px; border-inline-start: 1px solid var(--line-1); } +.om-setup-figure { display: grid; gap: 6px; align-content: start; padding-inline: var(--panel-pad); border-inline-start: 1px solid var(--line-1); } .om-setup-figure:first-child { padding-inline-start: 0; border-inline-start: 0; } -.om-setup-figure .om-fig { font-size: 30px; } +.om-setup-figure .om-fig { font-size: var(--fs-figure); line-height: 32px; } +.om-setup-figure .om-fig .om-unit { line-height: 1; } .om-setup-stages { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; - padding: 14px 18px 16px; + padding: 14px var(--panel-pad) var(--panel-pad); border-top: 1px solid var(--line-1); } .om-setup-stage-list { display: flex; flex-wrap: wrap; gap: 6px; } @@ -87,7 +83,7 @@ .om-battery { display: flex; flex-direction: column; } .om-battery .om-end { gap: 8px; align-items: baseline; } -.om-battery-chart { display: grid; gap: 6px; padding: 4px 18px 16px; margin-top: auto; } +.om-battery-chart { display: grid; gap: 6px; padding: 4px var(--panel-pad) var(--panel-pad); margin-top: auto; } .om-battery-chart svg { width: 100%; height: 120px; overflow: visible; } .om-chart-guide { stroke: var(--line-1); stroke-width: 1; vector-effect: non-scaling-stroke; } .om-chart-area { fill: rgb(var(--accent-rgb) / 0.14); } @@ -124,11 +120,12 @@ .device-showcase-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); } .device-showcase-status-detail { display: inline-flex; align-items: center; gap: 4px; color: var(--text-3); } -@media (max-width: 1180px) { - .om-hero-top { grid-template-columns: 132px minmax(0, 1fr); } - .om-hero-actions { grid-column: 2; } +@container om-device (max-width: 560px) { .om-setup-figures { grid-template-columns: repeat(2, minmax(0, 1fr)); row-gap: 16px; } .om-setup-figure:nth-child(odd) { padding-inline-start: 0; border-inline-start: 0; } +} + +@media (max-width: 1180px) { .om-overview-grid { grid-template-columns: 1fr; } .performance-layout { grid-template-columns: minmax(0, 1fr); } .performance-sidebar { position: static; } diff --git a/src/styles/fonts.css b/src/styles/fonts.css new file mode 100644 index 00000000..8cb781d3 --- /dev/null +++ b/src/styles/fonts.css @@ -0,0 +1,7 @@ +@font-face { + font-family: "DM Sans"; + src: url("/fonts/DMSans-Variable.ttf") format("truetype"); + font-weight: 100 1000; + font-style: normal; + font-display: swap; +} diff --git a/src/styles/index.css b/src/styles/index.css index 1092564b..6eb7603a 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -1,3 +1,4 @@ +@import "./fonts.css"; @import "./tokens.css"; @import "./base.css"; @import "./controls.css"; diff --git a/src/styles/shell.css b/src/styles/shell.css index f6c43566..5b5de942 100644 --- a/src/styles/shell.css +++ b/src/styles/shell.css @@ -1,4 +1,5 @@ .om-shell { + position: relative; display: grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr); height: 100vh; @@ -7,6 +8,7 @@ color: var(--text-1); font-family: var(--font-sans); font-size: var(--fs-body); + font-variation-settings: var(--font-opsz); transition: grid-template-columns var(--t-slow) var(--ease); } .om-shell.sidebar-collapsed { grid-template-columns: var(--sidebar-w-collapsed) minmax(0, 1fr); } @@ -15,8 +17,9 @@ html, body { background: var(--ink-1); } .om-sidebar { display: flex; flex-direction: column; gap: 18px; + position: relative; z-index: 1; min-width: 0; height: 100%; - padding: 14px 10px 12px; + padding: 14px var(--sidebar-inline) 12px; overflow-x: hidden; overflow-y: auto; background: var(--ink-0); border-inline-end: 1px solid var(--line-1); @@ -32,17 +35,17 @@ html, body { background: var(--ink-1); } .om-nav { display: grid; gap: 2px; } .om-nav-item { display: flex; align-items: center; gap: 11px; - width: 100%; height: 36px; padding-inline: 10px; + width: 100%; height: var(--nav-h); padding-inline: var(--nav-inline); border-radius: var(--r-sm); color: var(--text-2); - font-size: 13px; font-weight: 550; + font-size: var(--fs-body); font-weight: 500; text-align: start; white-space: nowrap; transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease); } .om-nav-item:hover { background: var(--glint); color: var(--text-1); } .om-nav-item.is-active { background: var(--surface-2); color: var(--text-1); box-shadow: var(--lit); } -.om-nav-icon { width: 17px; height: 17px; color: var(--text-3); transition: color var(--t-fast) var(--ease); } +.om-nav-icon { width: var(--nav-icon); height: var(--nav-icon); color: var(--text-3); transition: color var(--t-fast) var(--ease); } .om-nav-item:hover .om-nav-icon { color: var(--text-2); } .om-nav-item.is-active .om-nav-icon { color: var(--accent); } .om-nav-label { overflow: hidden; text-overflow: ellipsis; } @@ -65,7 +68,7 @@ html, body { background: var(--ink-1); } display: grid; place-items: center; width: 36px; height: 36px; flex: none; border-radius: var(--r-sm); - background: var(--stage-light), var(--surface-3); + background: var(--surface-3); } .om-side-device-art img { width: 30px; height: 30px; object-fit: contain; } .om-side-device-live { @@ -127,16 +130,16 @@ html, body { background: var(--ink-1); } .om-sidebar.is-collapsed .om-bridge-line-flag { display: none; } .om-main { - position: relative; + position: relative; z-index: 1; display: flex; flex-direction: column; min-width: 0; height: 100%; overflow: hidden; } .om-content { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; scroll-behavior: smooth; } -.om-page { width: 100%; max-width: var(--content-w); margin-inline: auto; padding: 28px 36px 120px; display: grid; gap: 18px; align-content: start; } +.om-page { width: 100%; max-width: calc(var(--content-w) + 2 * var(--page-x)); margin-inline: auto; padding: var(--page-top) var(--page-x) 120px; display: grid; gap: var(--panel-gap); align-content: start; } .om-page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; padding-bottom: 2px; } -.om-page-head h1 { font-size: var(--fs-title); font-weight: 650; letter-spacing: -0.02em; } +.om-page-head h1 { font-size: var(--fs-title); font-weight: 600; letter-spacing: 0; } .om-page-head .om-meta { margin-top: 4px; } .news-banner { @@ -286,3 +289,19 @@ a.news-banner-link:hover { text-decoration-color: var(--text-2); } .om-nav-sub { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-3); font-size: var(--fs-small); font-weight: 500; } .om-sidebar.is-collapsed .om-nav-text { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } .om-sidebar.is-collapsed .om-nav-discord { min-height: 36px; padding-block: 0; } + +.om-ascii { position: absolute; inset: 0; z-index: 0; overflow: hidden; pointer-events: none; user-select: none; } +.om-ascii-field { position: absolute; top: 0; right: 0; display: flex; flex-direction: column; direction: ltr; } +[dir="rtl"] .om-ascii-field { right: auto; left: 0; transform: scaleX(-1); } +.om-ascii-row { + height: 15.4px; + color: rgb(var(--accent-rgb) / var(--ascii-a)); + font-family: var(--font-ascii); + font-size: 11px; font-weight: 500; line-height: 15.4px; + font-variation-settings: normal; + white-space: pre; +} +:where(.om-ascii-field.is-sweeping) .om-ascii-row { animation: om-ascii-sweep 234ms cubic-bezier(0.333333333333, 1, 0.666666666667, 1) var(--ascii-delay) backwards; } +@keyframes om-ascii-sweep { from { transform: translateX(220px); } to { transform: none; } } +.reduce-interface-motion .om-ascii-row { animation: none; } +@media (prefers-reduced-motion: reduce) { .om-ascii-row { animation: none; } } diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 4ff5eb7f..b7688678 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -7,12 +7,17 @@ --font-sans: "DM Sans", system-ui, -apple-system, "Segoe UI", "Noto Sans", "Noto Sans Arabic", "Noto Sans JP", "Noto Sans KR", "Noto Sans SC", sans-serif; --font-mono: "JetBrains Mono", ui-monospace, "Cascadia Mono", monospace; + --font-ascii: "Cascadia Mono", ui-monospace, monospace; + --font-opsz: "opsz" 14; --fs-title: 22px; --fs-h2: 15px; --fs-body: 13px; --fs-meta: 12px; --fs-small: 11px; + --fs-figure: 30px; + --fs-crumb: 11px; + --fs-connection: 12px; --ease: cubic-bezier(0.2, 0.7, 0.2, 1); --t-fast: 120ms; @@ -21,7 +26,29 @@ --sidebar-w: 248px; --sidebar-w-collapsed: 68px; + --sidebar-inline: 10px; + --nav-h: 36px; + --nav-inline: 10px; + --nav-icon: 17px; + --page-x: 32px; + --page-top: 28px; --content-w: 1160px; + --rail-w: 280px; + --rail-w-medium: 220px; + --rail-gap: 16px; + --rail-art-inset: 0px; + --rail-art-stacked: 128px; + --tab-well-h: 40px; + --tab-h: 32px; + --tab-pad: 4px; + --tab-gap: 4px; + --panel-pad: 16px; + --panel-gap: 16px; + --row-min: 40px; + --control-h: 32px; + --switch-w: 36px; + --switch-h: 20px; + --switch-thumb: 16px; } :root, @@ -73,7 +100,7 @@ --primary-shadow: inset 0 1px 0 rgb(255 255 255 / 0.28), 0 1px 2px rgb(var(--shade-rgb) / 0.45); --shadow-float: 0 18px 44px -14px rgb(var(--shade-rgb) / 0.7), 0 2px 6px rgb(var(--shade-rgb) / 0.3); --focus: 0 0 0 2px var(--ink-1), 0 0 0 4px rgb(var(--accent-rgb) / 0.7); - --ground: radial-gradient(110% 80% at 82% -20%, rgb(var(--accent-rgb) / var(--light-a)), transparent 62%), var(--ink-1); + --ground: var(--ink-1); --stage-light: radial-gradient(62% 58% at 50% 46%, rgb(var(--accent-rgb) / 0.13), transparent 72%); --chart-fill: linear-gradient(180deg, rgb(var(--accent-rgb) / 0.2), rgb(var(--accent-rgb) / 0)); } diff --git a/src/styles/visual-contract.json b/src/styles/visual-contract.json new file mode 100644 index 00000000..922eb173 --- /dev/null +++ b/src/styles/visual-contract.json @@ -0,0 +1,64 @@ +{ + "sidebar": 248, + "sidebarCollapsed": 68, + "sidebarInline": 10, + "navHeight": 36, + "navInline": 10, + "navGap": 11, + "navIcon": 17, + "iconStroke": 1.8, + "pageX": 32, + "pageTop": 28, + "contentMax": 1160, + "railWide": 280, + "railMedium": 220, + "railWideFrom": 1000, + "railMediumFrom": 760, + "railGap": 16, + "railArtInset": 0, + "railArtStacked": 128, + "tabWell": 40, + "tabHeight": 32, + "tabPad": 4, + "tabGap": 4, + "tabWellRadius": 10, + "tabRadius": 6, + "panelPad": 16, + "panelGap": 16, + "rowMin": 40, + "control": 32, + "switchWidth": 36, + "switchHeight": 20, + "switchThumb": 16, + "radii": [3, 6, 10, 14], + "fontBody": 13, + "fontTitle": 22, + "fontPanel": 15, + "fontMeta": 12, + "fontSmall": 11, + "figure": 30, + "nameSize": 22, + "nameLine": 28, + "connection": 12, + "crumb": 11, + "dark": { + "ink0": "#08090a", + "ink1": "#0c0d0f", + "text1": "#eef0f2", + "text2": "#aab0b7", + "text3": "#7f858d", + "text4": "#565b63", + "accent": "#5dde89", + "tint": [214, 228, 240] + }, + "light": { + "ink0": "#fbfbf9", + "ink1": "#f2f2ef", + "text1": "#17181a", + "text2": "#45484e", + "text3": "#63676e", + "text4": "#9a9ea5", + "accent": "#c4581f", + "tint": [30, 30, 24] + } +} diff --git a/src/styles/visual-contract.test.ts b/src/styles/visual-contract.test.ts new file mode 100644 index 00000000..dff1bca6 --- /dev/null +++ b/src/styles/visual-contract.test.ts @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const contract = JSON.parse(readFileSync(new URL("./visual-contract.json", import.meta.url), "utf8")); +const tokens = readFileSync(new URL("./tokens.css", import.meta.url), "utf8").replaceAll("\r\n", "\n"); + +function block(selector: string): Map { + const start = tokens.indexOf(`${selector} {`); + assert.ok(start >= 0, `${selector} block`); + const body = tokens.slice(tokens.indexOf("{", start) + 1, tokens.indexOf("}", start)); + const vars = new Map(); + for (const match of body.matchAll(/--([\w-]+):\s*([^;]+);/g)) vars.set(match[1]!, match[2]!.trim()); + return vars; +} + +const root = block(":root"); +const dark = block(":root,\n[data-interface-theme]"); +const light = block('[data-interface-theme="light"]'); + +const px = (name: string): number => { + const value = root.get(name); + assert.ok(value, `--${name}`); + assert.match(value, /^\d+(\.\d+)?px$/, `--${name}`); + return Number.parseFloat(value); +}; + +test("shell, page and rail geometry match the shared contract", () => { + assert.equal(px("sidebar-w"), contract.sidebar); + assert.equal(px("sidebar-w-collapsed"), contract.sidebarCollapsed); + assert.equal(px("sidebar-inline"), contract.sidebarInline); + assert.equal(px("nav-h"), contract.navHeight); + assert.equal(px("nav-inline"), contract.navInline); + assert.equal(px("nav-icon"), contract.navIcon); + assert.equal(px("page-x"), contract.pageX); + assert.equal(px("page-top"), contract.pageTop); + assert.equal(px("content-w"), contract.contentMax); + assert.equal(px("rail-w"), contract.railWide); + assert.equal(px("rail-w-medium"), contract.railMedium); + assert.equal(px("rail-gap"), contract.railGap); + assert.equal(px("rail-art-inset"), contract.railArtInset); + assert.equal(px("rail-art-stacked"), contract.railArtStacked); +}); + +test("tabs, panels and controls match the shared contract", () => { + assert.equal(px("tab-well-h"), contract.tabWell); + assert.equal(px("tab-h"), contract.tabHeight); + assert.equal(px("tab-pad"), contract.tabPad); + assert.equal(px("tab-gap"), contract.tabGap); + assert.equal(px("panel-pad"), contract.panelPad); + assert.equal(px("panel-gap"), contract.panelGap); + assert.equal(px("row-min"), contract.rowMin); + assert.equal(px("control-h"), contract.control); + assert.equal(px("switch-w"), contract.switchWidth); + assert.equal(px("switch-h"), contract.switchHeight); + assert.equal(px("switch-thumb"), contract.switchThumb); + assert.deepEqual(["r-xs", "r-sm", "r-md", "r-lg"].map(px), contract.radii); + assert.equal(px("fs-body"), contract.fontBody); + assert.equal(px("fs-title"), contract.fontTitle); + assert.equal(px("fs-h2"), contract.fontPanel); + assert.equal(px("fs-meta"), contract.fontMeta); + assert.equal(px("fs-small"), contract.fontSmall); + assert.equal(px("fs-figure"), contract.figure); + assert.equal(px("fs-crumb"), contract.crumb); + assert.equal(px("fs-connection"), contract.connection); +}); + +test("dark and light palettes are the contract values", () => { + for (const [name, vars] of [["dark", dark], ["light", light]] as const) { + const p = contract[name]; + assert.equal(vars.get("ink-0"), p.ink0, `${name} ink-0`); + assert.equal(vars.get("ink-1"), p.ink1, `${name} ink-1`); + assert.equal(vars.get("text-1"), p.text1); + assert.equal(vars.get("text-2"), p.text2); + assert.equal(vars.get("text-3"), p.text3); + assert.equal(vars.get("text-4"), p.text4); + assert.equal(vars.get("accent"), p.accent); + assert.equal(vars.get("tint-rgb"), p.tint.join(" ")); + } +}); + +test("common ground and featured panels use native-supported materials", () => { + const controls = readFileSync(new URL("./controls.css", import.meta.url), "utf8"); + assert.equal(dark.get("ground"), "var(--ink-1)"); + const featured = controls.match(/\.om-panel\.is-lit\s*\{([^}]+)\}/)?.[1]; + assert.ok(featured, "featured panel material"); + assert.doesNotMatch(featured, /radial-gradient|var\(--sheen\)/); + assert.match(featured, /linear-gradient\(180deg,\s*rgb\(var\(--accent-rgb\)\s*\/\s*0\.07\),\s*transparent 50%\)/); + assert.match(featured, /linear-gradient\(var\(--surface-1\),\s*var\(--surface-1\)\),\s*var\(--ink-1\)/); +}); + +test("a selected tab keeps the shared keyboard focus ring", () => { + const css = readFileSync(new URL("./device.css", import.meta.url), "utf8"); + const focus = css.match(/\.om-tab:focus-visible\s*\{\s*box-shadow:\s*var\(--focus\);\s*\}/); + assert.ok(focus, "active tabs need their own focus rule"); + assert.ok(focus.index! > css.indexOf(".om-tab.is-active {"), "selected styling must not mask focus"); +}); + +test("DM Sans is self-hosted with its licence and pinned to the native instance", () => { + const fonts = readFileSync(new URL("./fonts.css", import.meta.url), "utf8"); + assert.match(fonts, /font-family:\s*"DM Sans"/); + assert.match(fonts, /url\("\/fonts\/DMSans-Variable\.ttf"\)/); + const font = readFileSync(new URL("../../public/fonts/DMSans-Variable.ttf", import.meta.url)); + assert.equal(font.length, 240_164); + assert.match(readFileSync(new URL("../../public/fonts/DM-Sans-OFL.txt", import.meta.url), "utf8"), /SIL Open Font License/); + assert.match(root.get("font-sans") ?? "", /^"DM Sans"/); + assert.equal(root.get("font-opsz"), '"opsz" 14'); + const html = readFileSync(new URL("../../index.html", import.meta.url), "utf8"); + assert.doesNotMatch(html, /family=DM\+Sans/); +}); diff --git a/test/feedback-security.test.ts b/test/feedback-security.test.ts new file mode 100644 index 00000000..8f2283fc --- /dev/null +++ b/test/feedback-security.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { onRequest } from "../functions/api/feedback.js"; + +const payload = { content: "@everyone feedback", username: "Admin", avatar_url: "https://evil.test/a", tts: true, allowed_mentions: { parse: ["everyone"] }, embeds: [{ title: "Game request", description: "details", color: 42, fields: [{ name: "Game", value: "Example", inline: true }], footer: { text: "@user" } }] }; +const env = { DISCORD_FEEDBACK_WEBHOOK: "https://discord.com/api/webhooks/fake/fake" }; +for (const multipart of [false, true]) { + test(`feedback rebuilds authority-safe ${multipart ? "multipart" : "JSON"} payload`, async () => { + const original = globalThis.fetch; + let sent: Request | undefined; + globalThis.fetch = async (url, init) => { sent = new Request(url, { ...init, duplex: "half" } as RequestInit); return new Response(null, { status: 204 }); }; + try { + const form = new FormData(); + form.set("payload_json", JSON.stringify(payload)); + form.set("files[0]", new Blob(["diagnostics"]), "report.txt"); + const request = new Request("https://example.test/api/feedback", multipart ? { method: "POST", body: form } : { method: "POST", body: JSON.stringify(payload), headers: { "Content-Type": "application/json" } }); + assert.equal((await onRequest({ request, env })).status, 200); + assert.ok(sent); + const sentForm = multipart ? await sent.formData() : null; + const body = sentForm ? JSON.parse(String(sentForm.get("payload_json"))) : await sent.json(); + assert.deepEqual(body.allowed_mentions, { parse: [] }); + for (const key of ["username", "avatar_url", "tts"]) assert.equal(key in body, false, key); + assert.equal(body.content, payload.content); + assert.deepEqual(body.embeds, payload.embeds); + if (sentForm) assert.equal(await (sentForm.get("files[0]") as File).text(), "diagnostics"); + } finally { globalThis.fetch = original; } + }); +} + +test("feedback rejects invalid schemas and multipart authority fields without sending", async () => { + const original = globalThis.fetch; + let sends = 0; + globalThis.fetch = async () => { sends++; return new Response(null, { status: 204 }); }; + try { + for (const value of [null, [], { embeds: "not-an-array" }, { embeds: [{ fields: [{ name: "x", value: 3 }] }] }, { content: "x".repeat(2001) }]) { + const request = new Request("https://example.test/api/feedback", { method: "POST", body: JSON.stringify(value), headers: { "Content-Type": "application/json" } }); + assert.equal((await onRequest({ request, env })).status, 400); + } + const form = new FormData(); + form.append("payload_json", JSON.stringify(payload)); + form.append("payload_json", JSON.stringify(payload)); + assert.equal((await onRequest({ request: new Request("https://example.test/api/feedback", { method: "POST", body: form }), env })).status, 400); + assert.equal(sends, 0); + } finally { globalThis.fetch = original; } +}); + +test("feedback caps actual multipart bytes before form parsing", async () => { + let cancelled = false; + const body = new ReadableStream({ pull(controller) { controller.enqueue(new Uint8Array(1024 * 1024)); }, cancel() { cancelled = true; } }); + const request = new Request("https://example.test/api/feedback", { method: "POST", body, duplex: "half", headers: { "Content-Type": "multipart/form-data; boundary=fake" } } as RequestInit); + assert.equal((await onRequest({ request, env })).status, 413); + assert.equal(cancelled, true); +}); + +test("feedback bounds streamed bodies before JSON parsing or sending", async () => { + const original = globalThis.fetch; + globalThis.fetch = async () => { throw new Error("network denied"); }; + try { + const request = new Request("https://example.test/api/feedback", { method: "POST", headers: { "Content-Type": "application/json", "Content-Length": "1" }, body: JSON.stringify({ content: "x".repeat(70 * 1024) }) }); + assert.equal((await onRequest({ request, env })).status, 413); + } finally { globalThis.fetch = original; } +}); diff --git a/test/web-security.test.ts b/test/web-security.test.ts new file mode 100644 index 00000000..e3e31282 --- /dev/null +++ b/test/web-security.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { onRequest as app } from "../functions/_middleware.js"; + +for (const [name, handler] of [["app", app]] as const) { + test(`${name}: API routes never trust asset destinations or extensions`, async () => { + for (const path of ["/api", "/api/feedback", "/api/anything.png", "/%61pi/file.png"]) { + const response = await handler({ request: new Request(`https://example.test${path}`, { headers: { "Sec-Fetch-Dest": "image" } }), env: { SECURITY_KV: { get: async () => "1" } }, next: async () => new Response("unsafe") }); + assert.equal(response.status, 403, path); + } + }); + test(`${name}: forged asset destinations do not bypass rate or exploit checks`, async () => { + const env = { SECURITY_KV: { get: async (key: string) => key.startsWith("window:") ? "240" : null, put: async () => {} } }; + const next = async () => new Response("unsafe"); + const headers = { "Sec-Fetch-Dest": "image" }; + assert.equal((await handler({ request: new Request("https://example.test/api/data", { headers }), env, next })).status, 429); + assert.equal((await handler({ request: new Request("https://example.test/api/data?value=javascript:alert", { headers }), env, next })).status, 403); + }); + test(`${name}: actual streamed limits apply without KV and with absent or false lengths`, async () => { + for (const headers of [{}, { "Content-Length": "1" }]) { + let cancelled = false; + let pulls = 0; + const body = new ReadableStream({ pull(controller) { pulls++; controller.enqueue(new Uint8Array(1024 * 1024)); }, cancel() { cancelled = true; } }); + const response = await handler({ request: new Request("https://example.test/api/upload", { method: "PUT", body, duplex: "half", headers } as RequestInit), env: {}, next: async () => new Response("unsafe") }); + assert.equal(response.status, 413); + assert.equal(cancelled, true); + assert.ok(pulls <= 11); + } + }); + test(`${name}: bounded body remains available downstream`, async () => { + const request = new Request("https://example.test/api/upload", { method: "POST", body: "legitimate" }); + const response = await handler({ request, env: {}, next: async (forwarded = request) => new Response(await forwarded.text()) }); + assert.equal(await response.text(), "legitimate"); + }); +}