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.** CI -Release +Release Discussions PolyForm Noncommercial 1.0.0 DISCORD @@ -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 @@