From 208a21e46461bccf6f455b5d34858f7acc5efc2a Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Sat, 8 Aug 2026 18:04:34 -0600 Subject: [PATCH 1/8] feat: add PostHog developer diagnostics --- .github/workflows/release.yml | 3 + .gitignore | 4 + app/.env.example | 2 + app/package-lock.json | 119 ++++++++++++++++++- app/package.json | 3 +- app/src-rust/src/launch.rs | 33 +++++ app/src-rust/src/migrate.rs | 6 +- app/src/renderer/App.vue | 20 +++- app/src/renderer/api-types.ts | 1 + app/src/renderer/composables/useTelemetry.ts | 73 ++++++++++++ app/src/renderer/main.ts | 13 +- app/src/renderer/views/SettingsView.vue | 95 +++++++++++++++ app/vite.config.ts | 47 ++++---- docs/architecture/developer-diagnostics.md | 24 ++++ 14 files changed, 416 insertions(+), 27 deletions(-) create mode 100644 app/.env.example create mode 100644 app/src/renderer/composables/useTelemetry.ts create mode 100644 docs/architecture/developer-diagnostics.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7e6eef1ad..9d5b42426 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -145,6 +145,9 @@ jobs: - name: Build TypeScript run: cd app && npm run build + env: + VITE_POSTHOG_PROJECT_TOKEN: ${{ secrets.POSTHOG_PROJECT_TOKEN }} + VITE_POSTHOG_HOST: https://us.i.posthog.com - name: Download bundles for DMG run: | diff --git a/.gitignore b/.gitignore index ccae714bc..cb94d8fcb 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,10 @@ vkd3d-proton-reference/ /app/out/ /app/release/ +# Local PostHog project configuration and wizard state (NEVER commit credentials or account artifacts) +/app/.env +/app/.posthog-wizard-cache/ + # macOS app bundles (regeneratable) *.dmg *.pkg diff --git a/app/.env.example b/app/.env.example new file mode 100644 index 000000000..ddc2c8b74 --- /dev/null +++ b/app/.env.example @@ -0,0 +1,2 @@ +VITE_POSTHOG_PROJECT_TOKEN=your_posthog_project_token +VITE_POSTHOG_HOST=https://us.i.posthog.com diff --git a/app/package-lock.json b/app/package-lock.json index 1f6b3a87f..a93167472 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -8,7 +8,8 @@ "name": "metalsharp", "version": "0.59.0", "dependencies": { - "electron-store": "^10.0.0" + "electron-store": "^10.0.0", + "posthog-js": "^1.414.0" }, "devDependencies": { "@biomejs/biome": "^2.5.6", @@ -802,6 +803,31 @@ "node": ">=14.18.0" } }, + "node_modules/@posthog/browser-common": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.4.0.tgz", + "integrity": "sha512-W9DCGVks15docUMPvJ2nd8NS16Gn74bsGWuaeg31beEKFSjdW8wvnQ1ETY6WSql5pYxZb3GdJmEUZVVstKSrBQ==", + "license": "MIT", + "dependencies": { + "@posthog/core": "^1.46.8", + "@posthog/types": "^1.402.0" + } + }, + "node_modules/@posthog/core": { + "version": "1.46.9", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.46.9.tgz", + "integrity": "sha512-EXO6y5ih+jBkTCpUCuYgQmajuDZuvy6vMvflkub6pLQyi0GPlCPWVSvZZkOeQw9e2MxoD5GteeGCt9R8+UJ/yQ==", + "license": "MIT", + "dependencies": { + "@posthog/types": "^1.402.2" + } + }, + "node_modules/@posthog/types": { + "version": "1.402.2", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.402.2.tgz", + "integrity": "sha512-ZZTiS4dLwF4/D0YTzS3gGSLFnhuNOY5yu4d9VGS9trGe5GW6FjIXo20p18K5BPW2RZSYSld8sdnSAY3AUXleUQ==", + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", @@ -1150,6 +1176,13 @@ "@types/node": "*" } }, + "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/@vitejs/plugin-vue": { "version": "6.0.8", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", @@ -1942,6 +1975,20 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", + "hasInstallScript": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -2196,6 +2243,15 @@ "js-yaml": "^4.1.0" } }, + "node_modules/dompurify": { + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dot-prop": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", @@ -2639,6 +2695,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/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -4112,6 +4174,24 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/posthog-js": { + "version": "1.414.0", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.414.0.tgz", + "integrity": "sha512-dtZd4asdskr8lNyltAEX6zyn48uO1pO0EMvx6AXJU65PFhu6yn2LPbKtQcyLysjcN57FJPNT9QYL6St5SBJHqw==", + "license": "(Apache-2.0 AND MIT)", + "dependencies": { + "@posthog/browser-common": "^0.4.0", + "@posthog/core": "^1.46.9", + "@posthog/types": "^1.402.2", + "core-js": "^3.49.0", + "dompurify": "^3.4.12", + "fflate": "^0.4.8", + "preact": "^10.29.3", + "query-selector-shadow-dom": "^1.0.1", + "web-vitals": "^5.3.0", + "web-vitals-soft-navs": "npm:web-vitals@6.0.0" + } + }, "node_modules/postject": { "version": "1.0.0-alpha.6", "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", @@ -4142,6 +4222,24 @@ "node": "^12.20.0 || >=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/prettier": { "version": "3.9.6", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", @@ -4259,6 +4357,12 @@ ], "license": "MIT" }, + "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/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -5089,6 +5193,19 @@ } } }, + "node_modules/web-vitals": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz", + "integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==", + "license": "Apache-2.0" + }, + "node_modules/web-vitals-soft-navs": { + "name": "web-vitals", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.0.0.tgz", + "integrity": "sha512-Guaibvy/+uNtL6Bsu4jmMJGzuSl91oeRH5iO9pPRbYftnFUr3yqT1TUNX/OE4o9HexuEMU3Kb/Wg7iKhlffZUA==", + "license": "Apache-2.0" + }, "node_modules/webcrypto-core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", diff --git a/app/package.json b/app/package.json index 7a930376f..ad547978c 100644 --- a/app/package.json +++ b/app/package.json @@ -27,7 +27,8 @@ "dmg": "npm run dist -- --mac dmg" }, "dependencies": { - "electron-store": "^10.0.0" + "electron-store": "^10.0.0", + "posthog-js": "^1.414.0" }, "overrides": { "form-data": "4.0.6", diff --git a/app/src-rust/src/launch.rs b/app/src-rust/src/launch.rs index 5112fa176..3f7475bc9 100644 --- a/app/src-rust/src/launch.rs +++ b/app/src-rust/src/launch.rs @@ -202,6 +202,7 @@ pub fn get_config_for_home(home: &Path) -> Value { let mono_available = find_mono().is_ok(); let graphics_runtime_logs = graphics_runtime_logs_enabled(); let controller_input = controller_input_mode_for(home); + let developer_telemetry = developer_telemetry_enabled_for(home); json!({ "ok": true, @@ -212,6 +213,7 @@ pub fn get_config_for_home(home: &Path) -> Value { "controllerInput": controller_input, "m12Backend": m12_backend_mode_for(home), "msync": msync_enabled_for(home), + "developerTelemetry": developer_telemetry, }) } @@ -280,6 +282,12 @@ pub fn msync_enabled_for(home: &Path) -> bool { read_config_bool_for_home(home, "msync").unwrap_or(true) } +/// Whether developers may receive app diagnostics and session recordings. +/// Enabled by default; users can opt out in Settings. +pub fn developer_telemetry_enabled_for(home: &Path) -> bool { + read_config_bool_for_home(home, "developerTelemetry").unwrap_or(true) +} + fn read_config_bool_for_home(home: &Path, key: &str) -> Option { let path = config_path_for_home_unenv(home); let contents = std::fs::read_to_string(path).ok()?; @@ -511,6 +519,10 @@ pub fn set_config_for_home(home: &Path, body: &Map) -> Result(&persisted).unwrap()["developerTelemetry"], false); + + let _ = std::fs::remove_dir_all(&temp); + } } diff --git a/app/src-rust/src/migrate.rs b/app/src-rust/src/migrate.rs index 79da1f3e2..4b69c5882 100644 --- a/app/src-rust/src/migrate.rs +++ b/app/src-rust/src/migrate.rs @@ -2503,7 +2503,8 @@ fn restore_setup_json(ms_dir: &Path, data: &[u8], steam_api_key_restored: bool) /// are copied from the preserved file, so a future version's new keys (or /// defaults written during install) are never clobbered. fn restore_config_json(ms_dir: &Path, data: &[u8]) { - const PRESERVED_KEYS: &[&str] = &["m12Backend", "msync", "controllerInput", "graphicsRuntimeLogs"]; + const PRESERVED_KEYS: &[&str] = + &["m12Backend", "msync", "controllerInput", "graphicsRuntimeLogs", "developerTelemetry"]; let configs_dir = ms_dir.join("configs"); let _ = fs::create_dir_all(&configs_dir); let path = configs_dir.join("config.json"); @@ -2876,7 +2877,7 @@ mod tests { // that a fresh install would write. fs::write( configs.join("config.json"), - r#"{"m12Backend":"dxmt","msync":false,"controllerInput":"x","graphicsRuntimeLogs":true,"futureKey":"keep"}"#, + r#"{"m12Backend":"dxmt","msync":false,"controllerInput":"x","graphicsRuntimeLogs":true,"developerTelemetry":false,"futureKey":"keep"}"#, ) .expect("write config.json"); @@ -2897,6 +2898,7 @@ mod tests { assert_eq!(restored["msync"], false, "msync toggle must survive migration"); assert_eq!(restored["controllerInput"], "x", "controller input mode must survive migration"); assert_eq!(restored["graphicsRuntimeLogs"], true, "graphics logs toggle must survive migration"); + assert_eq!(restored["developerTelemetry"], false, "developer telemetry opt-out must survive migration"); assert_eq!(restored["newVersionKey"], "fresh", "fresh-install keys must not be clobbered by restore"); let _ = fs::remove_dir_all(home); diff --git a/app/src/renderer/App.vue b/app/src/renderer/App.vue index 41de83851..41bbf9df1 100644 --- a/app/src/renderer/App.vue +++ b/app/src/renderer/App.vue @@ -12,6 +12,7 @@ import SettingsView from "./views/SettingsView.vue"; import { useTheme } from "./composables/useTheme"; import { useToast } from "./composables/useToast"; import { getAPI, api } from "./composables/useApi"; +import { captureTelemetry, configureTelemetry } from "./composables/useTelemetry"; import type { AppConfig, UpdateStatus, SteamStatus } from "./api-types"; interface SteamGame { @@ -289,8 +290,13 @@ function startHealthPolling() { } else { backendVersion.value = null; } - if (prev && !backendConnected.value) toast.show("Backend connection lost", "error"); - else if (!prev && backendConnected.value) toast.show("Backend connected", "success"); + if (prev && !backendConnected.value) { + captureTelemetry("backend_connection_lost"); + toast.show("Backend connection lost", "error"); + } else if (!prev && backendConnected.value) { + captureTelemetry("backend_recovered"); + toast.show("Backend connected", "success"); + } }, 120000); } @@ -319,6 +325,16 @@ watch(lowPerformanceMode, (enabled) => { onMounted(async () => { applyLowPerformanceMode(lowPerformanceMode.value); await checkBackend(); + const startupConfig = await api("GET", "/config"); + if (startupConfig?.ok) config.value = startupConfig; + const telemetryConfigured = await configureTelemetry(startupConfig?.developerTelemetry ?? true); + if (telemetryConfigured) { + const firstReady = localStorage.getItem("metalsharp-backend-ready-reported") !== "true"; + captureTelemetry(backendConnected.value ? "backend_ready" : "backend_unavailable", { + first_observed_ready: firstReady, + }); + if (backendConnected.value) localStorage.setItem("metalsharp-backend-ready-reported", "true"); + } if (new URLSearchParams(window.location.search).get("skip-to") === "library") { // The dev backend may still be starting (first-run bottle scan); wait for // it before loading the library instead of racing a dead window. diff --git a/app/src/renderer/api-types.ts b/app/src/renderer/api-types.ts index bb20d223f..3359725b3 100644 --- a/app/src/renderer/api-types.ts +++ b/app/src/renderer/api-types.ts @@ -36,6 +36,7 @@ interface AppConfig { controllerInput?: "off" | "x" | "d"; m12Backend?: "vkd3d-proton" | "dxmt"; msync?: boolean; + developerTelemetry?: boolean; } interface UpdateStatus { diff --git a/app/src/renderer/composables/useTelemetry.ts b/app/src/renderer/composables/useTelemetry.ts new file mode 100644 index 000000000..f51027d22 --- /dev/null +++ b/app/src/renderer/composables/useTelemetry.ts @@ -0,0 +1,73 @@ +import posthog from "posthog-js"; + +interface PostHogBuildConfig { + projectToken?: string; + host?: string; +} + +let initialized = false; +let enabled = false; +let handlersInstalled = false; +let buildConfig: PostHogBuildConfig = {}; + +export function setTelemetryBuildConfig(config: PostHogBuildConfig) { + buildConfig = config; +} + +function installExceptionHandlers() { + if (handlersInstalled) return; + handlersInstalled = true; + window.addEventListener("error", () => { + captureTelemetry("renderer_error", { kind: "window_error" }); + }); + window.addEventListener("unhandledrejection", () => { + captureTelemetry("renderer_error", { kind: "unhandled_rejection" }); + }); +} + +/** Enable or disable developer diagnostics. The app defaults this to enabled. */ +export async function configureTelemetry(allowDeveloperTelemetry: boolean): Promise { + enabled = allowDeveloperTelemetry; + const { projectToken, host } = buildConfig; + if (!projectToken || !host) return false; + + if (!initialized) { + posthog.init(projectToken, { + api_host: host, + autocapture: false, + capture_pageview: false, + capture_pageleave: false, + disable_session_recording: !allowDeveloperTelemetry, + session_recording: { maskAllInputs: true, maskTextSelector: "*" }, + opt_out_capturing_by_default: !allowDeveloperTelemetry, + person_profiles: "never", + }); + initialized = true; + installExceptionHandlers(); + } + + if (allowDeveloperTelemetry) { + posthog.opt_in_capturing(); + posthog.startSessionRecording(); + } else { + posthog.stopSessionRecording(); + posthog.opt_out_capturing(); + } + return true; +} + +export function captureTelemetry(event: string, properties?: Record) { + if (initialized && enabled) posthog.capture(event, properties); +} + +export function captureTelemetryException(_error: unknown) { + captureTelemetry("renderer_error", { kind: "vue_error" }); +} + +/** Free-text is captured only when a user explicitly submits this form. */ +export function submitDeveloperFeedback(message: string): boolean { + const feedback = message.trim().slice(0, 4000); + if (!initialized || !enabled || !feedback) return false; + posthog.capture("developer_feedback_submitted", { feedback }); + return true; +} diff --git a/app/src/renderer/main.ts b/app/src/renderer/main.ts index c9efa4bd6..7f1c621e0 100644 --- a/app/src/renderer/main.ts +++ b/app/src/renderer/main.ts @@ -1,6 +1,17 @@ import { createApp } from "vue"; import App from "./App.vue"; +import { captureTelemetryException, setTelemetryBuildConfig } from "./composables/useTelemetry"; import "./styles/base.css"; import "./styles/transitions.css"; -createApp(App).mount("#app"); +declare const __POSTHOG_PROJECT_TOKEN__: string; +declare const __POSTHOG_HOST__: string; + +setTelemetryBuildConfig({ + projectToken: __POSTHOG_PROJECT_TOKEN__, + host: __POSTHOG_HOST__, +}); + +const app = createApp(App); +app.config.errorHandler = (error) => captureTelemetryException(error); +app.mount("#app"); diff --git a/app/src/renderer/views/SettingsView.vue b/app/src/renderer/views/SettingsView.vue index 8ff70a7e2..c04263ffc 100644 --- a/app/src/renderer/views/SettingsView.vue +++ b/app/src/renderer/views/SettingsView.vue @@ -2,6 +2,7 @@ import { ref, inject, onMounted, onUnmounted, type Ref } from "vue"; import { useToast } from "../composables/useToast"; import { api, getAPI } from "../composables/useApi"; +import { configureTelemetry, submitDeveloperFeedback } from "../composables/useTelemetry"; import type { AppConfig, UpdateStatus } from "../api-types"; import IconTrash2 from "~icons/lucide/trash-2"; @@ -41,6 +42,8 @@ const shaderCache = ref(null); const pipelineCache = ref(null); const apiKeyInput = ref(""); const graphicsRuntimeLogs = ref(false); +const developerTelemetry = ref(true); +const developerFeedback = ref(""); const m12Backend = ref<"vkd3d-proton" | "dxmt">("vkd3d-proton"); interface WineMonoStatus { @@ -162,6 +165,7 @@ async function refreshConfig() { if (result?.ok) { config.value = result; graphicsRuntimeLogs.value = Boolean(result.graphicsRuntimeLogs ?? result.graphics_runtime_logs); + developerTelemetry.value = result.developerTelemetry ?? true; if (result.m12Backend === "vkd3d-proton" || result.m12Backend === "dxmt") { m12Backend.value = result.m12Backend; } @@ -444,6 +448,34 @@ async function setM12Backend(backend: "vkd3d-proton" | "dxmt") { } } +async function toggleDeveloperTelemetry(enabled: boolean) { + const previous = developerTelemetry.value; + developerTelemetry.value = enabled; + const result = await api("POST", "/config", { developerTelemetry: enabled }); + if (!result?.ok) { + developerTelemetry.value = previous; + toast.show("Failed to save developer diagnostics setting", "error"); + return; + } + config.value = result; + developerTelemetry.value = result.developerTelemetry ?? true; + await configureTelemetry(developerTelemetry.value); + toast.show(developerTelemetry.value ? "Developer diagnostics enabled" : "Developer diagnostics disabled", "success"); +} + +function sendDeveloperFeedback() { + if (!developerTelemetry.value) { + toast.show("Enable developer diagnostics before sending feedback", "error"); + return; + } + if (!submitDeveloperFeedback(developerFeedback.value)) { + toast.show("Enter feedback before submitting", "error"); + return; + } + developerFeedback.value = ""; + toast.show("Feedback sent to MetalSharp developers", "success"); +} + function uninstallMetalsharp() { getAPI().uninstallApp(); } @@ -641,6 +673,59 @@ function uninstallMetalsharp() { +
+

