diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7e6eef1a..9d5b4242 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 ccae714b..cb94d8fc 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/CHANGELOG.md b/CHANGELOG.md
index 9a400c21..9af81a43 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,20 @@
# Changelog
+## v0.59.1 — 2026-08-08
+
+### Added
+
+- **Developer diagnostics and feedback** — PostHog US Cloud operational diagnostics are enabled by default with a persisted Settings opt-out, privacy-minimized renderer lifecycle/error events, fully masked session recordings, and explicit Settings feedback submission. No Steam/account data, paths, game-library metadata, command lines, raw logs, backend responses, exception details, credentials, or secrets are collected automatically.
+- **Emergency active-game stop shortcut** — `Cmd+Q` invokes MetalSharp's existing PID-scoped Stop path while a tracked game is active, including when the game owns focus; with no active game, it retains normal application quit behavior.
+- **Reminiscence default route** — Steam AppID 1675140 now defaults to the M11 pipeline and is listed in the supported-games matrix.
+
+### Fixed
+
+- **M12/DXMT pipeline switches** — game-folder graphics deployment now strictly removes the conflicting prior route before staging the selected one. M12 owns VKD3D-Proton `d3d12.dll`/`d3d12core.dll` and DXVK `dxgi.dll`/`d3d11.dll`; DXMT transitions evict that full set, and M12 transitions remove DXMT-specific files first.
+- **VKMT MoltenVK installation and upgrades** — fresh installs and existing installations now synchronize Wine’s direct-load MoltenVK copies from the staged VKMT lane after graphics extraction, with byte-level stale-copy detection; the legacy DXMT fast path also verifies and repairs missing M12 lanes.
+- **Saved application theme** — auxiliary and process-manager windows now respect an explicitly saved theme; first launch remains dark.
+- **`js-yaml` security update** — updated to 4.3.1.
+
## v0.59.0 — 2026-08-06
### Added
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 537e0b07..3ccfd7a9 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24)
-project(metalsharp VERSION 0.59.0 LANGUAGES C CXX OBJC OBJCXX)
+project(metalsharp VERSION 0.59.1 LANGUAGES C CXX OBJC OBJCXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
diff --git a/README.md b/README.md
index 29d2330a..774668e2 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,12 @@
# MetalSharp
-**Updated:** 2026-08-01
+**Updated:** 2026-08-08
**Run Windows games on MacOS Silicon.**

-

+

@@ -21,7 +21,7 @@ MetalSharp is an application designed to run Windows Steam and Windows Steam gam
## Arm64-FEX Build For Devs Out Now
-[0.60.0 Dependancies Release](https://github.com/metalsharp/MetalSharp/releases/tag/v0.60.0-dependency-bundles), 0.59.0 Remains The Current Working Version.
+[0.60.0 Dependancies Release](https://github.com/metalsharp/MetalSharp/releases/tag/v0.60.0-dependency-bundles), 0.59.1 Remains The Current Working Version.
## Quick Start
diff --git a/app/.env.example b/app/.env.example
new file mode 100644
index 00000000..ddc2c8b7
--- /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 1f6b3a87..f3f9ffc9 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -1,14 +1,15 @@
{
"name": "metalsharp",
- "version": "0.59.0",
+ "version": "0.59.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metalsharp",
- "version": "0.59.0",
+ "version": "0.59.1",
"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",
@@ -3171,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": [
{
@@ -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 7a930376..c95dd999 100644
--- a/app/package.json
+++ b/app/package.json
@@ -1,7 +1,7 @@
{
"name": "metalsharp",
- "version": "0.59.0",
- "description": "MetalSharp \u2014 D3D\u2192Metal translation layer frontend",
+ "version": "0.59.1",
+ "description": "MetalSharp — D3D→Metal translation layer frontend",
"author": "MetalSharp",
"repository": {
"type": "git",
@@ -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",
@@ -54,7 +55,7 @@
"build": {
"appId": "com.metalsharp.app",
"productName": "MetalSharp",
- "copyright": "Copyright \u00a9 2026 MetalSharp",
+ "copyright": "Copyright © 2026 MetalSharp",
"afterPack": "build/adhoc-deep-sign.cjs",
"afterSign": "build/notarize.cjs",
"mac": {
diff --git a/app/src-rust/Cargo.lock b/app/src-rust/Cargo.lock
index f5bcebf5..597d5f77 100644
--- a/app/src-rust/Cargo.lock
+++ b/app/src-rust/Cargo.lock
@@ -554,7 +554,7 @@ checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "metalsharp-backend"
-version = "0.59.0"
+version = "0.59.1"
dependencies = [
"ctrlc",
"dirs",
diff --git a/app/src-rust/Cargo.toml b/app/src-rust/Cargo.toml
index ffaca5d6..d8dce7b1 100644
--- a/app/src-rust/Cargo.toml
+++ b/app/src-rust/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "metalsharp-backend"
-version = "0.59.0"
+version = "0.59.1"
edition = "2021"
[dependencies]
diff --git a/app/src-rust/src/installer.rs b/app/src-rust/src/installer.rs
index 51923a4d..c757c05e 100644
--- a/app/src-rust/src/installer.rs
+++ b/app/src-rust/src/installer.rs
@@ -1286,7 +1286,17 @@ pub fn ensure_graphics_runtimes_ready(home: &Path) -> Result
{
&& dxmt_m12_runtime_current_for_dir(&dxmt_m12_dir)
&& !graphics_bundle_has_update(home)
{
- return Ok(false);
+ // Legacy DXMT currency alone is insufficient: older installations can
+ // have a current graphics marker yet lack the later vkd3d/DXVK/VKMT
+ // M12 lanes. Run the M12 ensure on the fast path so it also repairs
+ // Wine's direct-load MoltenVK mirror without re-staging healthy lanes.
+ return match ensure_vkd3d_proton_runtime_ready(home) {
+ Ok(changed) => Ok(changed),
+ Err(err) => {
+ eprintln!("setup: vkd3d-proton lanes not staged (DXMT fallback remains active): {}", err);
+ Ok(false)
+ },
+ };
}
let home_buf = home.to_path_buf();
@@ -1468,6 +1478,33 @@ pub fn dxmt_m12_runtime_artifact_valid_for_home(home: &Path, rel: &str) -> bool
crate::diagnostics::file_sha256(&dxmt_m12_runtime_dir_for_home(home).join(rel)).as_deref() == Some(*expected)
}
+/// Mirror the VKMT MoltenVK lane into Wine's direct-load location. Wine's
+/// Vulkan driver bypasses the route ICD/DYLD configuration and loads these
+/// names from `lib/wine/x86_64-unix`, so they must be byte-identical to the
+/// selected VKMT lane after both a fresh install and an upgrade.
+fn sync_vkmt_moltenvk_into_wine_tree(wine_dir: &Path) -> Result {
+ let source = wine_dir.join("lib").join("moltenvk-vkmt").join("libMoltenVK.dylib");
+ if !source.is_file() {
+ return Ok(false);
+ }
+ let source_bytes = fs::read(&source).map_err(|e| format!("read VKMT MoltenVK {}: {}", source.display(), e))?;
+ let unix_dir = wine_dir.join("lib").join("wine").join("x86_64-unix");
+ fs::create_dir_all(&unix_dir)
+ .map_err(|e| format!("create Wine MoltenVK directory {}: {}", unix_dir.display(), e))?;
+
+ let mut changed = false;
+ for name in ["libMoltenVK.dylib", "libMoltenVK.1.dylib"] {
+ let target = unix_dir.join(name);
+ let matches_source = fs::read(&target).map(|bytes| bytes == source_bytes).unwrap_or(false);
+ if !matches_source {
+ fs::write(&target, &source_bytes)
+ .map_err(|e| format!("sync VKMT MoltenVK {} -> {}: {}", source.display(), target.display(), e))?;
+ changed = true;
+ }
+ }
+ Ok(changed)
+}
+
/// Stage the vkd3d-proton M12 stack from the graphics bundle:
/// `Graphics/dll/{vkd3d-proton,dxvk,moltenvk-vkmt}` -> the runtime wine lib
/// lanes. Installs when the bundle carries the lanes; returns Ok(true) when
@@ -1485,29 +1522,10 @@ pub fn ensure_vkd3d_proton_runtime_ready(home: &Path) -> Result {
let dxvk_dir = wine_dir.join("lib").join("dxvk");
let moltenvk_dir = wine_dir.join("lib").join("moltenvk-vkmt");
- // The wine tree bundles a STOCK libMoltenVK (1.4.1) in lib/wine/x86_64-unix
- // that wine's Vulkan driver (winemac.drv -> winevulkan) loads DIRECTLY,
- // bypassing VK_ICD_FILENAMES and the DYLD env entirely. It must be the VKMT
- // build: the stock build lacks the robustness2 gate and newer VKMT fixes,
- // so DXVK rejects the device ("Skipping: Device does not support required
- // feature 'robustBufferAccess2'"). Self-heal: mirror the VKMT lane's
- // libMoltenVK (both the unversioned and versioned names) into the wine tree
- // whenever they differ. Cheap (size compare) and idempotent.
- let unix_dir = wine_dir.join("lib").join("wine").join("x86_64-unix");
- let lane_mvk = moltenvk_dir.join("libMoltenVK.dylib");
- if lane_mvk.is_file() {
- let lane_len = fs::metadata(&lane_mvk).map(|m| m.len()).unwrap_or(0);
- for name in ["libMoltenVK.dylib", "libMoltenVK.1.dylib"] {
- let target = unix_dir.join(name);
- let needs_sync = match fs::metadata(&target) {
- Ok(m) => m.len() != lane_len,
- Err(_) => true,
- };
- if needs_sync {
- let _ = fs::copy(&lane_mvk, &target);
- }
- }
- }
+ // Wine's Vulkan driver bypasses the route ICD/DYLD configuration, so heal
+ // any stale direct-load MoltenVK copy before checking whether the VKMT
+ // lanes are current. This is content-based rather than size-based.
+ sync_vkmt_moltenvk_into_wine_tree(&wine_dir)?;
// Cheap read-only currency gate: only re-extract when a shipped lane
// artifact is missing or hash-mismatched. The extraction below zstd-
@@ -1540,6 +1558,10 @@ pub fn ensure_vkd3d_proton_runtime_ready(home: &Path) -> Result {
}
}
let _ = fs::remove_dir_all(&tmp);
+ // Fresh installs and upgrades only have the new VKMT lane after the
+ // copy above; synchronize again so Wine's direct-load path cannot
+ // retain the old bundled MoltenVK.
+ sync_vkmt_moltenvk_into_wine_tree(&wine_dir)?;
mark_split_bundle_installed(home, GRAPHICS_DLL_BUNDLE, &archive);
}
@@ -3222,6 +3244,28 @@ mod tests {
let _ = fs::remove_dir_all(home);
}
+ #[test]
+ fn vkmt_moltenvk_sync_replaces_equal_size_stale_wine_direct_load_copies() {
+ let home = test_home("vkmt-moltenvk-sync");
+ let wine_dir = crate::platform::metalsharp_home_dir_for(&home).join("runtime").join("wine");
+ let lane = wine_dir.join("lib").join("moltenvk-vkmt");
+ let unix = wine_dir.join("lib").join("wine").join("x86_64-unix");
+ fs::create_dir_all(&lane).expect("create VKMT lane");
+ fs::create_dir_all(&unix).expect("create Wine unix lane");
+ fs::write(lane.join("libMoltenVK.dylib"), b"vkmt-new-1.4.2").expect("write VKMT MoltenVK");
+ for name in ["libMoltenVK.dylib", "libMoltenVK.1.dylib"] {
+ fs::write(unix.join(name), b"stock-old-1.4.").expect("write stale stock MoltenVK");
+ }
+
+ assert!(sync_vkmt_moltenvk_into_wine_tree(&wine_dir).expect("synchronize VKMT MoltenVK"));
+ for name in ["libMoltenVK.dylib", "libMoltenVK.1.dylib"] {
+ assert_eq!(fs::read(unix.join(name)).unwrap(), b"vkmt-new-1.4.2", "{name} must match VKMT lane");
+ }
+ assert!(!sync_vkmt_moltenvk_into_wine_tree(&wine_dir).expect("idempotent synchronization"));
+
+ let _ = fs::remove_dir_all(home);
+ }
+
#[test]
fn moltenvk_vkmt_and_dxvk_lanes_ready_only_when_files_present() {
let home = test_home("vkmt-lanes");
diff --git a/app/src-rust/src/launch.rs b/app/src-rust/src/launch.rs
index 5112fa17..3f7475bc 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/main.rs b/app/src-rust/src/main.rs
index 25e10c7c..35000d81 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-rust/src/migrate.rs b/app/src-rust/src/migrate.rs
index 79da1f3e..4b69c588 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-rust/src/mtsp/launcher.rs b/app/src-rust/src/mtsp/launcher.rs
index 087cc67d..e366251d 100644
--- a/app/src-rust/src/mtsp/launcher.rs
+++ b/app/src-rust/src/mtsp/launcher.rs
@@ -917,6 +917,7 @@ fn is_metalsharp_route_dll_conflict(path: &Path) -> bool {
| "d3d10core.dll"
| "d3d11.dll"
| "d3d12.dll"
+ | "d3d12core.dll"
| "dxgi.dll"
| "dxgi_dxmt.dll"
| "nvapi64.dll"
@@ -928,6 +929,17 @@ fn is_metalsharp_route_dll_conflict(path: &Path) -> bool {
}
pub fn deploy_recipe_dlls(recipe: &super::recipe::LaunchRecipe) -> Result<(), Box> {
+ // This is the mandatory deployment boundary for every game-local graphics
+ // route. Remove the other runtime family's copies before writing the
+ // selected route so direct launch paths cannot retain stale DXVK/vkd3d or
+ // DXMT DLLs after a pipeline change.
+ let quarantined = quarantine_route_conflicts_for_recipe(recipe)?;
+ if quarantined > 0 {
+ eprintln!(
+ "mtsp: removed {} stale route DLL conflict(s) before deploying {}",
+ quarantined, recipe.pipeline_name
+ );
+ }
validate_recipe_runtime(recipe)?;
// Controller input shims ([x]/[d] selector) ride along with every
@@ -6209,6 +6221,135 @@ export VK_ICD_FILENAMES="/opt/homebrew/etc/vulkan/icd.d/MoltenVK_icd.json"
}
}
+ #[test]
+ fn deploy_recipe_dlls_strictly_isolates_m12_and_dxmt_route_families() {
+ let root = test_dir("strict-route-family-switch");
+ let game_dir = root.join("game");
+ let exe_dir = game_dir.join("Binaries").join("Win64");
+ let m12_source = root.join("runtime").join("wine").join("lib").join("vkd3d-proton").join("x86_64-windows");
+ let dxvk_source = root.join("runtime").join("wine").join("lib").join("dxvk").join("x86_64-windows");
+ let dxmt_source = root.join("runtime").join("wine").join("lib").join("dxmt").join("x86_64-windows");
+ std::fs::create_dir_all(&exe_dir).unwrap();
+ std::fs::create_dir_all(&m12_source).unwrap();
+ std::fs::create_dir_all(&dxvk_source).unwrap();
+ std::fs::create_dir_all(&dxmt_source).unwrap();
+ std::fs::write(exe_dir.join("Game.exe"), b"exe").unwrap();
+
+ for dll in ["d3d12.dll", "d3d12core.dll"] {
+ std::fs::write(m12_source.join(dll), format!("m12-vkd3d-{dll}")).unwrap();
+ }
+ for dll in ["dxgi.dll", "d3d11.dll"] {
+ std::fs::write(dxvk_source.join(dll), format!("m12-dxvk-{dll}")).unwrap();
+ }
+ for dll in ["dxgi.dll", "d3d11.dll", "winemetal.dll"] {
+ std::fs::write(dxmt_source.join(dll), format!("dxmt-{dll}")).unwrap();
+ }
+
+ let make_recipe =
+ |pipeline: PipelineId, backend: &str, sources: Vec<(&Path, &str, &str)>| recipe::LaunchRecipe {
+ appid: 42,
+ pipeline,
+ pipeline_name: pipeline.to_legacy_method().into(),
+ backend: backend.into(),
+ game_dir: Some(game_dir.clone()),
+ exe_path: Some(exe_dir.join("Game.exe")),
+ exe_name: Some("Game.exe".into()),
+ launch_args: Vec::new(),
+ env: Vec::new(),
+ dlls: sources
+ .into_iter()
+ .map(|(source_dir, source_subpath, filename)| recipe::RecipeDll {
+ source_subpath: source_subpath.into(),
+ filename: filename.into(),
+ source_path: source_dir.join(filename),
+ dest_path: exe_dir.join(filename),
+ source_present: true,
+ })
+ .collect(),
+ runtime_assets: Vec::new(),
+ warnings: Vec::new(),
+ };
+
+ let m12 = make_recipe(
+ PipelineId::M12,
+ "vkd3d-proton",
+ vec![
+ (&m12_source, "lib/vkd3d-proton/x86_64-windows", "d3d12.dll"),
+ (&m12_source, "lib/vkd3d-proton/x86_64-windows", "d3d12core.dll"),
+ (&dxvk_source, "lib/dxvk/x86_64-windows", "dxgi.dll"),
+ (&dxvk_source, "lib/dxvk/x86_64-windows", "d3d11.dll"),
+ ],
+ );
+ deploy_recipe_dlls(&m12).expect("deploy M12");
+ assert_eq!(std::fs::read_to_string(exe_dir.join("dxgi.dll")).unwrap(), "m12-dxvk-dxgi.dll");
+ assert_eq!(std::fs::read_to_string(exe_dir.join("d3d11.dll")).unwrap(), "m12-dxvk-d3d11.dll");
+
+ let m11 = make_recipe(
+ PipelineId::M11,
+ "dxmt",
+ vec![
+ (&dxmt_source, "lib/dxmt/x86_64-windows", "dxgi.dll"),
+ (&dxmt_source, "lib/dxmt/x86_64-windows", "d3d11.dll"),
+ (&dxmt_source, "lib/dxmt/x86_64-windows", "winemetal.dll"),
+ ],
+ );
+ deploy_recipe_dlls(&m11).expect("switch M12 to M11");
+ for dll in ["d3d12.dll", "d3d12core.dll"] {
+ assert!(!exe_dir.join(dll).exists(), "DXMT route must remove stale M12 {dll}");
+ }
+ assert_eq!(std::fs::read_to_string(exe_dir.join("dxgi.dll")).unwrap(), "dxmt-dxgi.dll");
+ assert_eq!(std::fs::read_to_string(exe_dir.join("d3d11.dll")).unwrap(), "dxmt-d3d11.dll");
+
+ deploy_recipe_dlls(&m12).expect("switch M11 to M12");
+ assert!(!exe_dir.join("winemetal.dll").exists(), "M12 route must remove stale DXMT winemetal.dll");
+ for (dll, contents) in [
+ ("d3d12.dll", "m12-vkd3d-d3d12.dll"),
+ ("d3d12core.dll", "m12-vkd3d-d3d12core.dll"),
+ ("dxgi.dll", "m12-dxvk-dxgi.dll"),
+ ("d3d11.dll", "m12-dxvk-d3d11.dll"),
+ ] {
+ assert_eq!(std::fs::read_to_string(exe_dir.join(dll)).unwrap(), contents, "M12 route must own {dll}");
+ }
+
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn every_dxmt_route_quarantines_the_m12_ownership_set() {
+ for pipeline in [PipelineId::M11, PipelineId::M11_32, PipelineId::M10, PipelineId::M10_32] {
+ let root = test_dir(&format!("dxmt-route-quarantine-{pipeline:?}"));
+ let game_dir = root.join("game");
+ let exe_dir = game_dir.join("bin");
+ std::fs::create_dir_all(&exe_dir).unwrap();
+ std::fs::write(exe_dir.join("Game.exe"), b"exe").unwrap();
+ for dll in ["d3d12.dll", "d3d12core.dll", "dxgi.dll", "d3d11.dll"] {
+ std::fs::write(exe_dir.join(dll), format!("stale-m12-{dll}")).unwrap();
+ }
+
+ let recipe = recipe::LaunchRecipe {
+ appid: 42,
+ pipeline,
+ pipeline_name: format!("{pipeline:?}"),
+ backend: "dxmt".into(),
+ game_dir: Some(game_dir.clone()),
+ exe_path: Some(exe_dir.join("Game.exe")),
+ exe_name: Some("Game.exe".into()),
+ launch_args: Vec::new(),
+ env: Vec::new(),
+ dlls: Vec::new(),
+ runtime_assets: Vec::new(),
+ warnings: Vec::new(),
+ };
+
+ let removed = quarantine_route_conflicts_for_recipe(&recipe).expect("quarantine M12 route files");
+ assert_eq!(removed, 4, "{pipeline:?} must evict every M12-owned D3D12/DXVK DLL");
+ for dll in ["d3d12.dll", "d3d12core.dll", "dxgi.dll", "d3d11.dll"] {
+ assert!(!exe_dir.join(dll).exists(), "{pipeline:?} must remove stale M12 {dll}");
+ }
+ let _ = std::fs::remove_dir_all(root);
+ }
+ }
+
#[test]
fn m11_quarantines_stale_route_dlls_before_deploying_legacy_dxmt() {
let root = test_dir("m11-route-quarantine");
diff --git a/app/src-rust/src/mtsp/rules.rs b/app/src-rust/src/mtsp/rules.rs
index b5d99321..ebbc5b0d 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/app/src/main/index.ts b/app/src/main/index.ts
index 10a56621..64e352e6 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;
}
@@ -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) => {
@@ -608,7 +636,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,
@@ -712,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 82a60454..e675cb90 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/App.vue b/app/src/renderer/App.vue
index 41de8385..41bbf9df 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 bb20d223..f87ca6b9 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 {
@@ -214,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/composables/useTelemetry.ts b/app/src/renderer/composables/useTelemetry.ts
new file mode 100644
index 00000000..f51027d2
--- /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 c9efa4bd..7f1c621e 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/LibraryView.vue b/app/src/renderer/views/LibraryView.vue
index 00b439db..33bc31dd 100644
--- a/app/src/renderer/views/LibraryView.vue
+++ b/app/src/renderer/views/LibraryView.vue
@@ -1,7 +1,7 @@