diff --git a/.env.example b/.env.example index 223b92b7..f823652e 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,3 @@ -# No public build-time variables are required. The app reads no VITE_* keys at -# build time; the former Supabase and voting/Turnstile configuration was removed -# when the request catalog and protected voting were retired. +# Public PostHog browser configuration. Copy these values into a local .env file. +VITE_POSTHOG_KEY= +VITE_POSTHOG_HOST= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 306c0e58..09224bbe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,7 @@ jobs: "@openmouse/keyboard-protocol": keyboardProtocolVersion, "@openmouse/protocol": protocolVersion, "lucide-react": "^1.43.0", + "posthog-js": "^1.438.17", "preact": "^10.29.8", }; diff --git a/build/check-bundle-size.ts b/build/check-bundle-size.ts index ff234cf3..3c1644e1 100644 --- a/build/check-bundle-size.ts +++ b/build/check-bundle-size.ts @@ -1,9 +1,11 @@ import { readdirSync, statSync } from "node:fs"; import { join } from "node:path"; +// JS raised from 2_800_000: posthog-js (anonymous analytics, src/platform/analytics.ts) +// added about 340 kB; measured 3_125_781 bytes with it. const BUDGET_BYTES: Record = { ".css": 165_000, - ".js": 2_800_000, + ".js": 3_150_000, }; const ASSETS = join("dist", "assets"); diff --git a/functions/_lib/security-headers.js b/functions/_lib/security-headers.js index 2e9a6af1..51259e56 100644 --- a/functions/_lib/security-headers.js +++ b/functions/_lib/security-headers.js @@ -22,14 +22,14 @@ export const CSP = [ "default-src 'self'", - "script-src 'self'", + "script-src 'self' https://us-assets.i.posthog.com", "script-src-attr 'none'", "style-src 'self' https://fonts.googleapis.com", "style-src-attr 'none'", "font-src 'self' data: https://fonts.gstatic.com", "img-src 'self' data: blob: https://img.openmouse.app https://cdn.cloudflare.steamstatic.com https://shared.akamai.steamstatic.com https://store-images.s-microsoft.com http://127.0.0.1:17846", "media-src 'self'", - "connect-src 'self' https://api.github.com https://cdn.jsdelivr.net http://127.0.0.1:17846 ws://127.0.0.1:17846", + "connect-src 'self' https://us.i.posthog.com https://us-assets.i.posthog.com https://api.github.com https://cdn.jsdelivr.net http://127.0.0.1:17846 ws://127.0.0.1:17846", "worker-src 'self'", "manifest-src 'self'", "object-src 'none'", diff --git a/package-lock.json b/package-lock.json index e41a201b..43e1251d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@openmouse/keyboard-protocol": "1.1.0", "@openmouse/protocol": "0.45.2", "lucide-react": "^1.43.0", + "posthog-js": "^1.438.17", "preact": "^10.29.8" }, "devDependencies": { @@ -499,6 +500,32 @@ "node": ">=20" } }, + "node_modules/@posthog/browser-common": { + "version": "0.9.8", + "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.9.8.tgz", + "integrity": "sha512-4+sQauvPkGfjQrz9whDCzV5I9wI9UTajIjukpRENhULP1a/uYyNR/LAa4CuX8XuIogtVpAYc+7iIZymwNSTOSA==", + "license": "MIT", + "dependencies": { + "@posthog/core": "^1.59.4", + "@posthog/types": "^1.416.0", + "preact": "^10.29.3" + } + }, + "node_modules/@posthog/core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.60.0.tgz", + "integrity": "sha512-d8jmcvJPmgjkcW0kVbIkDXcD3jJ+y/wS6oA4PeEGL914Rj8iM9HfB6VWvMWc6W57UJwpI9+L0hFJkizraVBA1Q==", + "license": "MIT", + "dependencies": { + "@posthog/types": "^1.416.0" + } + }, + "node_modules/@posthog/types": { + "version": "1.416.0", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.416.0.tgz", + "integrity": "sha512-0/9+02s/0uDyrH4G/qUg1yyl6gXUg8Uuxw/PlDad3KLzo9FFS9zS/kAQaEshx/saVGupj1aAy/4EUBxWisBogw==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", @@ -895,6 +922,36 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/core-js": { + "version": "3.51.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.51.0.tgz", + "integrity": "sha512-7bDKx/5jm5MxzsXxc93g6Z2+4ZsuT/MddrfRmTDJNAkWsYWHU8EVNEsBiIEWnMAMCHMmRfUEjkuo3jeNBOIHRw==", + "hasInstallScript": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/dompurify": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", + "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -955,6 +1012,12 @@ } } }, + "node_modules/fflate": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz", + "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1047,6 +1110,36 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/posthog-js": { + "version": "1.438.17", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.438.17.tgz", + "integrity": "sha512-BM0VHG/DTppBUX9TjQ3aX/+xKsmrMgYTWyGrhxL2GV3oWGleyDLUymjGBZHWXlS7bIMHMMJloSWWXBmZQGp5NQ==", + "license": "(Apache-2.0 AND MIT)", + "dependencies": { + "@posthog/browser-common": "^0.9.8", + "@posthog/core": "^1.59.4", + "@posthog/types": "^1.416.0", + "core-js": "^3.49.0", + "dompurify": "^3.4.13", + "fflate": "^0.4.8", + "preact": "^10.29.3", + "query-selector-shadow-dom": "^1.0.1", + "web-vitals": "^6.2.1", + "web-vitals-soft-navs": "npm:web-vitals@6.2.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/preact": { "version": "10.29.8", "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", @@ -1065,6 +1158,12 @@ } } }, + "node_modules/query-selector-shadow-dom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", + "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", + "license": "MIT" + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -1236,6 +1335,19 @@ "optional": true } } + }, + "node_modules/web-vitals": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.3.tgz", + "integrity": "sha512-Dmy+vdO1UPMqXiAtZSNayhl4f794Bxfd94W4HIj2HlYtczEQfEOon969memlcpcHrTuOKLa5baFjX7WbgmOq+A==", + "license": "Apache-2.0" + }, + "node_modules/web-vitals-soft-navs": { + "name": "web-vitals", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", + "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", + "license": "Apache-2.0" } } } diff --git a/package.json b/package.json index e27cd7fd..52889f6e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@openmouse/keyboard-protocol": "1.1.0", "@openmouse/protocol": "0.45.2", "lucide-react": "^1.43.0", + "posthog-js": "^1.438.17", "preact": "^10.29.8" }, "devDependencies": { diff --git a/public/_headers b/public/_headers index 31ef68a7..cf01f37a 100644 --- a/public/_headers +++ b/public/_headers @@ -22,7 +22,7 @@ # isolation buys the app nothing it needs. /* - Content-Security-Policy: default-src 'self'; script-src 'self'; script-src-attr 'none'; style-src 'self' https://fonts.googleapis.com; style-src-attr 'none'; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: https://img.openmouse.app https://cdn.cloudflare.steamstatic.com https://shared.akamai.steamstatic.com https://store-images.s-microsoft.com http://127.0.0.1:17846; media-src 'self'; connect-src 'self' https://api.github.com https://cdn.jsdelivr.net http://127.0.0.1:17846 ws://127.0.0.1:17846; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; require-trusted-types-for 'script'; trusted-types openmouse + Content-Security-Policy: default-src 'self'; script-src 'self' https://us-assets.i.posthog.com; script-src-attr 'none'; style-src 'self' https://fonts.googleapis.com; style-src-attr 'none'; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: https://img.openmouse.app https://cdn.cloudflare.steamstatic.com https://shared.akamai.steamstatic.com https://store-images.s-microsoft.com http://127.0.0.1:17846; media-src 'self'; connect-src 'self' https://us.i.posthog.com https://us-assets.i.posthog.com https://api.github.com https://cdn.jsdelivr.net http://127.0.0.1:17846 ws://127.0.0.1:17846; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; require-trusted-types-for 'script'; trusted-types openmouse X-Content-Type-Options: nosniff X-Frame-Options: DENY Referrer-Policy: strict-origin-when-cross-origin diff --git a/src/device/controller.ts b/src/device/controller.ts index 7ce0bbf8..4eaf8cfc 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -1,4 +1,5 @@ import { emptyKeyboardWorkspace } from "./keyboard-workspace.ts"; +import { track, deviceProps, scrubError } from "../platform/analytics"; import type { KeyboardCommand } from "./keyboard-workspace.ts"; import { cachedBatterySamples, estimateBatteryTime, estimateFromRatedLife, ratedBattery, recordBatterySample, type BatteryMode } from "../features/devices/battery-history"; import { bridgeEmptyResultMessage, isBridgeHidActive } from "../features/bridge/bridge-hid"; @@ -2244,6 +2245,7 @@ export async function flashPendingChanges(): Promise { : st("ctl.flashedMany", { n: written }); setReadStatus(flashed); pushToast("success", flashed); + track("pending_changes_applied", { change_count: written }); } function endDeviceWrite(): void { @@ -2366,7 +2368,15 @@ function recordDiagnosticCommand(command: string): void { renderDeviceDiagnostics(latestDiagnosticStatus); } -function recordDiagnosticError(error: unknown, fallback: string): void { +/** Model-level facts about the active device for analytics: no serials or ids. */ +function activeDeviceFacts(): { vendorId: number; productId: number; productName: string } | undefined { + return activeDevice + ? { vendorId: activeDevice.vendorId, productId: activeDevice.productId, productName: activeDevice.productName } + : undefined; +} + +function recordDiagnosticError(error: unknown, fallback: string, report = true): void { + if (report) track("device_error", { ...deviceProps(activeDeviceFacts()), ...scrubError(error) }); lastDiagnosticError = diagnosticErrorMessage(error, fallback); markHidActivity(lastDiagnosticError, { failed: true }); renderDeviceDiagnostics(latestDiagnosticStatus); @@ -2619,6 +2629,7 @@ export function downloadDiagnostics(): void { link.click(); URL.revokeObjectURL(url); diagnosticDownloadStatus = st("ctl.savedName", { name }); + track("diagnostics_downloaded"); emit(); } @@ -3651,7 +3662,9 @@ function handleHidDisconnect(event: HIDConnectionEvent): void { } /** Ask the browser for a device, then pick the best keyboard or mouse client. */ -async function requestSupportedClient(): Promise { +async function requestSupportedClient( + onSelected?: (device: HIDDevice) => void, +): Promise { if (!navigator.hid) throw new Error(st("ctl.noWebHid")); const devices = await navigator.hid.requestDevice({ filters: HID_FILTERS }); if (devices.length === 0) { @@ -3667,6 +3680,9 @@ async function requestSupportedClient(): Promise eggWeIsSupported(device))) { const weClient = eggWeFromAuthorized(await eggWeAuthorizedPool(devices)); @@ -3764,8 +3780,11 @@ export async function connect(): Promise { readStatus = st("ctl.choosePrompt"); emit(); + let attempted: { vendorId: number; productId: number; productName: string } | undefined; try { - const client = await requestSupportedClient(); + const client = await requestSupportedClient((device) => { + attempted = { vendorId: device.vendorId, productId: device.productId, productName: device.productName }; + }); if (!client) { deviceStatusText = st("ctl.notConnected"); readStatus = st("ctl.noSelection"); @@ -3779,10 +3798,15 @@ export async function connect(): Promise { }); emit(); await activateClient(client); + track("device_connected", deviceProps(attempted)); } catch (error) { const message = error instanceof Error ? error.message : "Unable to read the mouse."; const wrongDevice = error instanceof NotAMouseError; - if (!wrongDevice) recordDiagnosticError(error, message); + if (!wrongDevice) recordDiagnosticError(error, message, false); + track(wrongDevice ? "device_not_a_mouse" : "device_connection_failed", { + ...deviceProps(attempted), + ...scrubError(error), + }); if (eggClient() ?? eggWeClient()) { await active?.close().catch(() => undefined); active = null; diff --git a/src/features/games/GameRequestDialog.tsx b/src/features/games/GameRequestDialog.tsx index c93862df..ee8cc61b 100644 --- a/src/features/games/GameRequestDialog.tsx +++ b/src/features/games/GameRequestDialog.tsx @@ -1,4 +1,5 @@ import { displayText } from "../../i18n/index"; +import { track } from "../../platform/analytics"; import { useUiLocale } from "../../shared/ui-locale"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { Check, Copy, Gamepad2 } from "lucide-react"; @@ -215,6 +216,7 @@ export function GameRequestDialog({ open, onClose, locale = "en", bridgeActive, /* the cooldown is a courtesy; storage failures just skip it */ } control.pushToast("success", t(locale, "gamereq.sent"), tp(locale, "gamereq.sentDetail", { name: trimmedName })); + track("game_request_submitted", { request_kind: kind }); setName(""); setPath(""); onClose(); diff --git a/src/features/hardware-test/HardwareTestPage.tsx b/src/features/hardware-test/HardwareTestPage.tsx index 53bab7f6..e249a2a0 100644 --- a/src/features/hardware-test/HardwareTestPage.tsx +++ b/src/features/hardware-test/HardwareTestPage.tsx @@ -1,4 +1,5 @@ import { displayText } from "../../i18n/index"; +import { track } from "../../platform/analytics"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import * as control from "../../device/controller"; import type { ControlSnapshot } from "../../device/types"; @@ -106,6 +107,7 @@ export function HardwareTestPage({ snapshot }: { snapshot: ControlSnapshot }): R setStages([]); setLiveSample(null); setSamplingElapsedMs(0); + track("hardware_test_started"); void (async () => { let info = deviceInfoFromSnapshot(snapshot); @@ -372,6 +374,7 @@ export function HardwareTestPage({ snapshot }: { snapshot: ControlSnapshot }): R build: snapshot.buildLabel, supportedPage: crosscheckSupportedDevices(info, verdict), }); + track("hardware_test_completed", { verdict }); })().catch(() => { addStage({ kind: "check", label: "The suite crashed — restart the test", state: "fail" }); setReport(null); @@ -393,6 +396,7 @@ export function HardwareTestPage({ snapshot }: { snapshot: ControlSnapshot }): R anchor.click(); anchor.remove(); URL.revokeObjectURL(url); + track("hardware_test_report_downloaded"); }, [report, running]); // Opens the WebHID/Bridge device picker and connects the chosen mouse. diff --git a/src/features/settings/InterfaceSettings.tsx b/src/features/settings/InterfaceSettings.tsx index 6c0b1f40..522be54c 100644 --- a/src/features/settings/InterfaceSettings.tsx +++ b/src/features/settings/InterfaceSettings.tsx @@ -1,4 +1,5 @@ import { displayText } from "../../i18n/index"; +import { analyticsAvailable, analyticsEnabled, setAnalyticsEnabled, track } from "../../platform/analytics"; import { Globe, RotateCcw } from "lucide-react"; import { useState, type ReactNode } from "react"; import * as control from "../../device/controller"; @@ -48,6 +49,7 @@ export function ProfileKeyFields({ snapshot }: { snapshot: ControlSnapshot }): R onClick={() => { if (!key) return; void navigator.clipboard.writeText(key).then(() => { + track("profile_key_copied"); setCopied(true); setTimeout(() => setCopied(false), 1500); }); @@ -116,6 +118,7 @@ function SettingsGroup({ id, title, children }: { id: string; title: string; chi export function InterfaceSettings({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { const preferences = snapshot.preferences; const locale = preferences.locale; + const [analyticsOn, setAnalyticsOn] = useState(analyticsEnabled); const set = (key: K) => (value: (typeof preferences)[K]): void => control.setPreference(key, value); @@ -192,6 +195,12 @@ export function InterfaceSettings({ snapshot }: { snapshot: ControlSnapshot }): {toggle("interface-sounds", t(locale, "set.soundTitle"), t(locale, "set.soundDescription"), preferences.enabledSounds, set("enabledSounds"))} {toggle("interface-expand-sections", t(locale, "set.advancedEditors"), t(locale, "set.advancedEditorsBody"), preferences.expandSections, set("expandSections"))} {toggle("interface-show-experimental", t(locale, "set.experimentalTitle"), t(locale, "set.experimentalBody"), preferences.showExperimental, set("showExperimental"))} + {analyticsAvailable + ? toggle("interface-analytics", t(locale, "set.analytics"), t(locale, "set.analyticsBody"), analyticsOn, (next) => { + setAnalyticsEnabled(next); + setAnalyticsOn(next); + }) + : null} diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 4cde08fc..8664ca9a 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -251,6 +251,8 @@ const en = { "set.writes": "WRITES", "set.instantFlash": "Instant flash", "set.instantFlashBody": "Write each change to the mouse as soon as you make it, instead of staging it for the flash bar.", + "set.analytics": "Share anonymous diagnostics", + "set.analyticsBody": "Send anonymous usage and device error reports so supported devices can be fixed faster. No IP address, serial number or personal data is sent.", "set.flashImmediately": "Flash immediately", "set.sections": "SECTIONS", "set.advancedEditors": "Advanced editors", diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts index c760d046..04abbb35 100644 --- a/src/i18n/locales/ar.ts +++ b/src/i18n/locales/ar.ts @@ -193,6 +193,8 @@ export const ar: Record = { "set.writes": "الكتابة", "set.instantFlash": "التثبيت الفوري", "set.instantFlashBody": "اكتب كل تغيير إلى الماوس فور إجرائه، بدلًا من تجهيزه لشريط التثبيت.", + "set.analytics": "مشاركة بيانات التشخيص المجهولة", + "set.analyticsBody": "إرسال تقارير مجهولة عن الاستخدام وأخطاء الأجهزة لإصلاح الأجهزة المدعومة بشكل أسرع. لا يتم إرسال عنوان IP أو الرقم التسلسلي أو أي بيانات شخصية.", "set.flashImmediately": "ثبّت فورًا", "set.sections": "الأقسام", "set.advancedEditors": "المحررات المتقدمة", diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index eb5b34dc..9a6d5839 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -194,6 +194,8 @@ export const de: Record = { "set.writes": "SCHREIBVORGÄNGE", "set.instantFlash": "Sofortiges Flashen", "set.instantFlashBody": "Schreibt jede Änderung sofort auf die Maus, statt sie für die Flash-Leiste vorzubereiten.", + "set.analytics": "Anonyme Diagnosedaten teilen", + "set.analyticsBody": "Anonyme Nutzungs- und Gerätefehlerberichte senden, damit unterstützte Geräte schneller verbessert werden. Es werden weder IP-Adresse noch Seriennummer oder persönliche Daten gesendet.", "set.flashImmediately": "Sofort flashen", "set.sections": "ABSCHNITTE", "set.advancedEditors": "Erweiterte Editoren", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index d4e9e8b1..71d25e6f 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -194,6 +194,8 @@ export const es: Record = { "set.writes": "ESCRITURAS", "set.instantFlash": "Grabado instantáneo", "set.instantFlashBody": "Escribe cada cambio en el mouse en cuanto lo haces, en lugar de prepararlo en la barra de grabado.", + "set.analytics": "Compartir diagnósticos anónimos", + "set.analyticsBody": "Envía informes anónimos de uso y errores de dispositivos para corregir antes los dispositivos compatibles. No se envía la dirección IP, el número de serie ni datos personales.", "set.flashImmediately": "Grabar inmediatamente", "set.sections": "SECCIONES", "set.advancedEditors": "Editores avanzados", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index b27d3941..826a0c2b 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -194,6 +194,8 @@ export const fr: Record = { "set.writes": "ÉCRITURES", "set.instantFlash": "Gravure instantanée", "set.instantFlashBody": "Écrit chaque modification sur la souris dès que vous la faites, au lieu de la préparer pour la barre de gravure.", + "set.analytics": "Partager des diagnostics anonymes", + "set.analyticsBody": "Envoie des rapports anonymes d'utilisation et d'erreurs d'appareil pour corriger plus vite les appareils pris en charge. Aucune adresse IP, aucun numéro de série ni donnée personnelle n'est envoyé.", "set.flashImmediately": "Graver immédiatement", "set.sections": "SECTIONS", "set.advancedEditors": "Éditeurs avancés", diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index 7dfc9922..75bf05ca 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -194,6 +194,8 @@ export const ja: Record = { "set.writes": "書き込み", "set.instantFlash": "即時書き込み", "set.instantFlashBody": "変更を書き込みバーに保留せず、変更するたびにすぐマウスへ書き込みます。", + "set.analytics": "匿名の診断情報を共有", + "set.analyticsBody": "対応デバイスをより早く改善するため、匿名の利用状況とデバイスエラーのレポートを送信します。IPアドレス、シリアル番号、個人情報は送信されません。", "set.flashImmediately": "即座に書き込む", "set.sections": "セクション", "set.advancedEditors": "詳細エディター", diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index 73534909..a7c114b5 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -194,6 +194,8 @@ export const ko: Record = { "set.writes": "쓰기", "set.instantFlash": "즉시 기록", "set.instantFlashBody": "변경 사항을 기록 대기열에 두지 않고, 변경할 때마다 바로 마우스에 기록합니다.", + "set.analytics": "익명 진단 정보 공유", + "set.analyticsBody": "지원 기기를 더 빨리 개선할 수 있도록 익명의 사용 및 기기 오류 보고서를 보냅니다. IP 주소, 일련번호, 개인 정보는 전송되지 않습니다.", "set.flashImmediately": "즉시 기록", "set.sections": "섹션", "set.advancedEditors": "고급 편집기", diff --git a/src/i18n/locales/pt.ts b/src/i18n/locales/pt.ts index 6e67ec20..74442585 100644 --- a/src/i18n/locales/pt.ts +++ b/src/i18n/locales/pt.ts @@ -194,6 +194,8 @@ export const pt: Record = { "set.writes": "GRAVAÇÃO", "set.instantFlash": "Gravação instantânea", "set.instantFlashBody": "Grava cada mudança no mouse assim que você a faz, em vez de acumular para a barra de aplicação.", + "set.analytics": "Partilhar diagnósticos anónimos", + "set.analyticsBody": "Envia relatórios anónimos de utilização e de erros de dispositivos para corrigir mais depressa os dispositivos suportados. Não é enviado endereço IP, número de série nem dados pessoais.", "set.flashImmediately": "Gravar na hora", "set.sections": "SEÇÕES", "set.advancedEditors": "Editores avançados", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index e3e6c766..45c26415 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -193,6 +193,8 @@ export const ru: Record = { "set.writes": "ЗАПИСЬ", "set.instantFlash": "Мгновенная прошивка", "set.instantFlashBody": "Записывать каждое изменение в мышь сразу, не откладывая его в панель прошивки.", + "set.analytics": "Делиться анонимной диагностикой", + "set.analyticsBody": "Отправлять анонимные отчёты об использовании и ошибках устройств, чтобы быстрее улучшать поддержку устройств. IP-адрес, серийный номер и личные данные не передаются.", "set.flashImmediately": "Прошивать сразу", "set.sections": "РАЗДЕЛЫ", "set.advancedEditors": "Расширенные редакторы", diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts index ab6504dc..f77c6ec5 100644 --- a/src/i18n/locales/tr.ts +++ b/src/i18n/locales/tr.ts @@ -182,6 +182,8 @@ export const tr: Record = { "set.writes": "CİHAZA YAZMA", "set.instantFlash": "Anında yazma", "set.instantFlashBody": "Değişiklikleri yazma çubuğunda bekletmek yerine, yaptığınız anda fareye yazın.", + "set.analytics": "Anonim tanılama verilerini paylaş", + "set.analyticsBody": "Desteklenen cihazları daha hızlı iyileştirmek için anonim kullanım ve cihaz hatası raporları gönderir. IP adresi, seri numarası veya kişisel veri gönderilmez.", "set.flashImmediately": "Hemen cihaza yaz", "set.sections": "BÖLÜMLER", "set.advancedEditors": "Gelişmiş düzenleyiciler", diff --git a/src/i18n/locales/vi.ts b/src/i18n/locales/vi.ts index 2e0d7f36..d5e89d0a 100644 --- a/src/i18n/locales/vi.ts +++ b/src/i18n/locales/vi.ts @@ -146,6 +146,8 @@ export const vi: Record = { "set.writes": "GHI", "set.instantFlash": "Ghi tức thì", "set.instantFlashBody": "Ghi từng thay đổi vào chuột ngay khi bạn thực hiện, thay vì đưa vào hàng chờ trên thanh ghi.", + "set.analytics": "Chia sẻ chẩn đoán ẩn danh", + "set.analyticsBody": "Gửi báo cáo ẩn danh về mức sử dụng và lỗi thiết bị để cải thiện thiết bị được hỗ trợ nhanh hơn. Không gửi địa chỉ IP, số sê-ri hay dữ liệu cá nhân.", "set.flashImmediately": "Ghi ngay", "set.sections": "CÁC MỤC", "set.advancedEditors": "Trình chỉnh sửa nâng cao", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 61e4dc26..925c43fc 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -194,6 +194,8 @@ export const zh: Record = { "set.writes": "写入", "set.instantFlash": "即时写入", "set.instantFlashBody": "每次修改后立即写入鼠标,而不是先暂存到写入栏。", + "set.analytics": "共享匿名诊断信息", + "set.analyticsBody": "发送匿名的使用情况和设备错误报告,以便更快改进受支持的设备。不会发送 IP 地址、序列号或个人数据。", "set.flashImmediately": "立即写入", "set.sections": "分区", "set.advancedEditors": "高级编辑器", diff --git a/src/main.tsx b/src/main.tsx index d5fb298a..e2b84071 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import { createRoot } from "react-dom/client"; +import "./platform/analytics"; import "./styles/index.css"; import { App } from "./app/App"; import { UnsupportedNotice } from "./app/UnsupportedNotice"; diff --git a/src/platform/analytics.ts b/src/platform/analytics.ts new file mode 100644 index 00000000..4316b54a --- /dev/null +++ b/src/platform/analytics.ts @@ -0,0 +1,109 @@ +import posthogClient from "posthog-js"; + +const posthogKey = import.meta.env.VITE_POSTHOG_KEY; +const posthogHost = import.meta.env.VITE_POSTHOG_HOST; + +// Without both variables (local dev, forks, previews) analytics is a no-op. +export const posthog = posthogKey && posthogHost ? posthogClient : undefined; + +if (posthog) { + posthog.init(posthogKey!, { + api_host: posthogHost!, + defaults: "2026-05-30", + // Anonymous random id in localStorage only: counts users and return visits. + persistence: "localStorage", + ip: false, + respect_dnt: true, + autocapture: false, + disable_session_recording: true, + person_profiles: "identified_only", + capture_exceptions: true, + before_send: scrubExceptionEvent, + }); +} + +/** Whether anonymous diagnostics are being sent (on unless the user opted out). */ +export function analyticsEnabled(): boolean { + return posthog ? !posthog.has_opted_out_capturing() : false; +} + +export function setAnalyticsEnabled(enabled: boolean): void { + if (!posthog) return; + if (enabled) posthog.opt_in_capturing(); + else posthog.opt_out_capturing(); +} + +/** Whether this build reports anything, so Settings can hide a dead switch. */ +export const analyticsAvailable = posthog !== undefined; + +/** Model-level device facts only: never serials, receiver ids or user text. */ +export interface DeviceFacts { + vendorId?: number; + productId?: number; + productName?: string; + brand?: string; +} + +export function deviceProps(device: DeviceFacts | null | undefined): Record { + const props: Record = {}; + if (!device) return props; + if (device.vendorId !== undefined) props.vendor_id = device.vendorId; + if (device.productId !== undefined) props.product_id = device.productId; + if (device.productName) props.product_name = device.productName.slice(0, 80); + if (device.brand) props.brand = device.brand.slice(0, 40); + return props; +} + +/** Text with emails, paths and long hex ids (serials, receiver ids) removed, and capped. */ +function scrubText(raw: string): string { + return raw + .replace(/[\w.+-]+@[\w-]+\.[\w.]+/g, "") + .replace(/(?:[A-Za-z]:)?[\\/][\w .\\/-]{3,}/g, "") + .replace(/\b[0-9a-fA-F]{12,}\b/g, "") + .slice(0, 200); +} + +export function scrubError(error: unknown): { error_name: string; error_message: string } { + const name = error instanceof Error ? error.name : "Error"; + return { error_name: name, error_message: scrubText(error instanceof Error ? error.message : String(error)) }; +} + +interface ExceptionFrame { filename?: string; abs_path?: string; [key: string]: unknown } +interface ExceptionEntry { type?: string; value?: string; stacktrace?: { frames?: ExceptionFrame[] }; [key: string]: unknown } +interface OutgoingEvent { event: string; properties?: Record } + +/** Frame files stay only when they are the app's own bundles, so stacks still de-minify. */ +function ownFile(file: string | undefined): string | undefined { + return file && file.startsWith(location.origin) ? file.split(/[?#]/)[0] : undefined; +} + +/** Scrubs automatic `$exception` events before they leave the browser. */ +function scrubExceptionEvent(event: T): T { + if (!event || event.event !== "$exception" || !event.properties) return event; + const props = event.properties; + const list = props.$exception_list; + if (Array.isArray(list)) { + props.$exception_list = (list as ExceptionEntry[]).map((entry) => ({ + ...entry, + value: typeof entry.value === "string" ? scrubText(entry.value) : entry.value, + stacktrace: entry.stacktrace + ? { + ...entry.stacktrace, + frames: entry.stacktrace.frames?.map((frame) => ({ + ...frame, + filename: ownFile(frame.filename), + abs_path: ownFile(frame.abs_path), + })), + } + : entry.stacktrace, + })); + } + for (const key of ["$exception_message", "$exception_value"]) { + if (typeof props[key] === "string") props[key] = scrubText(props[key] as string); + } + return event; +} + +export function track(event: string, props?: Record): void { + posthog?.capture(event, props); +} diff --git a/test/security-headers.test.ts b/test/security-headers.test.ts index f0eb4eab..5fe482ae 100644 --- a/test/security-headers.test.ts +++ b/test/security-headers.test.ts @@ -42,7 +42,7 @@ test("public/_headers mirrors the canonical security header set", () => { test("the deployed CSP keeps scripts same-origin and inline-free", () => { assert.equal(directive("default-src"), "default-src 'self'"); - assert.equal(directive("script-src"), "script-src 'self'"); + assert.equal(directive("script-src"), "script-src 'self' https://us-assets.i.posthog.com"); assert.ok(!CSP.includes("'unsafe-inline'"), "no directive may allow inline code"); assert.ok(!CSP.includes("'unsafe-eval'"), "eval must stay blocked"); assert.equal(directive("script-src-attr"), "script-src-attr 'none'"); @@ -61,6 +61,8 @@ test("the deployed CSP enforces Trusted Types with the app policy", () => { test("the deployed CSP allows every origin the app talks to", () => { const connect = directive("connect-src"); for (const origin of [ + "https://us.i.posthog.com", + "https://us-assets.i.posthog.com", "https://api.github.com", "https://cdn.jsdelivr.net", "http://127.0.0.1:17846", @@ -68,6 +70,7 @@ test("the deployed CSP allows every origin the app talks to", () => { ]) { assert.ok(connect.includes(origin), `connect-src must include ${origin}`); } + assert.equal(directive("worker-src"), "worker-src 'self'"); assert.ok(directive("style-src").includes("https://fonts.googleapis.com")); assert.ok(directive("font-src").includes("https://fonts.gstatic.com")); assert.ok(directive("img-src").includes("https://img.openmouse.app"));