Developer Diagnostics

+
+
+
Allow developers to receive logs, errors, and crash reports
+
+ Enabled by default. Shares sanitized backend lifecycle events, renderer errors, and masked session + recordings. Turning this off stops future diagnostics and recordings. +
+
+
+ + {{ developerTelemetry ? "Enabled" : "Disabled" }} + + +
+
+
+
+
Send feedback
+
+ Submit a message directly to the developers. Do not include Steam keys, passwords, or other secrets. +
+
+ +
+
+

Runtime

@@ -869,6 +954,16 @@ function uninstallMetalsharp() { .settings-input-row input { width: 280px; } +.feedback-value { + align-items: flex-end; + max-width: 420px; +} +.feedback-input { + width: 320px; + min-height: 74px; + resize: vertical; + font: inherit; +} .settings-version { font-size: 12px; color: var(--text-dim); diff --git a/app/vite.config.ts b/app/vite.config.ts index 222b856f5..8ad6474a7 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -1,28 +1,35 @@ -import { defineConfig } from "vite"; +import { defineConfig, loadEnv } from "vite"; import vue from "@vitejs/plugin-vue"; import Icons from "unplugin-icons/vite"; import { resolve } from "path"; -export default defineConfig({ - plugins: [vue(), Icons({ compiler: "vue3", defaultStyle: "stroke", scale: 1.2 })], - root: resolve(__dirname, "src/renderer"), - base: "./", - build: { - outDir: resolve(__dirname, "dist/renderer"), - emptyOutDir: true, - rollupOptions: { - output: { - entryFileNames: "index.js", - assetFileNames: "assets/[name].[ext]", +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, __dirname, "VITE_"); + return { + define: { + __POSTHOG_PROJECT_TOKEN__: JSON.stringify(env.VITE_POSTHOG_PROJECT_TOKEN ?? ""), + __POSTHOG_HOST__: JSON.stringify(env.VITE_POSTHOG_HOST ?? ""), + }, + plugins: [vue(), Icons({ compiler: "vue3", defaultStyle: "stroke", scale: 1.2 })], + root: resolve(__dirname, "src/renderer"), + base: "./", + build: { + outDir: resolve(__dirname, "dist/renderer"), + emptyOutDir: true, + rollupOptions: { + output: { + entryFileNames: "index.js", + assetFileNames: "assets/[name].[ext]", + }, + }, + }, + resolve: { + alias: { + "@": resolve(__dirname, "src/renderer"), }, }, - }, - resolve: { - alias: { - "@": resolve(__dirname, "src/renderer"), + server: { + port: 5173, }, - }, - server: { - port: 5173, - }, + }; }); diff --git a/docs/architecture/developer-diagnostics.md b/docs/architecture/developer-diagnostics.md new file mode 100644 index 000000000..c5b59a400 --- /dev/null +++ b/docs/architecture/developer-diagnostics.md @@ -0,0 +1,24 @@ +# Developer Diagnostics and Feedback +**Updated:** 2026-08-08 + +MetalSharp uses PostHog Cloud in the US region (`https://us.i.posthog.com`) for operational diagnostics and product feedback. + +## User control + +`developerTelemetry` is stored in `~/.metalsharp/configs/config.json` and defaults to `true` for new installs. The Settings toggle, **Allow developers to receive logs, errors, and crash reports**, disables future PostHog capture and session recording immediately when turned off. The preference survives runtime migration. + +## Collected signals + +When enabled, the renderer captures only these operational signals: + +- `backend_ready` or `backend_unavailable` at app startup, including whether this is the first observed ready state on that installation. +- `backend_connection_lost` and `backend_recovered` from the existing backend health poll. +- Renderer error categories (`window_error`, `unhandled_rejection`, or `vue_error`) without exception messages, stacks, or payloads. +- Session recordings with every input and all text masked. +- `developer_feedback_submitted` only after the user explicitly presses **Send Feedback** in Settings. The submitted message is limited to 4,000 characters. + +The application does not identify people or automatically send Steam API keys, passwords, local file paths, game-library names, raw log files, arbitrary backend responses, or other user-entered input values. + +## Build and release configuration + +The PostHog project write key is deliberately excluded from Git. For local builds, copy `app/.env.example` to ignored `app/.env` and set `VITE_POSTHOG_PROJECT_TOKEN`. Release CI receives the key from the repository secret `POSTHOG_PROJECT_TOKEN` and injects it only while building the renderer. The write key is intended for client delivery; account credentials and personal API keys must never be added to source or workflow files. From 69eb5ba059249805ba45ed03d25ac16837bc2ac8 Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Sat, 8 Aug 2026 18:12:29 -0600 Subject: [PATCH 2/8] fix: update js-yaml to 4.3.1 --- app/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index a93167472..febda1304 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -3233,9 +3233,9 @@ } }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { From 7be39e39c01d1e7bb768c437c9591eaf60b0cf85 Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Sat, 8 Aug 2026 18:14:47 -0600 Subject: [PATCH 3/8] fix: preserve saved theme on startup --- app/src/main/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/index.ts b/app/src/main/index.ts index 10a566218..041cc392a 100644 --- a/app/src/main/index.ts +++ b/app/src/main/index.ts @@ -526,7 +526,7 @@ async function createProcessManagerWindow(): Promise { processManagerWindow = null; }); await processManagerWindow.loadFile(path.join(__dirname, "..", "renderer", "index.html"), { - query: { overlay: "process-manager", theme: "dark" }, + query: { overlay: "process-manager" }, }); return processManagerWindow; } @@ -608,7 +608,8 @@ async function createWindow(migrating = false) { }, }); - const query: Record = uiOnly ? { theme: "dark" } : {}; + // The renderer resolves a saved user choice; a first launch defaults to dark. + const query: Record = {}; if (process.env.METALSHARP_DEV_LIBRARY === "1") query["skip-to"] = "library"; mainWindow.loadFile(path.join(__dirname, "..", "renderer", "index.html"), { query, From 03669e5aaddc8f892e1c1ab02dbc2a49ea93e6e9 Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Sat, 8 Aug 2026 18:19:58 -0600 Subject: [PATCH 4/8] feat: add Reminiscence M11 compatibility rule --- app/src-rust/src/mtsp/rules.rs | 9 +++++++++ configs/mtsp-rules.toml | 8 ++++++++ docs/compatibility/GAMES-SUPPORTED.md | 3 ++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/app/src-rust/src/mtsp/rules.rs b/app/src-rust/src/mtsp/rules.rs index b5d993211..ebbc5b0de 100644 --- a/app/src-rust/src/mtsp/rules.rs +++ b/app/src-rust/src/mtsp/rules.rs @@ -954,6 +954,15 @@ mod tests { } } + #[test] + fn shipped_rules_route_reminiscence_to_m11() { + const SOURCE: &str = include_str!("../../../../configs/mtsp-rules.toml"); + let (_, recipes) = parse_rules_full(SOURCE); + let recipe = recipes.get(&1675140).expect("shipped rules must contain a Reminiscence override (appid 1675140)"); + assert_eq!(recipe.pipeline, PipelineId::M11); + assert_eq!(recipe.name, "Reminiscence"); + } + /// Stardew Valley (1.6+, net6 MonoGame) must default to the mono/fna /// route — the shipped config regression-guards the fna_arm64 rule. #[test] diff --git a/configs/mtsp-rules.toml b/configs/mtsp-rules.toml index 0e16fff53..0bafcb0ad 100644 --- a/configs/mtsp-rules.toml +++ b/configs/mtsp-rules.toml @@ -42,6 +42,14 @@ components = ["vcrun2019", "directx_jun2010"] [overrides.312520.diagnostics] check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] +# Reminiscence (Steam 1675140) is a verified D3D11 title. +[overrides.1675140] +pipeline = "m11" +name = "Reminiscence" + +[overrides.1675140.diagnostics] +check_dlls = ["d3d11.dll", "dxgi.dll", "winemetal.dll"] + [overrides.375520] pipeline = "m9" name = "Taimumari: Definitive Edition" diff --git a/docs/compatibility/GAMES-SUPPORTED.md b/docs/compatibility/GAMES-SUPPORTED.md index 7cd4bcf20..2bdbbd67a 100644 --- a/docs/compatibility/GAMES-SUPPORTED.md +++ b/docs/compatibility/GAMES-SUPPORTED.md @@ -1,6 +1,6 @@ # Games Supported -Updated: 2026-08-05 +Updated: 2026-08-08 Tested and working games organized by pipeline. Only games confirmed playable are listed. @@ -59,6 +59,7 @@ The primary/default D3D12 route runs on the vkd3d-proton stack (D3D12 → Vulkan | Subnautica | 264710 | | | Subnautica: Below Zero | 848450 | | | Rain World | 312520 | | +| Reminiscence | 1675140 | | | Hollow Knight | 367520 | | | Party Animals | 1260320 | Save M11 bottle, launch direct with Steam. | | Dave the Diver | 1868140 | | From 86bf2912b178451e3ef5bcbf1807b5e5aed6ef5a Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Sat, 8 Aug 2026 18:28:49 -0600 Subject: [PATCH 5/8] feat: stop active game with Cmd+Q --- app/src-rust/src/main.rs | 48 ++++++++++++++++++++++++++ app/src/main/index.ts | 29 ++++++++++++++++ app/src/main/preload.ts | 5 +++ app/src/renderer/api-types.ts | 2 ++ app/src/renderer/views/LibraryView.vue | 40 +++++++++++++-------- 5 files changed, 110 insertions(+), 14 deletions(-) diff --git a/app/src-rust/src/main.rs b/app/src-rust/src/main.rs index 25e10c7cc..35000d818 100644 --- a/app/src-rust/src/main.rs +++ b/app/src-rust/src/main.rs @@ -77,6 +77,46 @@ fn prune_inactive_game_pids() { } } +/// Return only live, MetalSharp-registered game process roots. This is the +/// trust boundary for global game-stop actions: arbitrary system PIDs must never +/// be accepted here. +fn active_game_targets(games: &HashMap) -> Vec<(u32, i32)> { + let mut targets: Vec<_> = games.iter().filter_map(|(&appid, &pid)| (pid > 0).then_some((appid, pid))).collect(); + targets.sort_unstable_by_key(|(appid, _)| *appid); + targets +} + +fn stop_active_games() -> Value { + prune_inactive_game_pids(); + let targets = running_games().lock().map(|games| active_game_targets(&games)).unwrap_or_default(); + let mut stopped = Vec::new(); + let mut errors = Vec::new(); + + for (appid, pid) in targets { + match launch::kill_game_with_pid(appid, pid) { + Ok(_) => { + unregister_game_pid(appid); + app_log(&format!("[STOPPED] appid {} | pid {} | source global-shortcut", appid, pid)); + stopped.push(json!({ "appid": appid, "pid": pid })); + }, + Err(error) => { + app_log(&format!( + "[STOP FAILED] appid {} | pid {} | source global-shortcut | error: {}", + appid, pid, error + )); + errors.push(json!({ "appid": appid, "pid": pid })); + }, + } + } + + json!({ + "ok": errors.is_empty(), + "active": !stopped.is_empty() || !errors.is_empty(), + "stopped": stopped, + "errors": errors, + }) +} + enum RouteResponse { Json(u16, Vec), Raw(u16, Vec, String), @@ -2250,6 +2290,7 @@ fn route(req: &mut tiny_http::Request) -> RouteResponse { ) }, (Method::Post, "/processes/force-kill") => resp(200, force_kill_metalsharp_processes()), + (Method::Post, "/games/stop-active") => resp(200, stop_active_games()), (Method::Post, "/kill") => { let body = read_body(req); let pid_param = body.get("pid").and_then(|v| v.as_u64()).unwrap_or(0) as i32; @@ -3086,6 +3127,13 @@ mod tests { assert!(!is_trusted_local_origin("https://localhost:5173")); } + #[test] + fn active_game_targets_include_only_registered_positive_pids() { + let targets = active_game_targets(&HashMap::from([(620, 4242), (4000, 0), (1260320, -1)])); + + assert_eq!(targets, vec![(620, 4242)]); + } + #[test] fn force_kill_targets_metalsharp_wine_helpers_but_not_app_processes() { let home = std::path::Path::new("/Users/test/.metalsharp"); diff --git a/app/src/main/index.ts b/app/src/main/index.ts index 041cc392a..64e352e6e 100644 --- a/app/src/main/index.ts +++ b/app/src/main/index.ts @@ -559,6 +559,34 @@ function registerProcessManagerShortcut(): void { } } +async function stopActiveGameFromShortcut(): Promise { + const result = (await requestBackend("POST", "/games/stop-active", undefined, 15000)) as { + ok?: boolean; + active?: boolean; + stopped?: Array<{ appid?: number }>; + }; + if (!result?.active) { + app.quit(); + return; + } + + const appids = (result.stopped ?? []) + .map((game) => game.appid) + .filter((appid): appid is number => typeof appid === "number"); + if (appids.length > 0 && mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("game:stopped", appids); + } + if (!result.ok) console.warn("MetalSharp Cmd+Q could not stop every active game"); +} + +function registerGameStopShortcut(): void { + const accelerator = process.platform === "darwin" ? "Command+Q" : "CommandOrControl+Q"; + const ok = globalShortcut.register(accelerator, () => void stopActiveGameFromShortcut()); + if (!ok && !globalShortcut.isRegistered(accelerator)) { + console.warn(`MetalSharp game-stop shortcut was not registered: ${accelerator}`); + } +} + async function checkNeedsMigration(): Promise { const marker = hasPostUpdateMigrationMarker(); return new Promise((resolve) => { @@ -713,6 +741,7 @@ app.whenReady().then(async () => { registerIpc(); registerProcessManagerShortcut(); + registerGameStopShortcut(); await createWindow(needsMigration); diff --git a/app/src/main/preload.ts b/app/src/main/preload.ts index 82a60454b..e675cb907 100644 --- a/app/src/main/preload.ts +++ b/app/src/main/preload.ts @@ -12,6 +12,11 @@ contextBridge.exposeInMainWorld("metalsharp", { installHomebrew: () => ipcRenderer.invoke("app:install-homebrew"), homebrewStatus: () => ipcRenderer.invoke("app:homebrew-status"), onSteamappsChanged: (callback: () => void) => ipcRenderer.on("steamapps:changed", callback), + onGameStopped: (callback: (appids: number[]) => void) => { + const listener = (_event: Electron.IpcRendererEvent, appids: number[]) => callback(appids); + ipcRenderer.on("game:stopped", listener); + return () => ipcRenderer.removeListener("game:stopped", listener); + }, openInFinder: (path: string) => ipcRenderer.invoke("app:open-in-finder", path), openLogsFolder: () => ipcRenderer.invoke("app:open-logs-folder"), openMetalsharpFolder: () => ipcRenderer.invoke("app:open-metalsharp-folder"), diff --git a/app/src/renderer/api-types.ts b/app/src/renderer/api-types.ts index 3359725b3..f87ca6b90 100644 --- a/app/src/renderer/api-types.ts +++ b/app/src/renderer/api-types.ts @@ -215,6 +215,8 @@ type MetalsharpAPI = { installDeps: (command: string) => Promise<{ ok: boolean; error?: string }>; installHomebrew: () => Promise<{ ok: boolean; installed?: boolean; path?: string; message?: string; error?: string }>; homebrewStatus: () => Promise<{ installed: boolean; path?: string }>; + onSteamappsChanged: (callback: () => void) => void; + onGameStopped: (callback: (appids: number[]) => void) => () => void; openInFinder: (path: string) => Promise; openLogsFolder: () => Promise<{ ok: boolean; path?: string; error?: string }>; openMetalsharpFolder: () => Promise<{ ok: boolean; path?: string; error?: string }>; diff --git a/app/src/renderer/views/LibraryView.vue b/app/src/renderer/views/LibraryView.vue index 00b439db0..33bc31dd2 100644 --- a/app/src/renderer/views/LibraryView.vue +++ b/app/src/renderer/views/LibraryView.vue @@ -1,7 +1,7 @@