diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1014ba7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example index 14559dd..811dea3 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,3 @@ -# Cloudflare Pages Functions runtime bindings. -SUPABASE_URL=https://your-project.supabase.co -SUPABASE_SERVICE_ROLE_KEY=configure-as-a-cloudflare-secret +VITE_SUPABASE_URL= +VITE_SUPABASE_ANON_KEY= +VITE_TURNSTILE_SITE_KEY= diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..b999191 --- /dev/null +++ b/.env.production @@ -0,0 +1,3 @@ +VITE_SUPABASE_URL=https://htvsdiikalggstogproq.supabase.co +VITE_SUPABASE_ANON_KEY=sb_publishable_k_Ea1wta7Ts0Bzj3cehBMg_VrLEujxR +VITE_TURNSTILE_SITE_KEY= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..70a3209 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + pull_request: + push: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + + - run: npm ci + + - name: Type check and build + run: npm run build + + - name: Test + run: npm test + + - name: Bundle size + run: npm run size + + - name: Audit + run: npm audit --audit-level=high + + - name: Approved runtime dependencies + run: | + node <<'NODE' + const { dependencies = {} } = require("./package.json"); + const approved = { + "@openmouse/protocol": "https://github.com/OpenMouse-Project/mouse-protocol.git", + "preact": "^10.29.8", + }; + + if (JSON.stringify(dependencies) !== JSON.stringify(approved)) { + console.error("Runtime dependencies differ from the maintainer-approved list."); + console.error("Expected:", approved); + console.error("Received:", dependencies); + process.exit(1); + } + NODE diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..e6c92b7 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,28 @@ +name: CodeQL + +on: + pull_request: + push: + schedule: + - cron: "0 6 * * 1" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + runs-on: ubuntu-latest + permissions: + security-events: write + actions: read + contents: read + steps: + - uses: actions/checkout@v7 + + - uses: github/codeql-action/init@v4 + with: + languages: javascript-typescript + queries: security-and-quality + + - uses: github/codeql-action/analyze@v4 diff --git a/.gitignore b/.gitignore index 458caef..abde248 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ node_modules/ dist/ +/preview/ CHANGELOG.md supabase/migrations/ .DS_Store .env .env.* !.env.example +!.env.production diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..4ba08b0 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +allow-git=all diff --git a/README.md b/README.md index 8c978b0..4adb995 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,58 @@ # OpenMouse -OpenMouse is a browser-based app for managing supported gaming mice in one place. -It uses the WebHID API to communicate with devices directly from Chrome or Edge — no separate desktop utility required. +OpenMouse is a browser-based control panel for supported gaming mice. -## Features +Connect a mouse, view its information, and change supported settings such as DPI +and polling rate without installing a different app for every brand. -- **DPI control** — view and change sensitivity presets -- **Polling rate** — switch between 125 Hz – 8 KHz where supported -- **Battery & firmware** — read live status from wireless mice -- **Lift-off distance**, debounce, motion sync, and more per brand -- Supports Logitech, Pulsar, Endgame Gear, and WLMouse hardware - -## Browser support - -| Browser | WebHID | Status | -|---------|--------|--------| -| Chrome | ✅ | Fully supported | -| Edge | ✅ | Fully supported | -| Firefox | ❌ | WebHID not implemented | -| Safari | ❌ | WebHID not implemented | +This branch is deployed as the public development control panel. ## Development ```bash npm install -npm run dev # start local dev server -npm run build # production build (requires tsc + vite) -npm test # run protocol unit tests +npm run dev ``` -Copy `.env.example` to `.env` and fill in your Supabase credentials if you need the license/access-gate functions. +Run the full local check before pushing changes: -## Mouse Check — Diagnostics & Discord Reporting +```bash +npm run check +``` -**Mouse Check** is a companion Tauri desktop app for checking whether a mouse is WebHID compatible. -It scans native HID devices, tests the Razer protocol over every interface, and generates a full diagnostic report. +## Bridge updates -### Where to enter your Discord Webhook URL +The Settings page compares the connected OpenMouse Bridge against its latest +stable GitHub release. It only retrieves the version, changelog, and download +link; it never downloads or installs an update in the background. Users can +also run the check manually. -1. Open **Mouse Check** -2. Select your mouse from the device list and click **Run Diagnostics** -3. When the **Discord Report** section appears at the bottom, paste your Discord Webhook URL into the field labeled *"Discord Webhook URL (saved locally)"* -4. Click **Send to Discord** +The control panel is organized by responsibility: `control.ts` coordinates the +application, while the template, events, DOM helpers, persisted preferences, +battery history, device selection, and rendering live in focused modules under +`src/`. -The webhook URL is saved in your browser's `localStorage` under the key `om-discord-webhook`. -It is **never** stored in the app code, never committed to the repository, and never sent anywhere other than Discord. +Packet codecs and WebHID drivers live in the standalone +[`@openmouse/protocol`](https://github.com/OpenMouse-Project/mouse-protocol) +library. Its codec entry points remain transport-independent, while its +`drivers` entry points own discovery filters, device clients, retries, and +application-facing status conversion. OpenMouse consumes the same public +exports that external consumers use. -**How to create a webhook:** -Discord server → Channel settings → Integrations → Webhooks → New Webhook → Copy Webhook URL +## Adding a vendor -## Stack +Codec and driver contributions belong in the `mouse-protocol` repository. -- **Vite 6** + **TypeScript 5** — build tooling -- **WebHID** — browser API for direct HID device access -- **Cloudflare Pages** — hosting + edge functions (access gate) -- **Supabase** — license verification (optional, for access gate) +1. Add transport-independent packet definitions and codecs under the vendor's + `mouse-protocol/src//` folder. +2. Add the WebHID implementation under `mouse-protocol/src/drivers//`. +3. Register the driver and browser filters in the shared driver layer. +4. Add or extend codec and driver tests, state which product IDs were verified + on hardware, and run the checks in both repositories. -## License +OpenMouse should only need changes when a driver introduces a genuinely new UI +capability. The control UI otherwise discovers supported clients through the +library registry automatically. -Not currently licensed for use, modification, or redistribution. -A license will be selected before the project's full public release. +Hardware-specific validation checklists live in the protocol repository's +`docs/` directory. diff --git a/build/check-bundle-size.ts b/build/check-bundle-size.ts new file mode 100644 index 0000000..5d7ce00 --- /dev/null +++ b/build/check-bundle-size.ts @@ -0,0 +1,69 @@ +import { readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +const BUDGET_BYTES: Record = { + // Raised from 103 kB for the interface themes: NieR: Automata and Liquid + // Glass each ship their own token block, and the liquid-glass material + // layer (SVG displacement filters plus their component rules) adds the + // largest share. The measured bundle is 153.7 kB; 175 kB adds headroom for + // the Developer Hall of Fame page (~15 kB of animated card and hero + // styles that load only on /contributors.html). + ".css": 175_000, + // Raised from 510 kB for Bridge discovery, profile editing, automatic + // reconnection, and recent device support, which have since grown further + // with the supported-device page and MX Master remap controls. Preview + // fixtures retain their separate allowance below; the measured aggregate + // is 573.4 kB with them, plus the ~11 kB Hall of Fame chunk. Raised again + // from 590 kB for the Razer button-mapping card and its codec: the measured + // aggregate is 588.2 kB, which left under 2 kB of headroom. Raised again to + // 610 kB for the Pulsar XS-1 feature-report driver and 4K receiver support + // (mouse-protocol 3c3a445): the X3 family codec plus the 4K DPI/polling work + // adds ~1.3 kB to the measured aggregate. + ".js": 610_000, +}; + +const ASSETS = join("dist", "assets"); + +function bundles(): { name: string; ext: string; bytes: number }[] { + return readdirSync(ASSETS) + .filter((name) => name.endsWith(".css") || name.endsWith(".js")) + .map((name) => ({ + name, + ext: name.slice(name.lastIndexOf(".")), + bytes: statSync(join(ASSETS, name)).size, + })); +} + +const found = bundles(); +if (found.length === 0) { + console.error(`No bundles in ${ASSETS}. Run "npm run build" first.`); + process.exit(1); +} + +const budgets = { + ...BUDGET_BYTES, + ".js": BUDGET_BYTES[".js"] + (found.some(({ name }) => name.startsWith("preview-fixtures-")) ? 18_000 : 0), +}; + +const totals = new Map(); +for (const { ext, bytes } of found) totals.set(ext, (totals.get(ext) ?? 0) + bytes); + +let failed = false; +for (const [ext, budget] of Object.entries(budgets)) { + const bytes = totals.get(ext) ?? 0; + const percent = Math.round((bytes / budget) * 100); + const label = `${ext.slice(1).toUpperCase().padEnd(3)} ${String(bytes).padStart(7)} / ${budget} bytes (${percent}%)`; + if (bytes > budget) { + failed = true; + console.error(`over budget ${label}`); + } else { + console.log(`ok ${label}`); + } +} + +if (failed) { + console.error(""); + console.error("A bundle grew past its budget. Justify the growth and raise BUDGET_BYTES,"); + console.error("or find what was added. Adding a CSS framework once cost 19 kB unnoticed."); + process.exit(1); +} diff --git a/build/pwa-vite-plugin.ts b/build/pwa-vite-plugin.ts new file mode 100644 index 0000000..32c7174 --- /dev/null +++ b/build/pwa-vite-plugin.ts @@ -0,0 +1,160 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import type { Plugin } from "vite"; + +/** + * Public files that are not referenced by the page markup but are still needed + * for an installed app to start without a network connection. + */ +const STATIC_PRECACHE = [ + "/manifest.webmanifest", + "/favicon.ico", + "/favicon-32.png", + "/favicon-dark.svg", + "/apple-touch-icon.png", + "/icon-512.png", + "/mouse-preview.svg", +]; + +/** + * Pages whose emitted markup is scanned for the hashed assets to precache. + * The license gate and the control app are deliberately absent: both are + * verified per request by functions/_middleware.js, so caching either one + * would let a revoked license keep working offline. + */ +const PRECACHE_PAGES: { file: string; url: string }[] = [ + { file: "index.html", url: "/" }, + { file: "demo.html", url: "/demo.html" }, +]; + +/** Same-origin paths the worker must never serve from its own cache. */ +const BYPASS_SOURCE = [ + "/^\\/api\\//", + "/^\\/control-app/", + "/^\\/protected-assets\\//", + "/^\\/control(?:\\.html)?$/", +].join(", "); + +function renderServiceWorker(version: string, precache: string[]): string { + return `// Generated by build/pwa-vite-plugin.ts. Do not edit by hand. +const CACHE = "openmouse-${version}"; +const FONT_CACHE = "openmouse-fonts"; +const PRECACHE = ${JSON.stringify(precache, null, 2)}; + +/** + * Licensed routes. These are validated per request by the Cloudflare + * middleware and must always reach the network, so an expired or revoked + * session cannot be served from a local cache. + */ +const BYPASS = [${BYPASS_SOURCE}]; + +const FONT_ORIGINS = ["https://fonts.googleapis.com", "https://fonts.gstatic.com"]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE) + .then((cache) => cache.addAll(PRECACHE)) + .then(() => self.skipWaiting()), + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys() + .then((keys) => Promise.all( + keys + .filter((key) => key !== CACHE && key !== FONT_CACHE) + .map((key) => caches.delete(key)), + )) + .then(() => self.clients.claim()), + ); +}); + +/** Serves the cached copy immediately and refreshes it in the background. */ +async function staleWhileRevalidate(request, cacheName) { + const cache = await caches.open(cacheName); + const cached = await cache.match(request); + const network = fetch(request) + .then((response) => { + if (response.ok || response.type === "opaque") cache.put(request, response.clone()); + return response; + }) + .catch(() => cached); + return cached ?? network; +} + +/** Prefers fresh markup, falling back to the cache when the network is gone. */ +async function networkFirst(request) { + const cache = await caches.open(CACHE); + try { + const response = await fetch(request); + if (response.ok) cache.put(request, response.clone()); + return response; + } catch (error) { + const cached = await cache.match(request) ?? await cache.match("/"); + if (cached) return cached; + throw error; + } +} + +self.addEventListener("fetch", (event) => { + const { request } = event; + if (request.method !== "GET") return; + + const url = new URL(request.url); + + if (FONT_ORIGINS.includes(url.origin)) { + event.respondWith(staleWhileRevalidate(request, FONT_CACHE)); + return; + } + + if (url.origin !== self.location.origin) return; + if (BYPASS.some((pattern) => pattern.test(url.pathname))) return; + + if (request.mode === "navigate") { + event.respondWith(networkFirst(request)); + return; + } + + event.respondWith( + caches.match(request).then((cached) => cached ?? fetch(request)), + ); +}); +`; +} + +/** Emits a service worker that precaches the public pages and their assets. */ +export function pwa(appVersion: string): Plugin { + let root = process.cwd(); + let outputDirectory = "dist"; + + return { + name: "openmouse-pwa", + apply: "build", + configResolved(config) { + root = config.root; + outputDirectory = config.build.outDir; + }, + // Runs against the written output: Vite injects the hashed asset tags into + // the markup after generateBundle, so the emitted HTML is only complete on disk. + async closeBundle() { + const outputRoot = resolve(root, outputDirectory); + const urls = new Set(STATIC_PRECACHE); + + for (const page of PRECACHE_PAGES) { + const markup = await readFile(resolve(outputRoot, page.file), "utf8"); + + urls.add(page.url); + for (const [, asset] of markup.matchAll(/(?:href|src)="(\/assets\/[^"]+)"/g)) { + urls.add(asset); + } + } + + const precache = [...urls].sort(); + const version = `${appVersion}-${createHash("sha256").update(precache.join("\n")).digest("hex").slice(0, 8)}`; + + await writeFile(resolve(outputRoot, "sw.js"), renderServiceWorker(version, precache)); + }, + }; +} diff --git a/control-app.html b/check.html similarity index 69% rename from control-app.html rename to check.html index 6d7d116..7225ac1 100644 --- a/control-app.html +++ b/check.html @@ -4,18 +4,18 @@ - - - - - - OpenMouse Control + + + + + Mouse Check — OpenMouse HID Diagnostics + -
- +
+ diff --git a/contributors.html b/contributors.html new file mode 100644 index 0000000..9db7f8e --- /dev/null +++ b/contributors.html @@ -0,0 +1,18 @@ + + + + + + + Developer Hall of Fame — OpenMouse + + + + + + + +
+ + + diff --git a/control.html b/control.html deleted file mode 100644 index a6e2a28..0000000 --- a/control.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - OpenMouse License - - -
- - - diff --git a/demo.html b/demo.html deleted file mode 100644 index 1cac51a..0000000 --- a/demo.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - OpenMouse Interface Preview - - -
- - - diff --git a/docs/protected-voting.md b/docs/protected-voting.md new file mode 100644 index 0000000..a6bf49e --- /dev/null +++ b/docs/protected-voting.md @@ -0,0 +1,31 @@ +# Protected mouse voting + +Voting is handled by a same-origin Cloudflare Pages Function. The browser solves +Turnstile, the function validates the one-use token, and Supabase records a keyed +hash of the Cloudflare-provided client IP. Raw IP addresses are never stored. + +## Setup + +1. Run `supabase/migrations/20260812001000_protected_mouse_voting.sql` in the + Supabase SQL editor. The old anonymous vote, request, and diagnostic RPCs must + remain revoked. +2. Create a Turnstile widget for `openmouse.app`. +3. Add `VITE_TURNSTILE_SITE_KEY` as a Cloudflare Pages text variable. This is a + public site key, not a secret. The Pages Function supplies it to the browser + at runtime. +4. Add these encrypted Pages secrets for the Function: + - `TURNSTILE_SECRET_KEY`: the Turnstile widget secret + - `VOTER_HASH_SECRET`: at least 32 random bytes; generate with + `openssl rand -hex 32` + - `SUPABASE_URL`: the project URL + - `SUPABASE_SERVICE_ROLE_KEY`: the legacy service-role JWT from Supabase +5. Deploy the `dev` build and verify a vote. Reusing an IP for the same mouse is + rejected, and each IP hash can vote for at most five different mice per + rolling 24-hour window. Run + `supabase/migrations/20260812002000_protected_mouse_requests.sql` to enable + protected submissions; each IP hash may create at most two requests per + rolling seven-day window. A new request starts with zero votes, and the + requester may vote through the normal protected vote button. + +Never prefix the service-role key, Turnstile secret, or voter-hash secret with +`VITE_`; Vite variables are included in browser code. diff --git a/functions/_middleware.js b/functions/_middleware.js deleted file mode 100644 index 35b130e..0000000 --- a/functions/_middleware.js +++ /dev/null @@ -1,52 +0,0 @@ -const COOKIE_NAME = "om_license_session"; - -function readCookie(request, name) { - const cookies = request.headers.get("Cookie") ?? ""; - for (const part of cookies.split(";")) { - const [key, ...value] = part.trim().split("="); - if (key === name) return value.join("="); - } - return null; -} - -async function sha256(value) { - const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); - return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); -} - -export async function onRequest({ request, env, next }) { - const url = new URL(request.url); - if (url.pathname.startsWith("/api/license/")) return next(); - - const sessionToken = readCookie(request, COOKIE_NAME); - const supabaseUrl = env.SUPABASE_URL || env.VITE_SUPABASE_URL; - const serviceKey = env.SUPABASE_SERVICE_ROLE_KEY; - let allowed = false; - - if (sessionToken && supabaseUrl && serviceKey) { - const response = await fetch(`${supabaseUrl}/rest/v1/rpc/validate_license_session`, { - method: "POST", - headers: { - apikey: serviceKey, - Authorization: `Bearer ${serviceKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ session_hash: await sha256(sessionToken) }), - }); - if (response.ok) { - const result = await response.json(); - allowed = result?.allowed === true; - } - } - - if (!allowed) { - if (url.pathname.startsWith("/protected-assets/")) return new Response("Not found", { status: 404 }); - return Response.redirect(new URL("/control", url), 302); - } - - const response = await next(); - const protectedResponse = new Response(response.body, response); - protectedResponse.headers.set("Cache-Control", "private, no-store"); - protectedResponse.headers.set("X-Content-Type-Options", "nosniff"); - return protectedResponse; -} diff --git a/functions/api/license/activate.js b/functions/api/license/activate.js deleted file mode 100644 index 503d349..0000000 --- a/functions/api/license/activate.js +++ /dev/null @@ -1,60 +0,0 @@ -const COOKIE_NAME = "om_license_session"; - -function json(body, status = 200, headers = {}) { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers }, - }); -} - -function toBase64Url(bytes) { - let binary = ""; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); -} - -async function sha256(value) { - const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); - return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); -} - -export async function onRequest({ request, env }) { - if (request.method !== "POST") { - return json({ message: "Method not allowed." }, 405, { Allow: "POST" }); - } - const supabaseUrl = env.SUPABASE_URL || env.VITE_SUPABASE_URL; - const serviceKey = env.SUPABASE_SERVICE_ROLE_KEY; - if (!supabaseUrl || !serviceKey) return json({ message: "License service is not configured." }, 503); - - let body; - try { - body = await request.json(); - } catch { - return json({ message: "Invalid request." }, 400); - } - const licenseCode = typeof body.licenseCode === "string" ? body.licenseCode.trim() : ""; - if (licenseCode.length < 12 || licenseCode.length > 200) { - return json({ message: "Invalid license code." }, 400); - } - - const sessionToken = toBase64Url(crypto.getRandomValues(new Uint8Array(32))); - const sessionHash = await sha256(sessionToken); - const response = await fetch(`${supabaseUrl}/rest/v1/rpc/activate_license_session`, { - method: "POST", - headers: { - apikey: serviceKey, - Authorization: `Bearer ${serviceKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ license_key: licenseCode, session_hash: sessionHash }), - }); - - if (!response.ok) { - return json({ message: "Invalid, expired, or disabled license code." }, 401); - } - const result = await response.json(); - const expiresAt = new Date(result.session_expires_at); - const maxAge = Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1000)); - const cookie = `${COOKIE_NAME}=${sessionToken}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${maxAge}`; - return json({ ok: true }, 200, { "Set-Cookie": cookie }); -} diff --git a/functions/api/mouse-request.d.ts b/functions/api/mouse-request.d.ts new file mode 100644 index 0000000..aff74c5 --- /dev/null +++ b/functions/api/mouse-request.d.ts @@ -0,0 +1,6 @@ +interface PagesFunctionContext { + request: Request; + env: Record; +} + +export function onRequest(context: PagesFunctionContext): Promise; diff --git a/functions/api/mouse-request.js b/functions/api/mouse-request.js new file mode 100644 index 0000000..30d9e7b --- /dev/null +++ b/functions/api/mouse-request.js @@ -0,0 +1,75 @@ +const json = (body, status = 200) => new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }, +}); + +async function hmac(value, secret) { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value)); + return [...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +export async function onRequest({ request, env }) { + if (request.method !== "POST") return json({ message: "Method not allowed." }, 405); + const requestUrl = new URL(request.url); + const origin = request.headers.get("Origin"); + if (origin && origin !== requestUrl.origin) return json({ message: "Origin not allowed." }, 403); + + const input = await request.json().catch(() => ({})); + const manufacturer = typeof input.manufacturer === "string" ? input.manufacturer.trim() : ""; + const model = typeof input.model === "string" ? input.model.trim() : ""; + const connection = typeof input.connection === "string" ? input.connection.trim() : "Not sure"; + if (!manufacturer || manufacturer.length > 80 || !model || model.length > 120 || connection.length > 80 + || typeof input.turnstileToken !== "string" || !input.turnstileToken) return json({ message: "Invalid mouse request." }, 400); + + const ip = request.headers.get("CF-Connecting-IP"); + if (!ip) return json({ message: "Could not verify this requester." }, 400); + if (!env.TURNSTILE_SECRET_KEY || !env.VOTER_HASH_SECRET || !env.SUPABASE_URL || !env.SUPABASE_SERVICE_ROLE_KEY) { + return json({ message: "Request protection is not configured." }, 503); + } + + const verification = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { + method: "POST", + body: new URLSearchParams({ secret: env.TURNSTILE_SECRET_KEY, response: input.turnstileToken, remoteip: ip }), + }).then((response) => response.json()); + if (!verification.success || verification.action !== "mouse-vote" || verification.hostname !== requestUrl.hostname) { + return json({ message: "Anti-spam verification failed. Try again." }, 403); + } + + const response = await fetch(`${env.SUPABASE_URL.replace(/\/$/, "")}/rest/v1/rpc/cast_protected_mouse_request`, { + method: "POST", + headers: { + apikey: env.SUPABASE_SERVICE_ROLE_KEY, + Authorization: `Bearer ${env.SUPABASE_SERVICE_ROLE_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + p_manufacturer: manufacturer, + p_model: model, + p_connection: connection || "Not sure", + p_voter_hash: await hmac(ip, env.VOTER_HASH_SECRET), + }), + }); + if (!response.ok) { + const detail = await response.json().catch(() => ({})); + const raw = detail.message ?? ""; + console.error("Protected mouse request RPC failed", { status: response.status, code: detail.code, message: raw }); + const message = /request limit/i.test(raw) ? "Request limit reached. Try again next week." + : /already voted/i.test(raw) ? "That mouse is already listed and you already voted for it." + : /daily vote limit/i.test(raw) ? "Daily vote limit reached. Try again tomorrow." + : detail.code === "42702" ? `Database query is ambiguous: ${raw}` + : detail.code === "PGRST202" ? "Protected request database migration is not installed." + : detail.code === "42883" ? "Protected voting database migration is not installed." + : "Could not save this request."; + return json({ message, code: detail.code ?? "REQUEST_REJECTED" }, detail.code === "PGRST202" || detail.code === "42883" ? 503 : 409); + } + const rows = await response.json(); + if (!rows[0]) return json({ message: "The request was accepted but no record was returned." }, 502); + return json(rows[0]); +} diff --git a/functions/api/mouse-vote.d.ts b/functions/api/mouse-vote.d.ts new file mode 100644 index 0000000..aff74c5 --- /dev/null +++ b/functions/api/mouse-vote.d.ts @@ -0,0 +1,6 @@ +interface PagesFunctionContext { + request: Request; + env: Record; +} + +export function onRequest(context: PagesFunctionContext): Promise; diff --git a/functions/api/mouse-vote.js b/functions/api/mouse-vote.js new file mode 100644 index 0000000..688cea3 --- /dev/null +++ b/functions/api/mouse-vote.js @@ -0,0 +1,59 @@ +const json = (body, status = 200) => new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }, +}); + +async function hmac(value, secret) { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value)); + return [...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +export async function onRequest(context) { + const { request, env } = context; + 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); + + const { requestId, turnstileToken } = await request.json().catch(() => ({})); + if (typeof requestId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(requestId) + || typeof turnstileToken !== "string" || !turnstileToken) return json({ message: "Invalid vote." }, 400); + const ip = request.headers.get("CF-Connecting-IP"); + if (!ip) return json({ message: "Could not verify this voter." }, 400); + if (!env.TURNSTILE_SECRET_KEY || !env.VOTER_HASH_SECRET || !env.SUPABASE_URL || !env.SUPABASE_SERVICE_ROLE_KEY) { + return json({ message: "Voting protection is not configured." }, 503); + } + + const verification = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { + method: "POST", + body: new URLSearchParams({ secret: env.TURNSTILE_SECRET_KEY, response: turnstileToken, remoteip: ip }), + }).then((response) => response.json()); + if (!verification.success || verification.action !== "mouse-vote" || verification.hostname !== new URL(request.url).hostname) { + return json({ message: "Anti-spam verification failed. Try again." }, 403); + } + + const response = await fetch(`${env.SUPABASE_URL.replace(/\/$/, "")}/rest/v1/rpc/cast_protected_mouse_vote`, { + method: "POST", + headers: { + apikey: env.SUPABASE_SERVICE_ROLE_KEY, + Authorization: `Bearer ${env.SUPABASE_SERVICE_ROLE_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ p_request_id: requestId, p_voter_hash: await hmac(ip, env.VOTER_HASH_SECRET) }), + }); + if (!response.ok) { + const detail = await response.json().catch(() => ({})); + const raw = detail.message ?? ""; + const message = /already voted/i.test(raw) ? "You already voted for this mouse." + : /daily vote limit/i.test(raw) ? "Daily vote limit reached. Try again tomorrow." + : "Could not record your vote."; + return json({ message }, 409); + } + return json({ ok: true }); +} diff --git a/functions/api/voting-config.d.ts b/functions/api/voting-config.d.ts new file mode 100644 index 0000000..c55c6c4 --- /dev/null +++ b/functions/api/voting-config.d.ts @@ -0,0 +1,6 @@ +interface PagesFunctionContext { + request: Request; + env: Record; +} + +export function onRequest(context: PagesFunctionContext): Response; diff --git a/functions/api/voting-config.js b/functions/api/voting-config.js new file mode 100644 index 0000000..8a7c25f --- /dev/null +++ b/functions/api/voting-config.js @@ -0,0 +1,10 @@ +const json = (body, status = 200) => new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }, +}); + +export function onRequest({ request, env }) { + if (request.method !== "GET") return json({ message: "Method not allowed." }, 405); + if (!env.VITE_TURNSTILE_SITE_KEY) return json({ message: "Voting protection is not configured." }, 503); + return json({ siteKey: env.VITE_TURNSTILE_SITE_KEY }); +} diff --git a/index.html b/index.html index e80fd1a..0d03bdd 100644 --- a/index.html +++ b/index.html @@ -4,36 +4,56 @@ + - - - - - - - - - - - - - - - - - - - - - - - - OpenMouse | All Your Mice. One Control Panel. + + + OpenMouse Control -
- +
+ + + diff --git a/package-lock.json b/package-lock.json index 2985c3a..fd45d6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,10 @@ "": { "name": "openmouse", "version": "0.1.0", + "dependencies": { + "@openmouse/protocol": "https://github.com/OpenMouse-Project/mouse-protocol.git", + "preact": "^10.29.8" + }, "devDependencies": { "typescript": "^5.8.3", "vite": "^6.3.5" @@ -454,6 +458,14 @@ "node": ">=18" } }, + "node_modules/@openmouse/protocol": { + "version": "0.1.0", + "resolved": "git+https://github.com/OpenMouse-Project/mouse-protocol.git#ba64a12d7633d58967c213cd44eef10d12e465e0", + "integrity": "sha512-htpO6JBZSdbkx5NUfBaABufH7CXfjqA+C8rx4zGYdjb/FXrb9LE5nt7suuIvt03E8185x2I8dUyg/AjpJKjgvQ==", + "engines": { + "node": ">=20" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.3", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", @@ -887,9 +899,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -954,6 +966,24 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.29.8", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", + "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, "node_modules/rollup": { "version": "4.62.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", diff --git a/package.json b/package.json index 48f5916..59d572e 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,15 @@ "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", - "test": "node --test src/egg-we-protocol.test.ts", + "test": "node --test \"src/**/*.test.ts\"", + "size": "node build/check-bundle-size.ts", + "check": "npm run build && npm test", "preview": "vite preview" }, + "dependencies": { + "@openmouse/protocol": "https://github.com/OpenMouse-Project/mouse-protocol.git", + "preact": "^10.29.8" + }, "devDependencies": { "typescript": "^5.8.3", "vite": "^6.3.5" diff --git a/public/_routes.json b/public/_routes.json deleted file mode 100644 index 06e94de..0000000 --- a/public/_routes.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "version": 1, - "include": ["/api/license/*", "/control-app*", "/protected-assets/*"], - "exclude": [] -} diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png deleted file mode 100644 index 101d26c..0000000 Binary files a/public/apple-touch-icon.png and /dev/null differ diff --git a/public/devices/README.md b/public/devices/README.md new file mode 100644 index 0000000..2f4035d --- /dev/null +++ b/public/devices/README.md @@ -0,0 +1,98 @@ +# Device artwork + +Top-down product images shown in the persistent device panel. Vite serves +this folder from the site root, so a file here is reachable at +`/devices/.png`. + +Adding one: + +1. Save a **transparent** PNG or WebP named after the model in kebab-case, e.g. + `razer-viper-v3-pro.png`. The panel sits on a dark background, so an image + with a white backdrop shows as a white block. +2. Keep enough resolution for a product panel up to roughly 340 px wide. +3. Map the device to it in `src/ui/device-images.ts`, keyed by + `vendorId:productId` in lowercase hex. A mouse with separate wired and + receiver product ids needs an entry for each. + +Add the file and its mapping in the same commit. A mapping whose file is missing +fails at load rather than at build, and the panel then drops the thumbnail. + +## Licensing + +Vendor product renders are usually copyrighted marketing assets, and this +repository is public. Prefer artwork you made or can redistribute — a traced +silhouette is enough at this size — over an official render lifted from a +product page. + +`logitech-pro-x-superlight-2c.png` was supplied for the redesign from Logitech's +official PRO X SUPERLIGHT 2c product gallery. Confirm redistribution terms +before including it in a public release package. + +`logitech-pro-x2-superstrike.png` was supplied from Logitech G's official +PRO X2 SUPERSTRIKE product gallery. Confirm redistribution terms before +including it in a public release package. + +The three `logitech-g502*.png` files were supplied from Lenovo, Logitech G, +and MyXprs product-image URLs. They were normalized to matching 700×700 +transparent canvases; the original G502 backdrop was extracted from its source +render. Confirm redistribution terms before including them in a public release +package. + +`logitech-mx-master-4.png` is a line-art trace made for this repository, not a +vendor render. Only geometry derives from the source: the outer silhouette and +the shell seams — button split, scroll-wheel housing, thumb rest, thumb wheel, +side-panel crease and the wheel-mode button — were authored as paths against a +product image. No colour, shading, texture or lettering was carried over, and +the Logitech wordmark on the shell was deliberately excluded rather than faded, +since it is a trademark and this repository is public. + +`endgame-gear-op1-8k.png` was supplied from an Overclockers UK product-image +URL for the OP1 8K. Confirm redistribution terms before including it in a +public release package. + +`razer-viper-v2-pro.png` was supplied from Razer's support FAQ device-layout +asset (`dl.razerzone.com/src/6048-1-en-v10.png`). Ideally replace it with a +higher-resolution image if one is found. Confirm redistribution terms before +including it in a public release package. + +`keychron-nape-pro.png` was supplied from Keychron's sysmgr cover CDN for the +Nape Pro. Confirm redistribution terms before including it in a public release +package. + +`teevolution-terra-pro.png` was supplied from Teevolution's Terra PRO Shopify +CDN product render. Confirm redistribution terms before including it in a +public release package. + +`crdrako-ko-one.png` was supplied from CRDRAKO's KO-ONE Shopify CDN product +render and converted to a transparent PNG. Confirm redistribution terms before +including it in a public release package. + +`razer-viper-mini.webp` was supplied from a Discord attachment URL for the +Razer Viper Mini (wired). Confirm redistribution terms before including it in +a public release package. + +`zaunkoenig-m3k.png` was supplied from the OpenMouse product-image storage URL +for Zaunkoenig M3K and is also used for the M2K entry. Confirm redistribution +terms before including it in a public release package. + +`attackshark-r5-ultra.png` is the top-down render of the Attack Shark R5 Ultra +extracted from Attack Shark's official product gallery +(`cdn.shopify.com/s/files/1/0823/5050/6282/files/R5ULTRA_C06_3.png`), keyed +out of its white backdrop and downscaled. Confirm redistribution terms before +including it in a public release package. + +`razer-viper.webp` was supplied from a Best Buy shopping page for the device. Confirm redistribution +terms before including it in a public release package. + +`logitech-mx-master-3s.png` was supplied from Logitech's product CDN (MX Master +3S Bluetooth Edition graphite top view). Confirm redistribution terms before +including it in a public release package. + +`pulsar-x2-v2.png` was supplied from Pulsar Gaming Gears' Japan CDN product +render for the X2 v2 [Red Edition] Gaming Mouse (top-down view of the Medium +shell), cropped to the mouse, resized, and centered on a transparent canvas. +Confirm redistribution terms before including it in a public release package. + +`pulsar-pro-dongle.png` was supplied from Tomauri's Shopify CDN product render +for the Pulsar 8K Polling Wireless Dongle. Confirm redistribution terms before +including it in a public release package. diff --git a/public/devices/attackshark-r5-ultra.png b/public/devices/attackshark-r5-ultra.png new file mode 100644 index 0000000..48b4ed5 Binary files /dev/null and b/public/devices/attackshark-r5-ultra.png differ diff --git a/public/devices/crdrako-ko-one.png b/public/devices/crdrako-ko-one.png new file mode 100644 index 0000000..312d425 Binary files /dev/null and b/public/devices/crdrako-ko-one.png differ diff --git a/public/devices/endgame-gear-op1-8k.png b/public/devices/endgame-gear-op1-8k.png new file mode 100644 index 0000000..778436f Binary files /dev/null and b/public/devices/endgame-gear-op1-8k.png differ diff --git a/public/devices/keychron-nape-pro.png b/public/devices/keychron-nape-pro.png new file mode 100644 index 0000000..c8a2fac Binary files /dev/null and b/public/devices/keychron-nape-pro.png differ diff --git a/public/devices/logitech-g502-x-plus.png b/public/devices/logitech-g502-x-plus.png new file mode 100644 index 0000000..45beb18 Binary files /dev/null and b/public/devices/logitech-g502-x-plus.png differ diff --git a/public/devices/logitech-g502-x.png b/public/devices/logitech-g502-x.png new file mode 100644 index 0000000..116c831 Binary files /dev/null and b/public/devices/logitech-g502-x.png differ diff --git a/public/devices/logitech-g502.png b/public/devices/logitech-g502.png new file mode 100644 index 0000000..60b4303 Binary files /dev/null and b/public/devices/logitech-g502.png differ diff --git a/public/devices/logitech-mx-master-3s.png b/public/devices/logitech-mx-master-3s.png new file mode 100644 index 0000000..3abb34d Binary files /dev/null and b/public/devices/logitech-mx-master-3s.png differ diff --git a/public/devices/logitech-mx-master-4.png b/public/devices/logitech-mx-master-4.png new file mode 100644 index 0000000..e0d8403 Binary files /dev/null and b/public/devices/logitech-mx-master-4.png differ diff --git a/public/devices/logitech-pro-x-superlight-2c.png b/public/devices/logitech-pro-x-superlight-2c.png new file mode 100644 index 0000000..7a19c30 Binary files /dev/null and b/public/devices/logitech-pro-x-superlight-2c.png differ diff --git a/public/devices/logitech-pro-x2-superstrike.png b/public/devices/logitech-pro-x2-superstrike.png new file mode 100644 index 0000000..ac5a4f4 Binary files /dev/null and b/public/devices/logitech-pro-x2-superstrike.png differ diff --git a/public/devices/ninjutso-sora-v2.png b/public/devices/ninjutso-sora-v2.png new file mode 100644 index 0000000..e01e975 Binary files /dev/null and b/public/devices/ninjutso-sora-v2.png differ diff --git a/public/devices/ninjutso-sora-v3.png b/public/devices/ninjutso-sora-v3.png new file mode 100644 index 0000000..d548db6 Binary files /dev/null and b/public/devices/ninjutso-sora-v3.png differ diff --git a/public/devices/ninjutso-ten.png b/public/devices/ninjutso-ten.png new file mode 100644 index 0000000..2b26bfb Binary files /dev/null and b/public/devices/ninjutso-ten.png differ diff --git a/public/devices/pulsar-pro-dongle.png b/public/devices/pulsar-pro-dongle.png new file mode 100644 index 0000000..0d3ed36 Binary files /dev/null and b/public/devices/pulsar-pro-dongle.png differ diff --git a/public/devices/pulsar-x2-v2.png b/public/devices/pulsar-x2-v2.png new file mode 100644 index 0000000..764ca95 Binary files /dev/null and b/public/devices/pulsar-x2-v2.png differ diff --git a/public/devices/razer-cobra.webp b/public/devices/razer-cobra.webp new file mode 100644 index 0000000..e5012cd Binary files /dev/null and b/public/devices/razer-cobra.webp differ diff --git a/public/devices/razer-viper-mini.webp b/public/devices/razer-viper-mini.webp new file mode 100644 index 0000000..063628b Binary files /dev/null and b/public/devices/razer-viper-mini.webp differ diff --git a/public/devices/razer-viper-v2-pro.png b/public/devices/razer-viper-v2-pro.png new file mode 100644 index 0000000..441d963 Binary files /dev/null and b/public/devices/razer-viper-v2-pro.png differ diff --git a/public/devices/razer-viper-v3-pro.png b/public/devices/razer-viper-v3-pro.png new file mode 100644 index 0000000..1bd0252 Binary files /dev/null and b/public/devices/razer-viper-v3-pro.png differ diff --git a/public/devices/razer-viper.webp b/public/devices/razer-viper.webp new file mode 100644 index 0000000..a909838 Binary files /dev/null and b/public/devices/razer-viper.webp differ diff --git a/public/devices/teevolution-terra-pro.png b/public/devices/teevolution-terra-pro.png new file mode 100644 index 0000000..d54d2fa Binary files /dev/null and b/public/devices/teevolution-terra-pro.png differ diff --git a/public/devices/unknown-device.png b/public/devices/unknown-device.png new file mode 100644 index 0000000..cbfb885 Binary files /dev/null and b/public/devices/unknown-device.png differ diff --git a/public/devices/wlmouse-beast-g.png b/public/devices/wlmouse-beast-g.png new file mode 100644 index 0000000..4f946c3 Binary files /dev/null and b/public/devices/wlmouse-beast-g.png differ diff --git a/public/devices/zaunkoenig-m3k.png b/public/devices/zaunkoenig-m3k.png new file mode 100644 index 0000000..c1a466b Binary files /dev/null and b/public/devices/zaunkoenig-m3k.png differ diff --git a/public/favicon-32.png b/public/favicon-32.png deleted file mode 100644 index bc9dd90..0000000 Binary files a/public/favicon-32.png and /dev/null differ diff --git a/public/favicon-dark.svg b/public/favicon-dark.svg deleted file mode 100644 index 0cf367a..0000000 --- a/public/favicon-dark.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/public/favicon.ico b/public/favicon.ico index 2816250..ff75db3 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/favicon.png b/public/favicon.png deleted file mode 100644 index 94cafb5..0000000 Binary files a/public/favicon.png and /dev/null differ diff --git a/public/icon-512.png b/public/icon-512.png deleted file mode 100644 index 94cafb5..0000000 Binary files a/public/icon-512.png and /dev/null differ diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..1ae1dcf Binary files /dev/null and b/public/logo.png differ diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..c08dc63 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,36 @@ +{ + "id": "/", + "name": "OpenMouse", + "short_name": "OpenMouse", + "description": "Manage supported gaming mice in one place — DPI, polling rate, battery, and sensor settings.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#09090b", + "theme_color": "#09090b", + "categories": ["utilities", "productivity"], + "icons": [ + { + "src": "/favicon-32.png", + "sizes": "32x32", + "type": "image/png" + }, + { + "src": "/apple-touch-icon.png", + "sizes": "180x180", + "type": "image/png" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/public/miku-mascot.gif b/public/miku-mascot.gif new file mode 100644 index 0000000..6a8c101 Binary files /dev/null and b/public/miku-mascot.gif differ diff --git a/public/mouse-preview.svg b/public/mouse-preview.svg deleted file mode 100644 index 9f3a8ba..0000000 --- a/public/mouse-preview.svg +++ /dev/null @@ -1,38 +0,0 @@ - - Gaming mouse - A simple top view of a black wireless gaming mouse. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/og-dark.png b/public/og-dark.png deleted file mode 100644 index e5cfe04..0000000 Binary files a/public/og-dark.png and /dev/null differ diff --git a/public/og-dark.svg b/public/og-dark.svg deleted file mode 100644 index 4736c5b..0000000 --- a/public/og-dark.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - OpenMouse - All your mice. One control panel. - - - CONNECTED - diff --git a/public/og-preview-dark.png b/public/og-preview-dark.png deleted file mode 100644 index d370203..0000000 Binary files a/public/og-preview-dark.png and /dev/null differ diff --git a/public/og.png b/public/og.png deleted file mode 100644 index 154c262..0000000 Binary files a/public/og.png and /dev/null differ diff --git a/public/robots.txt b/public/robots.txt deleted file mode 100644 index 9913739..0000000 --- a/public/robots.txt +++ /dev/null @@ -1,4 +0,0 @@ -User-agent: * -Allow: / - -Sitemap: https://openmouse.app/sitemap.xml diff --git a/public/sitemap.xml b/public/sitemap.xml deleted file mode 100644 index c168107..0000000 --- a/public/sitemap.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - https://openmouse.app/ - 2026-07-29 - weekly - 1.0 - - - https://openmouse.app/demo.html - 2026-07-29 - monthly - 0.7 - - diff --git a/public/superlight-2c-black.png b/public/superlight-2c-black.png deleted file mode 100644 index f7c16a0..0000000 Binary files a/public/superlight-2c-black.png and /dev/null differ diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..6be9a45 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,55 @@ +const CACHE = "openmouse"; +const CACHEABLE_HOSTS = ["fonts.googleapis.com", "fonts.gstatic.com"]; + +function isImmutable(url) { + if (url.origin === self.location.origin) { + return url.pathname.startsWith("/assets/") || url.pathname === "/favicon.ico"; + } + return CACHEABLE_HOSTS.includes(url.hostname); +} + +self.addEventListener("install", (event) => { + event.waitUntil((async () => { + const cache = await caches.open(CACHE); + await cache.addAll(["/index.html", "/favicon.ico"]).catch(() => undefined); + await self.skipWaiting(); + })()); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil((async () => { + const names = await caches.keys(); + await Promise.all(names.filter((name) => name !== CACHE).map((name) => caches.delete(name))); + await self.clients.claim(); + })()); +}); + +self.addEventListener("fetch", (event) => { + const request = event.request; + if (request.method !== "GET") return; + + if (request.mode === "navigate") { + event.respondWith((async () => { + try { + const response = await fetch(request); + (await caches.open(CACHE)).put("/index.html", response.clone()); + return response; + } catch { + return await caches.match("/index.html") ?? Response.error(); + } + })()); + return; + } + + if (!isImmutable(new URL(request.url))) return; + + event.respondWith((async () => { + const cached = await caches.match(request); + if (cached) return cached; + const response = await fetch(request); + if (response.ok || response.type === "opaque") { + (await caches.open(CACHE)).put(request, response.clone()); + } + return response; + })()); +}); diff --git a/public/x-banner-v2.png b/public/x-banner-v2.png deleted file mode 100644 index 9189f6b..0000000 Binary files a/public/x-banner-v2.png and /dev/null differ diff --git a/public/x-banner-v2.svg b/public/x-banner-v2.svg deleted file mode 100644 index 0e97c73..0000000 --- a/public/x-banner-v2.svg +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - OpenMouse - All your mice. One control panel. - - - - - EARLY ACCESS SOON - - - - OPENMOUSE.APP - diff --git a/public/x-banner-v3.png b/public/x-banner-v3.png deleted file mode 100644 index be54103..0000000 Binary files a/public/x-banner-v3.png and /dev/null differ diff --git a/public/x-banner-v3.svg b/public/x-banner-v3.svg deleted file mode 100644 index fd51fda..0000000 --- a/public/x-banner-v3.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - OpenMouse - diff --git a/public/x-banner.png b/public/x-banner.png deleted file mode 100644 index 65505eb..0000000 Binary files a/public/x-banner.png and /dev/null differ diff --git a/public/x-profile.png b/public/x-profile.png deleted file mode 100644 index bd3c1d7..0000000 Binary files a/public/x-profile.png and /dev/null differ diff --git a/public/x-profile.svg b/public/x-profile.svg deleted file mode 100644 index 6bd8c81..0000000 --- a/public/x-profile.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/src/app/App.tsx b/src/app/App.tsx new file mode 100644 index 0000000..b1a389e --- /dev/null +++ b/src/app/App.tsx @@ -0,0 +1,397 @@ +import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; +import * as control from "../device/controller"; +import { WORKSPACE_TAB_ORDER, type ControlSnapshot, type WorkspaceTab } from "../device/types"; +import { interfaceThemeSlug } from "../interface-preferences"; +import { CaptureDialog } from "./CaptureDialog"; +import { Diagnostics, LogitechDetails } from "./Diagnostics"; +import { InterfaceSettings } from "./InterfaceSettings"; +import { PendingBar } from "./PendingBar"; +import { Profiles } from "./Profiles"; +import { Sidebar } from "./Sidebar"; +import { Superstrike } from "./Superstrike"; +import { SupportRequestsDialog } from "./SupportRequestsDialog"; +import { ToastHost } from "./Toasts"; +import { useControl } from "./useControl"; +import { BatteryIcon } from "./ui"; +import { DpiCard } from "./cards/DpiCard"; +import { LightforceCard, PollingCard, SensorCard } from "./cards/PerformanceCards"; +import { LightingCard } from "./cards/LightingCard"; +import { MxMasterButtonsCard, MxMasterCards } from "./cards/MxMasterCards"; +import { + DebounceCard, + EggButtonCard, + EggCpiCard, + EggFilterCard, + EggPollingCard, + EggSpdtCard, + FinalmouseCard, + LowPowerCard, + NinjutsoClickCard, + NinjutsoSensorCard, + ProcessingCard, + RazerButtonCard, + PulsarProCard, + SignalCard, + SleepCard, + TeevolutionDpiLightingCard, +} from "./cards/AdvancedCards"; +import { cardAvailability } from "./cards/availability"; + +function on(tab: WorkspaceTab, tabs: readonly WorkspaceTab[]): boolean { + return tabs.includes(tab); +} + +function DeviceOverview({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { + const status = snapshot.status; + if (!status) return null; + const isWired = status.connectionType === "Wired"; + const showBattery = !snapshot.traits.eggControls + && (status.ui?.forceShowBattery || !isWired || status.batteryPercent !== null); + const stats = showBattery ? 3 : 2; + const supportsDongleLed = status.brand === "Pulsar" + && status.dongleLedEnabled !== null + && status.dongleLedEnabled !== undefined; + + return ( +
+ {showBattery ? ( +
+ BATTERY + + + + + {status.batteryPercent === null ? "—" : `${status.batteryPercent}%`} + + {control.batteryDetail(status)} +
+ ) : null} +
+ FIRMWARE + {status.firmware[0] ?? "—"} + + {status.firmware.length > 1 + ? status.firmware.slice(1).join(" · ") + : status.firmware.length === 1 + ? "Firmware reported by mouse" + : "Not reported"} + +
+
+ CONNECTION + {status.connectionType ?? "Wireless"} + + {status.connectionDetail + ?? (status.activeProfile ? `2.4 GHz · Profile ${status.activeProfile}` : "2.4 GHz receiver")} + + {supportsDongleLed ? ( + + ) : null} +
+
+ ); +} + +function Workspace({ + snapshot, + onOpenCapture, +}: { + snapshot: ControlSnapshot; + onOpenCapture: () => void; +}): ReactNode { + const status = snapshot.status; + const tab = snapshot.workspaceTab; + if (!status) return null; + const has = cardAvailability(snapshot); + + const show = (available: boolean, tabs: readonly WorkspaceTab[]): boolean => available && on(tab, tabs); + + const performance = [ + show(has.dpi, ["performance"]) ? : null, + show(has.polling, ["performance"]) ? : null, + show(has.sensor, ["performance"]) ? : null, + show(has.lightforce, ["buttons"]) ? : null, + ].filter((node) => node !== null); + + const advanced = [ + show(has.signal, ["advanced"]) ? : null, + show(has.debounce, ["buttons"]) ? : null, + show(has.sleep, ["advanced"]) ? : null, + show(has.lightingAdvanced, ["advanced"]) + ? : null, + show(has.ninjutsoSensor, ["performance"]) + ? : null, + show(has.ninjutsoClick, ["performance"]) + ? : null, + show(has.lowPower, ["advanced"]) ? : null, + show(has.processing, ["performance"]) ? : null, + show(has.finalmouse, ["advanced"]) ? : null, + show(has.eggFilter, ["performance"]) ? : null, + show(has.eggSpdt, ["buttons"]) ? : null, + show(has.eggPolling, ["performance"]) ? : null, + show(has.eggCpi, ["performance"]) ? : null, + show(has.eggButtons, ["buttons"]) ? : null, + show(has.razerButtons, ["buttons"]) ? : null, + show(has.mxMasterButtons, ["buttons"]) + ? : null, + show(has.pulsarPro, ["profiles"]) ? : null, + ].filter((node) => node !== null); + + const lightingZones = status.lightingZones?.length ? status.lightingZones : status.lighting ? [status.lighting] : []; + const lighting = [ + show(has.lighting, ["lighting"]) + ? : null, + show(has.teevolutionDpiLighting, ["lighting"]) + ? : null, + ].filter((node) => node !== null); + + const showProfiles = show(has.profiles, ["profiles"]); + const showSuperstrike = show(has.superstrike, ["buttons"]); + const showLogitechDetails = show(has.logitechDetails, ["advanced"]); + const showMxMaster = on(tab, ["advanced"]) + && (status.hapticIntensity != null || status.wheelMode != null || status.friendlyName != null || status.hostCount != null); + const showDiagnostics = on(tab, ["advanced"]); + const showOverview = on(tab, ["overview"]); + + const anyPanel = performance.length > 0 || advanced.length > 0 || lighting.length > 0 + || showProfiles || showSuperstrike || showLogitechDetails || showMxMaster || showDiagnostics || showOverview; + + const slotsAvailable = snapshot.profile.slotsAvailable; + const stagesAvailable = Boolean(status.ui?.dpiStageEditor) + && Array.isArray(status.dpiStages) + && status.dpiStages.length > 0 + && !slotsAvailable; + const showSeparateDpiAxes = snapshot.traits.logitech + && status.supportsSeparateDpiAxes === true + && !slotsAvailable; + + return ( + <> + {showOverview ? : null} + + {!anyPanel ? ( +
+

+ {`${tab[0].toUpperCase()}${tab.slice(1)}`} controls are not available for this mouse. +

+ Choose another tab to continue configuring the device. +
+ ) : null} + + {showProfiles ? : null} + + {performance.length > 0 ? ( +
+ {performance} +
+ ) : null} + + {lighting.length > 0 ? ( +
+ {lighting} +
+ ) : null} + + {showLogitechDetails ? : null} + {showMxMaster ? ( +
+ +
+ ) : null} + {showSuperstrike ? : null} + + {advanced.length > 0 ? ( +
+ {advanced} +
+ ) : null} + + {on(tab, ["advanced"]) ? ( + + ) : null} + + {showDiagnostics ? : null} + + ); +} + +export function App(): ReactNode { + const snapshot = useControl(); + const panel = useRef(null); + const [captureOpen, setCaptureOpen] = useState(false); + const [supportRequestsOpen, setSupportRequestsOpen] = useState(false); + const { preferences, status } = snapshot; + + useEffect(() => { + const element = panel.current?.closest(".control-shell"); + if (!element) return; + const onWheel = (event: WheelEvent): void => { + const target = panel.current; + const source = event.target as Element; + if (!target || target.contains(source) || source.closest("dialog") || event.deltaY === 0) return; + target.scrollTop += event.deltaY; + event.preventDefault(); + }; + element.addEventListener("wheel", onWheel, { passive: false }); + return () => element.removeEventListener("wheel", onWheel); + }, []); + + useEffect(() => { + panel.current?.scrollTo({ top: 0, behavior: preferences.reducedMotion ? "auto" : "smooth" }); + }, [snapshot.workspaceTab]); + + const tabs = WORKSPACE_TAB_ORDER; + const onTabKey = (event: KeyboardEvent, current: WorkspaceTab): void => { + const index = tabs.indexOf(current); + let next: number; + if (event.key === "ArrowRight") next = (index + 1) % tabs.length; + else if (event.key === "ArrowLeft") next = (index - 1 + tabs.length) % tabs.length; + else if (event.key === "Home") next = 0; + else if (event.key === "End") next = tabs.length - 1; + else return; + event.preventDefault(); + control.setWorkspaceTab(tabs[next]); + const button = document.querySelector(`#workspace-tab-${tabs[next]}`); + button?.focus(); + }; + + return ( +
0 ? "has-pending-changes" : "", + ].filter(Boolean).join(" ")} + data-interface-theme={interfaceThemeSlug(preferences.theme)} + style={{ "--glass-intensity": preferences.glassIntensity }} + > + setSupportRequestsOpen(true)} /> + +
+
+
+
+
+

DEVICE CONTROL

+

{status?.name ?? "Connect a mouse"}

+
+
+
+ + {snapshot.deviceStatusText} +
+
+

+