diff --git a/src/launcher-tauri/README.md b/src/launcher-tauri/README.md index 1991ad6d9..da915ab72 100644 --- a/src/launcher-tauri/README.md +++ b/src/launcher-tauri/README.md @@ -16,6 +16,9 @@ unaffected either way. ## Run it +`start.sh` is bash-only. For Windows and macOS, and for a step-by-step first run, see +[RUNNING.md](RUNNING.md). + ```bash cd src/launcher-tauri npm install # first time only diff --git a/src/launcher-tauri/RUNNING.md b/src/launcher-tauri/RUNNING.md new file mode 100644 index 000000000..bc9f3e303 --- /dev/null +++ b/src/launcher-tauri/RUNNING.md @@ -0,0 +1,151 @@ +# Running the Kyty Launcher (Tauri) — Windows, macOS, Linux + +Step-by-step guide to starting the Tauri launcher (`src/launcher-tauri`) on each platform. + +[README.md](README.md) covers what the launcher *is* and the Linux `./start.sh` flow. +This file covers the cross-platform startup path, because `start.sh` is a bash script that +probes `../../_Build/linux/install` and uses `ss` — it does not apply on Windows. + +The Tauri launcher is opt-in and separate from the Qt6 launcher (`src/launcher`). It is not +wired into the root `CMakeLists.txt` or CI, so nothing here changes how the emulator builds. + +## 1. Prerequisites + +| Need | Windows | macOS | Linux | +|---|---|---|---| +| Node.js + npm | [nodejs.org](https://nodejs.org) LTS | `brew install node` | distro package | +| Rust toolchain | `rustup` (`x86_64-pc-windows-msvc`) | `rustup` | `rustup` | +| C/C++ linker | MSVC Build Tools (already present if you build the emulator with `clang-cl`) | Xcode CLT | `build-essential` | +| WebView | WebView2 Runtime — preinstalled on Windows 11 | WKWebView (system) | `webkit2gtk-4.1` + `libsoup3` dev packages | + +Check what you have: + +```powershell +node --version; npm --version; cargo --version +``` + +## 2. Build the emulator first (recommended) + +The launcher UI starts without it, but **Launch** fails with `Could not find kyty_emulator` +until the emulator binary exists. Build and install it per the root +[README.md](../../README.md): + +```powershell +# from the repo root, inside a VS dev shell +cmake --build _Build/windows --target kyty_emulator +cmake --install _Build/windows --prefix _Build/windows/install +``` + +That produces `_Build/windows/install/kyty_emulator.exe` (`_Build/linux/install/kyty_emulator` +on Linux, `_Build/macos/install/` on macOS) — exactly where the launcher looks. See +[How the launcher finds the emulator](#how-the-launcher-finds-the-emulator). + +## 3. Install the frontend dependencies + +Once, and again whenever `package.json` changes: + +```powershell +cd src\launcher-tauri +npm ci +``` + +`npm ci` installs the exact `package-lock.json` set. Use `npm install` only when adding deps. + +## 4. Start it + +Three ways. Pick by what you are doing. + +### a. Development — hot reload + +```powershell +npm run tauri dev +``` + +Vite serves the UI on `http://localhost:1421` with HMR, and a debug Tauri binary points at it. +Edit anything under `src/` and the window refreshes. Rust changes under `src-tauri/src/` +trigger a recompile and restart. + +Port 1421 is `strictPort: true` in [vite.config.ts](vite.config.ts) — if something else holds +it, the dev server fails instead of picking another port. Find the holder: + +```powershell +netstat -ano | Select-String ":1421" +Stop-Process -Id +``` + +### b. Release binary — standalone, what you actually play on + +```powershell +npm run tauri build -- --no-bundle +.\src-tauri\target\release\kyty-launcher.exe +``` + +`--no-bundle` skips installer generation, so you get just the executable. On Linux/macOS the +binary is `src-tauri/target/release/kyty-launcher`. + +> **The debug binary is not a substitute.** It has `devUrl http://localhost:1421` baked in and +> fails standalone with `Could not connect to localhost: Connection refused`. Only the release +> binary embeds the built frontend. Run the debug one through `tauri dev` or not at all. + +### c. Installable package + +```powershell +npm run tauri build +``` + +Artifacts land in `src-tauri/target/release/bundle/`. `bundle.targets` is `"all"` in +[tauri.conf.json](src-tauri/tauri.conf.json), so each host resolves its own formats — +MSI/NSIS on Windows, `.app`/`.dmg` on macOS, `.deb`/AppImage on Linux. + +### Linux/macOS shortcut + +`./start.sh` wraps (b): builds the release binary if any watched source is newer, then runs it. +`./start.sh --dev` runs (a); `./start.sh --no-build` reruns the existing binary. Bash only. + +## How the launcher finds the emulator + +[`discover_emulator`](src-tauri/src/emulator.rs) probes in order: + +1. `kyty_emulator.exe` next to the launcher binary +2. the launcher binary's parent directory +3. walking up to 8 parent directories, checking `_Build/windows/install/kyty_emulator.exe` + at each (`_Build/linux/install/` / `_Build/macos/install/` on the other platforms) +4. `PATH` + +Step 3 is what makes a dev checkout work with no configuration: from +`src-tauri/target/release/` it takes 5 levels to reach the repo root, well inside the limit. +If your emulator lives elsewhere, put it on `PATH` or set the path in Settings. + +## Where settings live + +The launcher reads and writes the same `Kyty.ini` as the Qt launcher, resolved by +[`resolve_settings_path`](src-tauri/src/config.rs): + +1. `Kyty.ini` in the current working directory wins — portable install +2. otherwise the per-user config directory: + +| OS | Path | +|---|---| +| Windows | `%APPDATA%\Kyty\Kyty.ini` | +| macOS | `~/Library/Application Support/Kyty/Kyty.ini` | +| Linux | `~/.config/Kyty/Kyty.ini` | + +## Windows launch behaviour + +- **Normal launch** passes `CREATE_NO_WINDOW`, so the emulator's console is hidden. Its output + is not lost — it streams to the in-app console and the session log. +- **External terminal** launch mode opens `cmd.exe /K` in a new console instead, when you want + a live terminal. +- **Auto-close on launch** (Settings > Emulator Settings, on by default) exits the launcher GUI + when a game starts; a detached supervisor owns the emulator, records playtime, then relaunches + the launcher. Turn it off to keep the window open during play. + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `Could not find kyty_emulator` on Launch | emulator not built or installed | run step 2 | +| `Could not connect to localhost: Connection refused` | ran the debug binary standalone | use the release binary, or `npm run tauri dev` | +| Dev server exits immediately | port 1421 taken (`strictPort`) | kill the holder, see 4a | +| UI text falls back to English | missing locale keys | `npm run i18n:coverage` reports missing/stale keys per locale | +| Rust build fails on a missing autogenerated permissions file | `src-tauri/target/` was copied from another absolute path, stale build-script cache | `Remove-Item -Recurse -Force src-tauri\target` and rebuild | diff --git a/src/launcher-tauri/public/art/ambient_boot.png b/src/launcher-tauri/public/art/ambient_boot.png deleted file mode 100644 index bf5b69561..000000000 Binary files a/src/launcher-tauri/public/art/ambient_boot.png and /dev/null differ diff --git a/src/launcher-tauri/public/art/ambient_boot.webp b/src/launcher-tauri/public/art/ambient_boot.webp new file mode 100644 index 000000000..4de80bfb2 Binary files /dev/null and b/src/launcher-tauri/public/art/ambient_boot.webp differ diff --git a/src/launcher-tauri/public/art/ambient_idle.png b/src/launcher-tauri/public/art/ambient_idle.png deleted file mode 100644 index 04f4c9623..000000000 Binary files a/src/launcher-tauri/public/art/ambient_idle.png and /dev/null differ diff --git a/src/launcher-tauri/public/art/ambient_idle.webp b/src/launcher-tauri/public/art/ambient_idle.webp new file mode 100644 index 000000000..b59b9d446 Binary files /dev/null and b/src/launcher-tauri/public/art/ambient_idle.webp differ diff --git a/src/launcher-tauri/public/art/controller_diagram.png b/src/launcher-tauri/public/art/controller_diagram.png deleted file mode 100644 index eccc9a9aa..000000000 Binary files a/src/launcher-tauri/public/art/controller_diagram.png and /dev/null differ diff --git a/src/launcher-tauri/public/art/controller_diagram.webp b/src/launcher-tauri/public/art/controller_diagram.webp new file mode 100644 index 000000000..fc708bfe7 Binary files /dev/null and b/src/launcher-tauri/public/art/controller_diagram.webp differ diff --git a/src/launcher-tauri/public/art/cover_placeholder_01.png b/src/launcher-tauri/public/art/cover_placeholder_01.png deleted file mode 100644 index 801d43c37..000000000 Binary files a/src/launcher-tauri/public/art/cover_placeholder_01.png and /dev/null differ diff --git a/src/launcher-tauri/public/art/cover_placeholder_01.webp b/src/launcher-tauri/public/art/cover_placeholder_01.webp new file mode 100644 index 000000000..6fae17c08 Binary files /dev/null and b/src/launcher-tauri/public/art/cover_placeholder_01.webp differ diff --git a/src/launcher-tauri/public/art/cover_placeholder_02.png b/src/launcher-tauri/public/art/cover_placeholder_02.png deleted file mode 100644 index 405d5d02e..000000000 Binary files a/src/launcher-tauri/public/art/cover_placeholder_02.png and /dev/null differ diff --git a/src/launcher-tauri/public/art/cover_placeholder_02.webp b/src/launcher-tauri/public/art/cover_placeholder_02.webp new file mode 100644 index 000000000..924f59d6f Binary files /dev/null and b/src/launcher-tauri/public/art/cover_placeholder_02.webp differ diff --git a/src/launcher-tauri/public/art/cover_placeholder_03.png b/src/launcher-tauri/public/art/cover_placeholder_03.png deleted file mode 100644 index 21494dc84..000000000 Binary files a/src/launcher-tauri/public/art/cover_placeholder_03.png and /dev/null differ diff --git a/src/launcher-tauri/public/art/cover_placeholder_03.webp b/src/launcher-tauri/public/art/cover_placeholder_03.webp new file mode 100644 index 000000000..ff66cd0a3 Binary files /dev/null and b/src/launcher-tauri/public/art/cover_placeholder_03.webp differ diff --git a/src/launcher-tauri/public/art/cover_placeholder_04.png b/src/launcher-tauri/public/art/cover_placeholder_04.png deleted file mode 100644 index a69e07cfb..000000000 Binary files a/src/launcher-tauri/public/art/cover_placeholder_04.png and /dev/null differ diff --git a/src/launcher-tauri/public/art/cover_placeholder_04.webp b/src/launcher-tauri/public/art/cover_placeholder_04.webp new file mode 100644 index 000000000..02dac9fd2 Binary files /dev/null and b/src/launcher-tauri/public/art/cover_placeholder_04.webp differ diff --git a/src/launcher-tauri/public/art/cover_placeholder_05.png b/src/launcher-tauri/public/art/cover_placeholder_05.png deleted file mode 100644 index bde31f53d..000000000 Binary files a/src/launcher-tauri/public/art/cover_placeholder_05.png and /dev/null differ diff --git a/src/launcher-tauri/public/art/cover_placeholder_05.webp b/src/launcher-tauri/public/art/cover_placeholder_05.webp new file mode 100644 index 000000000..1554c0cdf Binary files /dev/null and b/src/launcher-tauri/public/art/cover_placeholder_05.webp differ diff --git a/src/launcher-tauri/public/art/cover_placeholder_06.png b/src/launcher-tauri/public/art/cover_placeholder_06.png deleted file mode 100644 index fe87941c3..000000000 Binary files a/src/launcher-tauri/public/art/cover_placeholder_06.png and /dev/null differ diff --git a/src/launcher-tauri/public/art/cover_placeholder_06.webp b/src/launcher-tauri/public/art/cover_placeholder_06.webp new file mode 100644 index 000000000..e3cdbed4e Binary files /dev/null and b/src/launcher-tauri/public/art/cover_placeholder_06.webp differ diff --git a/src/launcher-tauri/public/art/kyty_mark.png b/src/launcher-tauri/public/art/kyty_mark.png deleted file mode 100644 index 1eed98303..000000000 Binary files a/src/launcher-tauri/public/art/kyty_mark.png and /dev/null differ diff --git a/src/launcher-tauri/public/art/kyty_mark.webp b/src/launcher-tauri/public/art/kyty_mark.webp new file mode 100644 index 000000000..e208986dd Binary files /dev/null and b/src/launcher-tauri/public/art/kyty_mark.webp differ diff --git a/src/launcher-tauri/scripts/i18n-coverage.mjs b/src/launcher-tauri/scripts/i18n-coverage.mjs index 22dd2ba67..7da8c7618 100644 --- a/src/launcher-tauri/scripts/i18n-coverage.mjs +++ b/src/launcher-tauri/scripts/i18n-coverage.mjs @@ -36,7 +36,13 @@ function stripInterfaceBlock(src, name) { } function loadCatalog(file) { - const src = readFileSync(path.join(localesDir, file), "utf8"); + // Normalised to LF first. Every strip below is a regex anchored on a bare + // newline, and git checks these files out with CRLF on Windows + // (core.autocrlf), so on a Windows clone the DeepPartial strip matched + // nothing: the `export type` survived into the data: URL and the whole + // script died with "SyntaxError: Unexpected token 'export'". It reports + // coverage fine on Linux, which is why this went unnoticed. + const src = readFileSync(path.join(localesDir, file), "utf8").replace(/\r\n/g, "\n"); const stripped = stripInterfaceBlock(src, "Catalog") .replace(/export type DeepPartial[\s\S]*?:\s*T;\n/, "") .replace(/^import[^\n]*\n/gm, "") diff --git a/src/launcher-tauri/src-tauri/Cargo.lock b/src/launcher-tauri/src-tauri/Cargo.lock index 2d9d7e166..8a2c01438 100644 --- a/src/launcher-tauri/src-tauri/Cargo.lock +++ b/src/launcher-tauri/src-tauri/Cargo.lock @@ -2297,7 +2297,9 @@ dependencies = [ "tokio", "walkdir", "webkit2gtk", + "webview2-com", "windows 0.62.2", + "windows-core 0.61.2", ] [[package]] diff --git a/src/launcher-tauri/src-tauri/Cargo.toml b/src/launcher-tauri/src-tauri/Cargo.toml index 75293c0ae..e4a0c5084 100644 --- a/src/launcher-tauri/src-tauri/Cargo.toml +++ b/src/launcher-tauri/src-tauri/Cargo.toml @@ -8,6 +8,21 @@ edition = "2021" name = "kyty_launcher_lib" crate-type = ["staticlib", "cdylib", "rlib"] +# Release tuning per Tauri's own size guide. This launcher is a front-end +# that sits resident next to an emulator competing for the same machine, so +# its code pages are RAM the emulator does not get: smaller is the point, +# and there is no hot compute path here that would want opt-level = 3. +# +# `panic = "abort"` drops the unwinding tables. Nothing here uses +# catch_unwind (checked), and cargo ignores this setting when building test +# targets, so `cargo test --release` still works. +[profile.release] +codegen-units = 1 # one unit lets LLVM optimize across the whole crate +lto = true # link-time optimization across crates too +opt-level = "s" # size over raw speed -- see above +panic = "abort" # no unwinding tables +strip = true # no debug symbols in the shipped binary + [build-dependencies] tauri-build = { version = "2", features = [] } @@ -34,6 +49,13 @@ webkit2gtk = { version = "2.0", features = ["v2_38"] } bluer = { version = "0.17.4", features = ["bluetoothd"] } [target.'cfg(target_os = "windows")'.dependencies] +# WebView2 memory tuning for webview2_tuning.rs -- see that file's doc comment. +# Pinned to the tree wry/tauri already link (webview2-com 0.38 -> windows-core +# 0.61), which is a different `windows` major than the 0.62 the Bluetooth code +# below uses. Both resolve side by side; the COM types must not be mixed across +# them, and are not. +webview2-com = "0.38" +windows-core = "0.61" windows = { version = "0.62", features = [ "Devices_Bluetooth", "Devices_Enumeration", diff --git a/src/launcher-tauri/src-tauri/capabilities/default.json b/src/launcher-tauri/src-tauri/capabilities/default.json index d8980bff4..cd50d5483 100644 --- a/src/launcher-tauri/src-tauri/capabilities/default.json +++ b/src/launcher-tauri/src-tauri/capabilities/default.json @@ -11,6 +11,10 @@ "core:window:allow-current-monitor", "core:window:allow-primary-monitor", { "identifier": "opener:allow-open-path", "allow": [{ "path": "**" }] }, + { + "identifier": "opener:allow-open-url", + "allow": [{ "url": "https://github.com/KytyPS5/KytyPS5/issues/*" }] + }, "core:window:allow-start-dragging", "core:window:allow-start-resize-dragging", diff --git a/src/launcher-tauri/src-tauri/src/compatibility.rs b/src/launcher-tauri/src-tauri/src/compatibility.rs index 8b10d5555..0720cb7b8 100644 --- a/src/launcher-tauri/src-tauri/src/compatibility.rs +++ b/src/launcher-tauri/src-tauri/src/compatibility.rs @@ -54,6 +54,41 @@ pub struct CompatibilityEntry { pub status: GameStatus, #[serde(default)] pub comment: String, + /// How many community reports back `status`. 0 for a locally-edited + /// entry, which is the user's own opinion rather than a report count. + #[serde(default)] + pub reports: u32, + /// The emulator build the reports were filed against, e.g. + /// "KytyPS5-2026-08-16-bc2f077". Empty when the feed does not say. + #[serde(default)] + pub version: String, + /// True when `status` came from this platform's own reports rather than + /// the feed's cross-platform aggregate -- see `platform_key`. + #[serde(default)] + pub platform_specific: bool, +} + +/// Which `platforms` sub-object of the community feed applies to this build. +/// +/// The feed carries a per-OS breakdown next to its aggregate, and the two +/// disagree often enough to matter: a title reported InGame on Linux can be +/// DoesntBoot on Windows, and the aggregate hides that. #177 raised exactly +/// this ("be aware of the game compatibility across platforms ... probably +/// not, especially on macOS"), so prefer this platform's own reports and +/// keep the aggregate only as a fallback. +const fn platform_key() -> &'static str { + #[cfg(windows)] + { + "windows" + } + #[cfg(target_os = "macos")] + { + "macos" + } + #[cfg(not(any(windows, target_os = "macos")))] + { + "linux" + } } pub type CompatibilityMap = HashMap; @@ -72,13 +107,25 @@ fn parse(data: &str) -> Result { if title_id.is_empty() { continue; } - let status = value + // This platform's own reports win over the cross-platform aggregate. + // A locally-edited file has no "platforms" at all, so it falls + // straight through to the top level, which is what it should do. + let per_platform = value.get("platforms").and_then(|p| p.get(platform_key())); + let platform_specific = per_platform.is_some(); + let source = per_platform.unwrap_or(&value); + + let status = source .get("status") .and_then(|v| v.as_str()) .map(GameStatus::from_text) .unwrap_or_default(); - let comment = value.get("comment").and_then(|v| v.as_str()).unwrap_or_default().to_string(); - entries.insert(title_id, CompatibilityEntry { status, comment }); + let comment = source.get("comment").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + let reports = source.get("reports").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + let version = source.get("version").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + entries.insert( + title_id, + CompatibilityEntry { status, comment, reports, version, platform_specific }, + ); } Ok(entries) } @@ -184,6 +231,66 @@ mod tests { assert_eq!(entries.get("PPSA01234").unwrap().status, GameStatus::MainMenu); } + /// Shaped exactly like a real entry from the community feed. + const FEED_ENTRY: &str = r#"{ + "PPSA01234": { + "status": "InGame", + "reports": 4, + "comment": "4 reports", + "platforms": { + "windows": { "status": "DoesntBoot", "reports": 1, "comment": "1 report", + "version": "KytyPS5-2026-08-16-bc2f077" }, + "linux": { "status": "InGame", "reports": 3, "comment": "3 reports", + "version": "KytyPS5-2026-08-16-bc2f077" }, + "macos": { "status": "Logo", "reports": 1, "comment": "1 report", + "version": "KytyPS5-2026-08-16-bc2f077" } + } + } + }"#; + + #[test] + fn this_platforms_reports_win_over_the_aggregate() { + let entry = parse(FEED_ENTRY).unwrap().remove("PPSA01234").unwrap(); + + // The aggregate says InGame. Whatever this platform's own reports + // say is what the user is shown instead -- the point of the split. + let expected = if cfg!(windows) { + GameStatus::DoesntBoot + } else if cfg!(target_os = "macos") { + GameStatus::Logo + } else { + GameStatus::InGame + }; + assert_eq!(entry.status, expected); + assert!(entry.platform_specific); + assert_eq!(entry.version, "KytyPS5-2026-08-16-bc2f077"); + } + + #[test] + fn entry_without_platforms_falls_back_to_the_aggregate() { + let json = r#"{ "PPSA01234": { "status": "MainMenu", "reports": 2 } }"#; + let entry = parse(json).unwrap().remove("PPSA01234").unwrap(); + + assert_eq!(entry.status, GameStatus::MainMenu); + assert_eq!(entry.reports, 2); + // Nothing claimed this is a per-platform figure, so the UI must not + // present it as one. + assert!(!entry.platform_specific); + } + + #[test] + fn locally_edited_entries_are_never_platform_specific() { + let dir = tempfile::tempdir().unwrap(); + let mut entries = CompatibilityMap::new(); + set_status(&mut entries, "PPSA01234", GameStatus::InGame); + save_local(dir.path(), &entries).unwrap(); + + let entry = load_local(dir.path()).remove("PPSA01234").unwrap(); + assert_eq!(entry.status, GameStatus::InGame); + assert!(!entry.platform_specific); + assert_eq!(entry.reports, 0, "a local edit is an opinion, not a report"); + } + #[test] fn local_round_trip() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/launcher-tauri/src-tauri/src/config.rs b/src/launcher-tauri/src-tauri/src/config.rs index cd84c9e49..83c0795b4 100644 --- a/src/launcher-tauri/src-tauri/src/config.rs +++ b/src/launcher-tauri/src-tauri/src/config.rs @@ -287,8 +287,26 @@ impl Configuration { } doc.set(section, &k("host_input_mapping"), encode_string_list(&self.host_input_mapping)); doc.set(section, &k("elf"), encode_string(&self.elf)); - doc.set(section, &k("audio_output_device"), encode_string(&self.audio_output_device)); - doc.set(section, &k("audio_input_device"), encode_string(&self.audio_input_device)); + // Same rule as bvh_stub_enabled above: both of these are + // launcher-tauri additions the Qt launcher knows nothing about, so + // writing them unconditionally put `audio_output_device=` and + // `audio_input_device=` into every Kyty.ini this launcher saved -- + // including the files a user carries back and forth to the Qt + // launcher. Empty means "whatever SDL2 picks by default", which is + // exactly what an absent key already decodes to (read_from leaves + // the String::new() default), so the empty case is left out + // entirely and an existing key is cleared on the way back to + // default. + if self.audio_output_device.is_empty() { + doc.remove(section, &k("audio_output_device")); + } else { + doc.set(section, &k("audio_output_device"), encode_string(&self.audio_output_device)); + } + if self.audio_input_device.is_empty() { + doc.remove(section, &k("audio_input_device")); + } else { + doc.set(section, &k("audio_input_device"), encode_string(&self.audio_input_device)); + } } fn read_from(doc: &IniDocument, section: &str, prefix: &str) -> Self { @@ -560,6 +578,35 @@ mod tests { assert_eq!(after.trim_end(), REAL_KYTY_INI.trim_end()); } + #[test] + fn selected_audio_devices_round_trip_and_clear_back_out() { + let dir = tempfile::tempdir().unwrap(); + let path = write_fixture(&dir, "Kyty.ini", REAL_KYTY_INI); + + // A picked device is persisted... + let mut cfg = load(&path); + cfg.global.audio_output_device = "Speakers (Realtek)".to_string(); + cfg.global.audio_input_device = "Microphone (USB)".to_string(); + save(&path, &cfg).unwrap(); + + let reloaded = load(&path); + assert_eq!(reloaded.global.audio_output_device, "Speakers (Realtek)"); + assert_eq!(reloaded.global.audio_input_device, "Microphone (USB)"); + + // ...and clearing it back to "system default" takes the key out + // again rather than leaving an empty one behind, so the file goes + // back to something the Qt launcher would have written. + let mut cleared = reloaded; + cleared.global.audio_output_device = String::new(); + cleared.global.audio_input_device = String::new(); + save(&path, &cleared).unwrap(); + + let after = fs::read_to_string(&path).unwrap(); + assert!(!after.contains("audio_output_device"), "{after}"); + assert!(!after.contains("audio_input_device"), "{after}"); + assert_eq!(after.trim_end(), REAL_KYTY_INI.trim_end()); + } + #[test] fn load_then_save_preserves_unowned_geometry_blob() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/launcher-tauri/src-tauri/src/emulator.rs b/src/launcher-tauri/src-tauri/src/emulator.rs index 74c29d4e9..aac3fb168 100644 --- a/src/launcher-tauri/src-tauri/src/emulator.rs +++ b/src/launcher-tauri/src-tauri/src/emulator.rs @@ -4,7 +4,11 @@ use crate::config::Configuration; use serde::Serialize; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader}; +// Only write_bash_script() needs it, and that is Unix-only -- the Windows +// path hands the whole command line to `cmd /K` instead of writing a script. +#[cfg(unix)] +use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::Mutex; diff --git a/src/launcher-tauri/src-tauri/src/lib.rs b/src/launcher-tauri/src-tauri/src/lib.rs index 1e4626643..cd2c55049 100644 --- a/src/launcher-tauri/src-tauri/src/lib.rs +++ b/src/launcher-tauri/src-tauri/src/lib.rs @@ -20,6 +20,9 @@ mod trophy; #[cfg(target_os = "linux")] mod webkit_tuning; +#[cfg(windows)] +mod webview2_tuning; + use compatibility::CompatibilityMap; use config::{Configuration, KytyConfig}; use std::collections::HashMap; @@ -282,6 +285,22 @@ fn record_play_stop(app: tauri::AppHandle, game_path: String) -> Result<(), Stri playtime::record_stop(&dir, &game_path, elapsed_seconds).map_err(|e| e.to_string()) } +/// Hands WebView2 the hint that this launcher is not the thing the machine +/// should be spending memory on right now. Called by the frontend +/// (lib/idle.ts) on the game-running transition only -- see +/// webview2_tuning.rs for why not on focus changes, and why not TrySuspend. +/// A no-op off Windows: WebKitGTK is tuned once at window creation instead +/// (webkit_tuning.rs), and WKWebView exposes no equivalent. +#[tauri::command] +fn set_webview_memory_low(app: tauri::AppHandle, low: bool) { + #[cfg(windows)] + if let Some(window) = app.get_webview_window("main") { + webview2_tuning::set_low_memory(&window, low); + } + #[cfg(not(windows))] + let _ = (app, low); +} + #[tauri::command] fn stop_game(state: State) -> Result<(), String> { emulator::stop(&state.run_state).map_err(|e| e.to_string()) @@ -707,6 +726,7 @@ pub fn run() { save_prefs, run_game, stop_game, + set_webview_memory_low, is_game_running, is_resumed_launch, get_logs_dir, diff --git a/src/launcher-tauri/src-tauri/src/webview2_tuning.rs b/src/launcher-tauri/src-tauri/src/webview2_tuning.rs new file mode 100644 index 000000000..9b32ae797 --- /dev/null +++ b/src/launcher-tauri/src-tauri/src/webview2_tuning.rs @@ -0,0 +1,56 @@ +//! The Windows half of the idle-cost work that `webkit_tuning.rs` does for +//! WebKitGTK. Measured here with the window open and the user doing nothing, +//! this launcher sat at ~17% of one core and ~511MB working set across eight +//! processes (kyty-launcher plus WebView2's browser/renderer/GPU/utility +//! set). Most of the CPU was WebView2's GPU process compositing animations +//! nobody was watching; the frontend's `data-idle` switch (lib/idle.ts) is +//! what parks those. +//! +//! This file covers the other half, the resident memory, for the one case +//! that actually matters to an emulator front-end: a game is running and the +//! launcher is still alive because `autoCloseOnLaunch` is off. WebView2's +//! `MemoryUsageTargetLevel` is Microsoft's documented knob for exactly that +//! -- "Set MemoryUsageTargetLevel = Low on inactive WebViews to reduce memory +//! usage -- this may prompt the browser engine to drop cached data or swap +//! memory to disk", restored to `Normal` when the WebView is active again. +//! +//! Deliberately NOT using `TrySuspendAsync`, the stronger knob sitting right +//! next to it: its documented precondition is that the controller's +//! `IsVisible` be false, and it throws `ERROR_INVALID_STATE` otherwise. The +//! launcher window is normally still visible behind a running game, so using +//! suspend would mean blanking the webview first -- a visible behaviour +//! change, not a free win, and one for the owner to decide rather than +//! something to slip in under a memory tweak. +//! +//! Only bound to the game-running transition, never to focus changes: Low is +//! documented to drop caches and swap, so paying that on every alt-tab would +//! trade a little idle RAM for a stutter on every return. + +use webview2_com::Microsoft::Web::WebView2::Win32::{ + ICoreWebView2_19, COREWEBVIEW2_MEMORY_USAGE_TARGET_LEVEL_LOW, + COREWEBVIEW2_MEMORY_USAGE_TARGET_LEVEL_NORMAL, +}; +use windows_core::Interface; + +/// Best effort throughout: every step here is an optional capability of the +/// installed WebView2 runtime (`ICoreWebView2_19` is only present from +/// 1.0.1722.45 on), and failing to shrink a cache is never a reason to fail +/// whatever the caller was actually doing. +pub fn set_low_memory(window: &tauri::WebviewWindow, low: bool) { + let level = if low { + COREWEBVIEW2_MEMORY_USAGE_TARGET_LEVEL_LOW + } else { + COREWEBVIEW2_MEMORY_USAGE_TARGET_LEVEL_NORMAL + }; + + let _ = window.with_webview(move |webview| { + let controller = webview.controller(); + let Ok(core) = (unsafe { controller.CoreWebView2() }) else { + return; + }; + let Ok(core19) = core.cast::() else { + return; + }; + let _ = unsafe { core19.SetMemoryUsageTargetLevel(level) }; + }); +} diff --git a/src/launcher-tauri/src/boot/BootController.tsx b/src/launcher-tauri/src/boot/BootController.tsx index 448e5a2df..9ad4d5908 100644 --- a/src/launcher-tauri/src/boot/BootController.tsx +++ b/src/launcher-tauri/src/boot/BootController.tsx @@ -105,8 +105,8 @@ export function BootController({ {children} {phase !== "ready" && (
-
- +
+
{t("boot.status")}
)} diff --git a/src/launcher-tauri/src/components/Dropdown.module.css b/src/launcher-tauri/src/components/Dropdown.module.css index ead70609a..5dd186711 100644 --- a/src/launcher-tauri/src/components/Dropdown.module.css +++ b/src/launcher-tauri/src/components/Dropdown.module.css @@ -89,7 +89,8 @@ transform: translateY(0) scale(1); } -:global(:root[data-perf="low"]) .panel { +:global(:root[data-perf="low"]) .panel, +:global(:root[data-idle="1"]) .panel { backdrop-filter: none; -webkit-backdrop-filter: none; background: rgba(20, 24, 36, 0.96); diff --git a/src/launcher-tauri/src/components/GameCard.module.css b/src/launcher-tauri/src/components/GameCard.module.css index a62780bef..26f452ffa 100644 --- a/src/launcher-tauri/src/components/GameCard.module.css +++ b/src/launcher-tauri/src/components/GameCard.module.css @@ -154,3 +154,9 @@ .captionHidden { opacity: 0; } + +/* Idle throttle (lib/idle.ts, theme.css) -- same reasoning as GameTile. */ +:global(:root[data-idle="1"]) .artFlux::before, +:global(:root[data-idle="1"]) .artShimmer::before { + animation-play-state: paused; +} diff --git a/src/launcher-tauri/src/components/GameDetail.module.css b/src/launcher-tauri/src/components/GameDetail.module.css index 4f3365b96..e6a557a03 100644 --- a/src/launcher-tauri/src/components/GameDetail.module.css +++ b/src/launcher-tauri/src/components/GameDetail.module.css @@ -172,3 +172,73 @@ display: flex; flex-direction: column; } + +/* ---- compatibility badge + its hover detail ----------------------------- + The badge itself is the whole story at a glance ("In game"); which + platform that figure came from, how many reports stand behind it and + which build they were filed against is the fine print you go looking for, + not something to read every time you open a game. So the badge stands + alone and the rest appears on hover. + + `.ps-focused` alongside `:hover` is not optional here: this app is driven + by a gamepad, which has no pointer, so a hover-only reveal would hide the + detail from exactly the input it is designed around. The badge carries + data-focusable so the pad can land on it -- the same thing Trophies' and + the saved-data rows do for informational elements. */ +.compatBadge { + position: relative; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 4px; + border-radius: var(--radius-sm); + font-size: 12px; + color: var(--text-secondary); + outline: 1.5px solid transparent; + outline-offset: 2px; + transition: outline-color var(--t-focus) var(--ease-enter); +} +.compatBadge.ps-focused { + outline-color: var(--accent); +} + +.compatTip { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 5; + display: flex; + flex-direction: column; + gap: 2px; + width: max-content; + max-width: 280px; + padding: 8px 10px; + text-align: left; + font-size: 11px; + line-height: 1.45; + color: var(--text-secondary); + background: var(--glass-heavy-bg); + border: 1px solid var(--glass-border-strong); + border-radius: var(--radius-sm); + box-shadow: 0 12px 28px -10px var(--shadow-color); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + opacity: 0; + transform: translateY(-4px); + pointer-events: none; + transition: opacity var(--t-fast) var(--ease-ui), transform var(--t-fast) var(--ease-ui); +} +.compatBadge:hover .compatTip, +.compatBadge.ps-focused .compatTip { + opacity: 1; + transform: translateY(0); +} + +/* Same flatten as every other blurred surface under the perf probe and the + idle throttle (theme.css). */ +:global(:root[data-perf="low"]) .compatTip, +:global(:root[data-idle="1"]) .compatTip { + backdrop-filter: none; + -webkit-backdrop-filter: none; + background: rgba(20, 24, 36, 0.95); +} diff --git a/src/launcher-tauri/src/components/GameDetail.tsx b/src/launcher-tauri/src/components/GameDetail.tsx index 2cfb22fd7..235ef028c 100644 --- a/src/launcher-tauri/src/components/GameDetail.tsx +++ b/src/launcher-tauri/src/components/GameDetail.tsx @@ -1,14 +1,16 @@ import { useEffect, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; -import { openPath } from "@tauri-apps/plugin-opener"; -import { FolderOpen, Play, Save as SaveIcon, Square, SlidersHorizontal, Trophy, Wrench } from "lucide-react"; -import type { Configuration, CompatibilityMap, GameEntry, GameStatus, KytyConfig, PatchStatus } from "../types"; +import { openPath, openUrl } from "@tauri-apps/plugin-opener"; +import { Flag, FolderOpen, Play, Save as SaveIcon, Square, SlidersHorizontal, Trophy, Wrench } from "lucide-react"; +import type { Configuration, CompatibilityMap, EmulatorInfo, GameEntry, GameStatus, KytyConfig, PatchStatus } from "../types"; +import { buildStatusReportUrl } from "../lib/statusReport"; import { useStore } from "../store/observable"; import { isRunningStore, runningGameStore, runGame, stopGame } from "../store/run"; import { STATUS_CLASS, useGameArt } from "./GameCard"; import { ConfigForm, Field } from "./ConfigForm"; import { configStore, saveConfigAndRescan } from "../store/library"; import { Dropdown } from "./Dropdown"; +import { Modal } from "./Modal"; import { Toggle } from "./Toggle"; import { TrophiesView } from "../views/Trophies"; import { useT } from "../i18n"; @@ -52,10 +54,43 @@ export function GameDetail({ const [isPatchable, setIsPatchable] = useState(false); const [saveDirs, setSaveDirs] = useState([]); const [error, setError] = useState(null); + /** Gates the jump out to the browser behind a confirm step -- leaving the + * app for an external site is not something a button press should do + * without saying so first. Modal rather than a native confirm(): the + * OS dialog's buttons live outside FocusNav's DOM, so a controller-only + * session could not answer it (the same reasoning the saved-data confirm + * below is written out for). */ + const [reportPrompt, setReportPrompt] = useState(false); const entry = compatibility[game.config.titleId.toUpperCase()]; const status: GameStatus = entry?.status ?? "Unknown"; + /** What the badge is actually claiming, in small print under it. + * + * A bare status is not enough to act on: the community feed reports a + * cross-platform aggregate alongside a per-OS breakdown, and the two + * disagree often enough to matter -- a title that is InGame on Linux can + * be DoesntBoot on Windows. compatibility.rs resolves this platform's own + * figure when the feed has one, and this says which of the two is on + * screen, how many reports stand behind it, and which emulator build they + * were filed against. One report on a build from months ago is a very + * different claim from twelve on the current one. + * + * Nothing here for a locally-edited database: that is the user's own + * opinion, with no report count or platform split to describe. */ + const compatDetail = (() => { + if (compatibilityIsLocal || !entry || status === "Unknown") return null; + const lines: string[] = [ + entry.platformSpecific ? t("gameDetail.compatThisPlatform") : t("gameDetail.compatAllPlatforms"), + ]; + if (entry.reports === 1) lines.push(t("gameDetail.compatReport")); + else if (entry.reports > 1) lines.push(t("gameDetail.compatReports", { count: String(entry.reports) })); + if (entry.version) lines.push(t("gameDetail.compatTestedOn", { value: entry.version })); + // One line each rather than a single "a · b · c" run: the build string + // alone is long enough to push that past any sensible tooltip width. + return lines; + })(); + useEffect(() => { setError(null); setActiveTab("settings"); @@ -73,11 +108,45 @@ export function GameDetail({ } }; + /** Opens the upstream status-report form with what the launcher already + * knows filled in. The emulator's version is probed here rather than held + * in state because it is wanted exactly once, at the moment the user asks + * to report -- and a failure to probe it is not a reason to refuse the + * report, only to leave that one field blank for them to type. */ + const reportStatus = async () => { + setReportPrompt(false); + let emulatorVersion = ""; + try { + emulatorVersion = (await invoke("find_emulator")).version; + } catch { + // Leave it blank; the form is still worth opening. + } + await openUrl( + buildStatusReportUrl({ + name: game.config.name, + titleId: game.config.titleId, + emulatorVersion, + status, + }), + ); + }; + const setStatus = async (next: GameStatus) => { await invoke("compatibility_set_status", { titleId: game.config.titleId, status: next }); onRescanCompatibility(); }; + /** Only reachable while the database is local. `compatibility_set_comment` + * has existed since the port but nothing ever called it, so a user editing + * their own compatibility notes could set a status and never say why. + * Committed on blur rather than per keystroke: every save rewrites the + * whole JSON file and triggers a rescan. */ + const commitComment = async (next: string) => { + if (next === (entry?.comment ?? "")) return; + await invoke("compatibility_set_comment", { titleId: game.config.titleId, comment: next }); + onRescanCompatibility(); + }; + const sections: { id: Tab; icon: typeof SlidersHorizontal; label: string }[] = [ { id: "settings", icon: SlidersHorizontal, label: t("gameDetail.settingsButton") }, ]; @@ -107,18 +176,47 @@ export function GameDetail({
{compatibilityIsLocal ? ( -
+
void setStatus(v as GameStatus)} options={Object.entries(STATUS_KEYS).map(([value, key]) => ({ value, label: t(key) }))} /> + void commitComment(e.target.value)} + style={{ + width: "100%", + boxSizing: "border-box", + padding: "6px 10px", + fontSize: 12, + color: "inherit", + background: "rgba(255, 255, 255, 0.06)", + border: "1px solid rgba(255, 255, 255, 0.14)", + borderRadius: 8, + }} + />
) : ( - + {t(STATUS_KEYS[status])} + {compatDetail && ( + + {compatDetail.map((line) => ( + {line} + ))} + + )} )} {isThisRunning ? ( @@ -133,6 +231,9 @@ export function GameDetail({ +
@@ -173,6 +274,27 @@ export function GameDetail({
+ + {reportPrompt && ( + setReportPrompt(false)} + footer={ + <> + + + + } + > +

{t("gameDetail.reportStatusPrompt")}

+
+ )} ); } diff --git a/src/launcher-tauri/src/components/GameTile.module.css b/src/launcher-tauri/src/components/GameTile.module.css index 4f0eb830e..95aae52b6 100644 --- a/src/launcher-tauri/src/components/GameTile.module.css +++ b/src/launcher-tauri/src/components/GameTile.module.css @@ -413,3 +413,11 @@ composes: caption; opacity: 1; } + +/* Idle throttle (lib/idle.ts, theme.css). The flux ring and shimmer keep + spinning on whichever tile holds gamepad focus (.ps-focused persists with + no pointer involved), so they outlive the window being looked at. */ +:global(:root[data-idle="1"]) .tileFlux::before, +:global(:root[data-idle="1"]) .tileShimmer::before { + animation-play-state: paused; +} diff --git a/src/launcher-tauri/src/components/GlassPanel.module.css b/src/launcher-tauri/src/components/GlassPanel.module.css index 51e57845f..09e3209c2 100644 --- a/src/launcher-tauri/src/components/GlassPanel.module.css +++ b/src/launcher-tauri/src/components/GlassPanel.module.css @@ -21,7 +21,8 @@ /* data-perf="low" already flattens .glass-panel (theme.css); this covers the same case for GlassPanel's own heavy variant. */ -:global(:root[data-perf="low"]) .heavy { +:global(:root[data-perf="low"]) .heavy, +:global(:root[data-idle="1"]) .heavy { backdrop-filter: none; -webkit-backdrop-filter: none; background: rgba(20, 24, 36, 0.92); diff --git a/src/launcher-tauri/src/components/HeroBackground.module.css b/src/launcher-tauri/src/components/HeroBackground.module.css index 7903c67c8..7e88a8eda 100644 --- a/src/launcher-tauri/src/components/HeroBackground.module.css +++ b/src/launcher-tauri/src/components/HeroBackground.module.css @@ -91,3 +91,11 @@ composes: accentRadial; opacity: 1; } + +/* Idle throttle (lib/idle.ts, theme.css). The Ken Burns pan is the single + most expensive thing this app composites -- a full-screen transform that + never ends -- so it is the first thing to park when nobody is watching. */ +:global(:root[data-idle="1"]) .base, +:global(:root[data-idle="1"]) .layer { + animation-play-state: paused; +} diff --git a/src/launcher-tauri/src/components/InputMappingDialog.tsx b/src/launcher-tauri/src/components/InputMappingDialog.tsx index a97b28cf2..c8c61ae73 100644 --- a/src/launcher-tauri/src/components/InputMappingDialog.tsx +++ b/src/launcher-tauri/src/components/InputMappingDialog.tsx @@ -10,7 +10,7 @@ // actually reads) already assumes. Produces `--gamepad-map // Control=SdlName` strings consumed by window.cpp's GamepadRemap. // -// The diagram (public/art/controller_diagram.png) depicts a DualSense- +// The diagram (public/art/controller_diagram.webp) depicts a DualSense- // shaped silhouette at the owner's explicit request (2026-09-08, cropped // from a reference photo, cropped to its front-view panel) -- flagged once // as a closer trade-dress consideration than the previous generic @@ -357,7 +357,7 @@ function CaptureOverlayFrame({ message, onCancel }: { message: string; onCancel: function ControllerDiagram() { return ( diff --git a/src/launcher-tauri/src/components/Modal.module.css b/src/launcher-tauri/src/components/Modal.module.css index 8b47ab740..2c419c5ca 100644 --- a/src/launcher-tauri/src/components/Modal.module.css +++ b/src/launcher-tauri/src/components/Modal.module.css @@ -14,7 +14,8 @@ box-shadow: 0 24px 60px -16px var(--shadow-color); } -:global(:root[data-perf="low"]) .surface { +:global(:root[data-perf="low"]) .surface, +:global(:root[data-idle="1"]) .surface { backdrop-filter: none; -webkit-backdrop-filter: none; background: rgba(20, 24, 36, 0.92); diff --git a/src/launcher-tauri/src/components/Modal.tsx b/src/launcher-tauri/src/components/Modal.tsx index b58aa5ecc..efa5fe32b 100644 --- a/src/launcher-tauri/src/components/Modal.tsx +++ b/src/launcher-tauri/src/components/Modal.tsx @@ -9,12 +9,20 @@ export function Modal({ width = 560, children, footer, + dividers = true, }: { title: string; onClose: () => void; width?: number; children: ReactNode; footer?: ReactNode; + /** Hairlines under the header and above the footer. On by default: the + * folder and image browsers scroll a long list under their header, and + * without the rule the rows slide under the title with nothing to + * separate them. A short confirm has nothing to scroll and no ambiguity + * about where its body ends, so the two rules there are pure chrome -- + * pass false and let the panel read as one surface. */ + dividers?: boolean; }) { const t = useT(); const surfaceRef = useRef(null); @@ -83,7 +91,7 @@ export function Modal({ alignItems: "center", justifyContent: "space-between", padding: "16px 20px", - borderBottom: "1px solid", + borderBottom: dividers ? "1px solid" : "none", }} >

{title}

@@ -99,7 +107,7 @@ export function Modal({ className={styles.footer} style={{ padding: "14px 20px", - borderTop: "1px solid", + borderTop: dividers ? "1px solid" : "none", display: "flex", justifyContent: "flex-end", gap: 10, diff --git a/src/launcher-tauri/src/i18n/locales/ar.ts b/src/launcher-tauri/src/i18n/locales/ar.ts index 20281b33d..86dacbbff 100644 --- a/src/launcher-tauri/src/i18n/locales/ar.ts +++ b/src/launcher-tauri/src/i18n/locales/ar.ts @@ -112,6 +112,15 @@ const ar: DeepPartial = { removeSaveDataFailed: "تعذّر الحذف:\n{list}", saveDataTab: "بيانات الحفظ", noSaveData: "لم يتم العثور على بيانات حفظ لهذه اللعبة.", + compatReport: "تقرير واحد", + compatReports: "{count} تقارير", + compatTestedOn: "تم الاختبار على {value}", + compatAllPlatforms: "جميع المنصات", + compatThisPlatform: "هذه المنصة", + reportStatus: "الإبلاغ عن الحالة", + compatibilityNote: "ملاحظة (اختياري)", + reportStatusPrompt: "يفتح نموذج مشكلات KytyPS5 على GitHub، معبّأ مسبقًا باسم اللعبة ورقمها التسلسلي وإصدار المحاكي لديك. لا يُرسل شيء حتى ترسله هناك بنفسك.", + reportStatusConfirm: "فتح في المتصفح", status: { Unknown: "غير معروف", InGame: "داخل اللعبة", diff --git a/src/launcher-tauri/src/i18n/locales/de.ts b/src/launcher-tauri/src/i18n/locales/de.ts index 46d068023..7fa9ee3ed 100644 --- a/src/launcher-tauri/src/i18n/locales/de.ts +++ b/src/launcher-tauri/src/i18n/locales/de.ts @@ -112,6 +112,15 @@ const de: DeepPartial = { removeSaveDataFailed: "Konnte nicht gelöscht werden:\n{list}", saveDataTab: "Spielstände", noSaveData: "Für dieses Spiel wurden keine Spielstände gefunden.", + compatReport: "1 Bericht", + compatReports: "{count} Berichte", + compatTestedOn: "getestet mit {value}", + compatAllPlatforms: "alle Plattformen", + compatThisPlatform: "diese Plattform", + reportStatus: "Status melden", + compatibilityNote: "Notiz (optional)", + reportStatusPrompt: "Öffnet das KytyPS5-Issue-Formular auf GitHub, vorausgefüllt mit dem Namen des Spiels, der Seriennummer und deinem Emulator-Build. Es wird nichts gesendet, bis du es dort abschickst.", + reportStatusConfirm: "Im Browser öffnen", status: { Unknown: "Unbekannt", InGame: "Im Spiel", diff --git a/src/launcher-tauri/src/i18n/locales/en.ts b/src/launcher-tauri/src/i18n/locales/en.ts index 3818196cc..7bd3238b7 100644 --- a/src/launcher-tauri/src/i18n/locales/en.ts +++ b/src/launcher-tauri/src/i18n/locales/en.ts @@ -125,6 +125,15 @@ export interface Catalog { removeSaveDataFailed: string; saveDataTab: string; noSaveData: string; + compatReport: string; + compatReports: string; + compatTestedOn: string; + compatAllPlatforms: string; + compatThisPlatform: string; + reportStatus: string; + compatibilityNote: string; + reportStatusPrompt: string; + reportStatusConfirm: string; status: { Unknown: string; InGame: string; @@ -438,6 +447,15 @@ const en: Catalog = { removeSaveDataFailed: "Could not remove:\n{list}", saveDataTab: "Saved data", noSaveData: "No saved data found for this game.", + compatReport: "1 report", + compatReports: "{count} reports", + compatTestedOn: "tested on {value}", + compatAllPlatforms: "all platforms", + compatThisPlatform: "this platform", + reportStatus: "Report status", + compatibilityNote: "Note (optional)", + reportStatusPrompt: "Opens the KytyPS5 issue form on GitHub, pre-filled with this game's name, serial and your emulator build. Nothing is sent until you submit it there.", + reportStatusConfirm: "Open in browser", status: { Unknown: "Unknown", InGame: "In game", diff --git a/src/launcher-tauri/src/i18n/locales/es.ts b/src/launcher-tauri/src/i18n/locales/es.ts index c1bd93adc..ba239720f 100644 --- a/src/launcher-tauri/src/i18n/locales/es.ts +++ b/src/launcher-tauri/src/i18n/locales/es.ts @@ -112,6 +112,15 @@ const es: DeepPartial = { removeSaveDataFailed: "No se pudo eliminar:\n{list}", saveDataTab: "Datos guardados", noSaveData: "No se encontraron datos guardados para este juego.", + compatReport: "1 informe", + compatReports: "{count} informes", + compatTestedOn: "probado en {value}", + compatAllPlatforms: "todas las plataformas", + compatThisPlatform: "esta plataforma", + reportStatus: "Informar del estado", + compatibilityNote: "Nota (opcional)", + reportStatusPrompt: "Abre el formulario de incidencias de KytyPS5 en GitHub, con el nombre del juego, el número de serie y tu versión del emulador ya rellenados. No se envía nada hasta que lo envíes allí.", + reportStatusConfirm: "Abrir en el navegador", status: { Unknown: "Desconocido", InGame: "En juego", diff --git a/src/launcher-tauri/src/i18n/locales/fr.ts b/src/launcher-tauri/src/i18n/locales/fr.ts index db5baf408..00a47d03f 100644 --- a/src/launcher-tauri/src/i18n/locales/fr.ts +++ b/src/launcher-tauri/src/i18n/locales/fr.ts @@ -112,6 +112,15 @@ const fr: DeepPartial = { removeSaveDataFailed: "Impossible de supprimer :\n{list}", saveDataTab: "Données de sauvegarde", noSaveData: "Aucune donnée de sauvegarde trouvée pour ce jeu.", + compatReport: "1 rapport", + compatReports: "{count} rapports", + compatTestedOn: "testé sur {value}", + compatAllPlatforms: "toutes les plateformes", + compatThisPlatform: "cette plateforme", + reportStatus: "Signaler l'état", + compatibilityNote: "Note (facultatif)", + reportStatusPrompt: "Ouvre le formulaire de ticket KytyPS5 sur GitHub, pré-rempli avec le nom du jeu, son numéro de série et votre version de l'émulateur. Rien n'est envoyé tant que vous ne l'avez pas soumis.", + reportStatusConfirm: "Ouvrir dans le navigateur", status: { Unknown: "Inconnu", InGame: "En jeu", diff --git a/src/launcher-tauri/src/i18n/locales/id.ts b/src/launcher-tauri/src/i18n/locales/id.ts index e5a13231f..e7fbf5626 100644 --- a/src/launcher-tauri/src/i18n/locales/id.ts +++ b/src/launcher-tauri/src/i18n/locales/id.ts @@ -112,6 +112,15 @@ const id: DeepPartial = { removeSaveDataFailed: "Tidak dapat menghapus:\n{list}", saveDataTab: "Data simpanan", noSaveData: "Tidak ditemukan data simpanan untuk game ini.", + compatReport: "1 laporan", + compatReports: "{count} laporan", + compatTestedOn: "diuji pada {value}", + compatAllPlatforms: "semua platform", + compatThisPlatform: "platform ini", + reportStatus: "Laporkan status", + compatibilityNote: "Catatan (opsional)", + reportStatusPrompt: "Membuka formulir isu KytyPS5 di GitHub, terisi otomatis dengan nama gim, nomor seri, dan versi emulator Anda. Tidak ada yang dikirim sampai Anda mengirimkannya di sana.", + reportStatusConfirm: "Buka di peramban", status: { Unknown: "Tidak diketahui", InGame: "Dalam game", diff --git a/src/launcher-tauri/src/i18n/locales/it.ts b/src/launcher-tauri/src/i18n/locales/it.ts index 01d274f38..11926721f 100644 --- a/src/launcher-tauri/src/i18n/locales/it.ts +++ b/src/launcher-tauri/src/i18n/locales/it.ts @@ -112,6 +112,15 @@ const it: DeepPartial = { removeSaveDataFailed: "Impossibile rimuovere:\n{list}", saveDataTab: "Dati di salvataggio", noSaveData: "Nessun dato di salvataggio trovato per questo gioco.", + compatReport: "1 segnalazione", + compatReports: "{count} segnalazioni", + compatTestedOn: "testato su {value}", + compatAllPlatforms: "tutte le piattaforme", + compatThisPlatform: "questa piattaforma", + reportStatus: "Segnala stato", + compatibilityNote: "Nota (facoltativa)", + reportStatusPrompt: "Apre il modulo di segnalazione KytyPS5 su GitHub, precompilato con nome del gioco, seriale e build del tuo emulatore. Non viene inviato nulla finché non lo invii tu.", + reportStatusConfirm: "Apri nel browser", status: { Unknown: "Sconosciuto", InGame: "In gioco", diff --git a/src/launcher-tauri/src/i18n/locales/ja.ts b/src/launcher-tauri/src/i18n/locales/ja.ts index 9f501706c..85af5d21f 100644 --- a/src/launcher-tauri/src/i18n/locales/ja.ts +++ b/src/launcher-tauri/src/i18n/locales/ja.ts @@ -112,6 +112,15 @@ const ja: DeepPartial = { removeSaveDataFailed: "削除できませんでした:\n{list}", saveDataTab: "セーブデータ", noSaveData: "このゲームのセーブデータは見つかりませんでした。", + compatReport: "レポート 1 件", + compatReports: "レポート {count} 件", + compatTestedOn: "{value} でテスト済み", + compatAllPlatforms: "すべてのプラットフォーム", + compatThisPlatform: "このプラットフォーム", + reportStatus: "状態を報告", + compatibilityNote: "メモ(任意)", + reportStatusPrompt: "ゲーム名・シリアル・エミュレーターのビルドを入力済みの KytyPS5 課題フォームを GitHub で開きます。そこで送信するまで何も送られません。", + reportStatusConfirm: "ブラウザで開く", status: { Unknown: "不明", InGame: "ゲーム内", diff --git a/src/launcher-tauri/src/i18n/locales/ko.ts b/src/launcher-tauri/src/i18n/locales/ko.ts index fa7b97d0d..48362e0ca 100644 --- a/src/launcher-tauri/src/i18n/locales/ko.ts +++ b/src/launcher-tauri/src/i18n/locales/ko.ts @@ -112,6 +112,15 @@ const ko: DeepPartial = { removeSaveDataFailed: "삭제할 수 없습니다:\n{list}", saveDataTab: "저장 데이터", noSaveData: "이 게임에 대한 저장 데이터를 찾을 수 없습니다.", + compatReport: "보고서 1건", + compatReports: "보고서 {count}건", + compatTestedOn: "{value}에서 테스트됨", + compatAllPlatforms: "모든 플랫폼", + compatThisPlatform: "이 플랫폼", + reportStatus: "상태 보고", + compatibilityNote: "메모(선택 사항)", + reportStatusPrompt: "게임 이름, 시리얼, 에뮬레이터 빌드가 미리 입력된 KytyPS5 이슈 양식을 GitHub에서 엽니다. 거기서 직접 제출하기 전에는 아무것도 전송되지 않습니다.", + reportStatusConfirm: "브라우저에서 열기", status: { Unknown: "알 수 없음", InGame: "게임 중", diff --git a/src/launcher-tauri/src/i18n/locales/nl.ts b/src/launcher-tauri/src/i18n/locales/nl.ts index 45f377816..3ddf5143a 100644 --- a/src/launcher-tauri/src/i18n/locales/nl.ts +++ b/src/launcher-tauri/src/i18n/locales/nl.ts @@ -112,6 +112,15 @@ const nl: DeepPartial = { removeSaveDataFailed: "Kon niet worden verwijderd:\n{list}", saveDataTab: "Opslaggegevens", noSaveData: "Geen opslaggegevens gevonden voor dit spel.", + compatReport: "1 melding", + compatReports: "{count} meldingen", + compatTestedOn: "getest op {value}", + compatAllPlatforms: "alle platforms", + compatThisPlatform: "dit platform", + reportStatus: "Status melden", + compatibilityNote: "Notitie (optioneel)", + reportStatusPrompt: "Opent het KytyPS5-issueformulier op GitHub, vooraf ingevuld met de naam van het spel, het serienummer en je emulatorversie. Er wordt niets verzonden totdat je het daar indient.", + reportStatusConfirm: "Openen in browser", status: { Unknown: "Onbekend", InGame: "In het spel", diff --git a/src/launcher-tauri/src/i18n/locales/pl.ts b/src/launcher-tauri/src/i18n/locales/pl.ts index abcef607d..4e6f2d3a7 100644 --- a/src/launcher-tauri/src/i18n/locales/pl.ts +++ b/src/launcher-tauri/src/i18n/locales/pl.ts @@ -112,6 +112,15 @@ const pl: DeepPartial = { removeSaveDataFailed: "Nie udało się usunąć:\n{list}", saveDataTab: "Dane zapisu", noSaveData: "Nie znaleziono danych zapisu dla tej gry.", + compatReport: "1 zgłoszenie", + compatReports: "{count} zgłoszeń", + compatTestedOn: "testowano na {value}", + compatAllPlatforms: "wszystkie platformy", + compatThisPlatform: "ta platforma", + reportStatus: "Zgłoś status", + compatibilityNote: "Notatka (opcjonalnie)", + reportStatusPrompt: "Otwiera formularz zgłoszeń KytyPS5 na GitHubie, wypełniony nazwą gry, numerem seryjnym i wersją Twojego emulatora. Nic nie zostanie wysłane, dopóki go tam nie wyślesz.", + reportStatusConfirm: "Otwórz w przeglądarce", status: { Unknown: "Nieznany", InGame: "W grze", diff --git a/src/launcher-tauri/src/i18n/locales/pt-BR.ts b/src/launcher-tauri/src/i18n/locales/pt-BR.ts index 62f026b42..219738fb9 100644 --- a/src/launcher-tauri/src/i18n/locales/pt-BR.ts +++ b/src/launcher-tauri/src/i18n/locales/pt-BR.ts @@ -112,6 +112,15 @@ const ptBR: DeepPartial = { removeSaveDataFailed: "Não foi possível remover:\n{list}", saveDataTab: "Dados salvos", noSaveData: "Nenhum dado salvo encontrado para este jogo.", + compatReport: "1 relato", + compatReports: "{count} relatos", + compatTestedOn: "testado em {value}", + compatAllPlatforms: "todas as plataformas", + compatThisPlatform: "esta plataforma", + reportStatus: "Relatar status", + compatibilityNote: "Nota (opcional)", + reportStatusPrompt: "Abre o formulário de problemas do KytyPS5 no GitHub, já preenchido com o nome do jogo, o serial e a build do seu emulador. Nada é enviado até você enviá-lo lá.", + reportStatusConfirm: "Abrir no navegador", status: { Unknown: "Desconhecido", InGame: "Em jogo", diff --git a/src/launcher-tauri/src/i18n/locales/ru.ts b/src/launcher-tauri/src/i18n/locales/ru.ts index 9693add6b..cbc1d62d2 100644 --- a/src/launcher-tauri/src/i18n/locales/ru.ts +++ b/src/launcher-tauri/src/i18n/locales/ru.ts @@ -112,6 +112,15 @@ const ru: DeepPartial = { removeSaveDataFailed: "Не удалось удалить:\n{list}", saveDataTab: "Сохранения", noSaveData: "Сохранения для этой игры не найдены.", + compatReport: "1 отчёт", + compatReports: "отчётов: {count}", + compatTestedOn: "проверено на {value}", + compatAllPlatforms: "все платформы", + compatThisPlatform: "эта платформа", + reportStatus: "Сообщить о статусе", + compatibilityNote: "Заметка (необязательно)", + reportStatusPrompt: "Откроет форму обращения KytyPS5 на GitHub с уже заполненными названием игры, серийным номером и сборкой вашего эмулятора. Ничего не отправится, пока вы не отправите её там.", + reportStatusConfirm: "Открыть в браузере", status: { Unknown: "Неизвестно", InGame: "В игре", diff --git a/src/launcher-tauri/src/i18n/locales/th.ts b/src/launcher-tauri/src/i18n/locales/th.ts index 0ab8f1b40..cdf209910 100644 --- a/src/launcher-tauri/src/i18n/locales/th.ts +++ b/src/launcher-tauri/src/i18n/locales/th.ts @@ -112,6 +112,15 @@ const th: DeepPartial = { removeSaveDataFailed: "ไม่สามารถลบได้:\n{list}", saveDataTab: "ข้อมูลเซฟ", noSaveData: "ไม่พบข้อมูลเซฟสำหรับเกมนี้", + compatReport: "1 รายงาน", + compatReports: "{count} รายงาน", + compatTestedOn: "ทดสอบบน {value}", + compatAllPlatforms: "ทุกแพลตฟอร์ม", + compatThisPlatform: "แพลตฟอร์มนี้", + reportStatus: "รายงานสถานะ", + compatibilityNote: "หมายเหตุ (ไม่บังคับ)", + reportStatusPrompt: "เปิดแบบฟอร์มรายงานปัญหา KytyPS5 บน GitHub โดยกรอกชื่อเกม ซีเรียล และรุ่นอีมูเลเตอร์ของคุณไว้ให้แล้ว จะไม่มีการส่งข้อมูลจนกว่าคุณจะกดส่งที่นั่น", + reportStatusConfirm: "เปิดในเบราว์เซอร์", status: { Unknown: "ไม่ทราบ", InGame: "อยู่ในเกม", diff --git a/src/launcher-tauri/src/i18n/locales/tr.ts b/src/launcher-tauri/src/i18n/locales/tr.ts index fdb028316..84f208859 100644 --- a/src/launcher-tauri/src/i18n/locales/tr.ts +++ b/src/launcher-tauri/src/i18n/locales/tr.ts @@ -112,6 +112,15 @@ const tr: DeepPartial = { removeSaveDataFailed: "Kaldırılamadı:\n{list}", saveDataTab: "Kayıt verileri", noSaveData: "Bu oyun için kayıt verisi bulunamadı.", + compatReport: "1 rapor", + compatReports: "{count} rapor", + compatTestedOn: "{value} üzerinde test edildi", + compatAllPlatforms: "tüm platformlar", + compatThisPlatform: "bu platform", + reportStatus: "Durumu bildir", + compatibilityNote: "Not (isteğe bağlı)", + reportStatusPrompt: "KytyPS5 sorun formunu GitHub'da açar; oyunun adı, seri numarası ve emülatör sürümünüz önceden doldurulur. Siz orada göndermeden hiçbir şey gönderilmez.", + reportStatusConfirm: "Tarayıcıda aç", status: { Unknown: "Bilinmiyor", InGame: "Oyun içinde", diff --git a/src/launcher-tauri/src/i18n/locales/vi.ts b/src/launcher-tauri/src/i18n/locales/vi.ts index 11f8c4a62..d1634106e 100644 --- a/src/launcher-tauri/src/i18n/locales/vi.ts +++ b/src/launcher-tauri/src/i18n/locales/vi.ts @@ -112,6 +112,15 @@ const vi: DeepPartial = { removeSaveDataFailed: "Không thể xóa:\n{list}", saveDataTab: "Dữ liệu lưu", noSaveData: "Không tìm thấy dữ liệu lưu cho trò chơi này.", + compatReport: "1 báo cáo", + compatReports: "{count} báo cáo", + compatTestedOn: "đã thử nghiệm trên {value}", + compatAllPlatforms: "tất cả nền tảng", + compatThisPlatform: "nền tảng này", + reportStatus: "Báo cáo trạng thái", + compatibilityNote: "Ghi chú (tùy chọn)", + reportStatusPrompt: "Mở biểu mẫu báo lỗi KytyPS5 trên GitHub, điền sẵn tên trò chơi, số sê-ri và bản dựng trình giả lập của bạn. Không có gì được gửi cho đến khi bạn gửi ở đó.", + reportStatusConfirm: "Mở trong trình duyệt", status: { Unknown: "Không xác định", InGame: "Đang trong trò chơi", diff --git a/src/launcher-tauri/src/i18n/locales/zh-Hans.ts b/src/launcher-tauri/src/i18n/locales/zh-Hans.ts index 74d4ead69..77dcbba7a 100644 --- a/src/launcher-tauri/src/i18n/locales/zh-Hans.ts +++ b/src/launcher-tauri/src/i18n/locales/zh-Hans.ts @@ -112,6 +112,15 @@ const zhHans: DeepPartial = { removeSaveDataFailed: "无法删除:\n{list}", saveDataTab: "存档数据", noSaveData: "未找到此游戏的存档数据。", + compatReport: "1 份报告", + compatReports: "{count} 份报告", + compatTestedOn: "在 {value} 上测试", + compatAllPlatforms: "所有平台", + compatThisPlatform: "当前平台", + reportStatus: "报告状态", + compatibilityNote: "备注(可选)", + reportStatusPrompt: "在 GitHub 上打开 KytyPS5 问题表单,并已填入游戏名称、序列号和你的模拟器版本。在你提交之前不会发送任何内容。", + reportStatusConfirm: "在浏览器中打开", status: { Unknown: "未知", InGame: "游戏中", diff --git a/src/launcher-tauri/src/i18n/locales/zh-Hant.ts b/src/launcher-tauri/src/i18n/locales/zh-Hant.ts index 66adebfb1..be87994b2 100644 --- a/src/launcher-tauri/src/i18n/locales/zh-Hant.ts +++ b/src/launcher-tauri/src/i18n/locales/zh-Hant.ts @@ -112,6 +112,15 @@ const zhHant: DeepPartial = { removeSaveDataFailed: "無法刪除:\n{list}", saveDataTab: "存檔資料", noSaveData: "找不到此遊戲的存檔資料。", + compatReport: "1 份報告", + compatReports: "{count} 份報告", + compatTestedOn: "在 {value} 上測試", + compatAllPlatforms: "所有平台", + compatThisPlatform: "目前平台", + reportStatus: "回報狀態", + compatibilityNote: "備註(選填)", + reportStatusPrompt: "在 GitHub 上開啟 KytyPS5 問題表單,並已填入遊戲名稱、序號與你的模擬器版本。在你送出之前不會傳送任何內容。", + reportStatusConfirm: "在瀏覽器中開啟", status: { Unknown: "未知", InGame: "遊戲中", diff --git a/src/launcher-tauri/src/lib/contextMenu.ts b/src/launcher-tauri/src/lib/contextMenu.ts new file mode 100644 index 000000000..451dec2f9 --- /dev/null +++ b/src/launcher-tauri/src/lib/contextMenu.ts @@ -0,0 +1,28 @@ +/** Suppresses the webview's native right-click menu. + * + * This is a console UI driven by a gamepad and a focus ring, not a web page: + * the browser context menu ("Back", "Reload", "Save image as...") exposes + * navigation this app has no concept of, and offers to save the cover art + * out of a launcher. Nothing in the UI is discoverable through it. + * + * Two deliberate exceptions: + * + * - Editable fields keep their menu. Settings is full of real text inputs + * (game directories, emulator path), and right-click paste is the normal + * way to get a long Windows path into one. Killing that would cost more + * than the menu does. + * - Dev builds keep it, so "Inspect" still works while running + * `npm run tauri dev`. Release builds have no devtools to reach anyway. + */ + +const EDITABLE = "input, textarea, [contenteditable]:not([contenteditable='false'])"; + +export function installContextMenuSuppression(): void { + if (import.meta.env.DEV) return; + + window.addEventListener("contextmenu", (event) => { + const target = event.target; + if (target instanceof Element && target.closest(EDITABLE)) return; + event.preventDefault(); + }); +} diff --git a/src/launcher-tauri/src/lib/heroArt.ts b/src/launcher-tauri/src/lib/heroArt.ts index b67dcc386..85eb0012b 100644 --- a/src/launcher-tauri/src/lib/heroArt.ts +++ b/src/launcher-tauri/src/lib/heroArt.ts @@ -14,7 +14,7 @@ function hashToPlaceholder(key: string): string { h = (h * 31 + key.charCodeAt(i)) | 0; } const index = (Math.abs(h) % COVER_PLACEHOLDER_COUNT) + 1; - return `/art/cover_placeholder_${String(index).padStart(2, "0")}.png`; + return `/art/cover_placeholder_${String(index).padStart(2, "0")}.webp`; } export type HeroArtKind = "backdrop" | "icon" | "placeholder"; diff --git a/src/launcher-tauri/src/lib/idle.ts b/src/launcher-tauri/src/lib/idle.ts new file mode 100644 index 000000000..85c0ba9f5 --- /dev/null +++ b/src/launcher-tauri/src/lib/idle.ts @@ -0,0 +1,129 @@ +/** Idle throttle switch. Sets `data-idle="1"` on the document root whenever + * this launcher's animation is work nobody is watching, and CSS (theme.css's + * `---- idle throttle` block, plus the panel modules) reacts by parking every + * running animation and dropping live backdrop-filter to a flat fill. + * + * Why: measured on Windows with the window open and the user doing nothing, + * the launcher sat at ~17% of one core -- ~13% of that in WebView2's GPU + * process -- and never went quiet, because the hero Ken Burns pan + * (HeroBackground.module.css, 42s infinite alternate), the per-tile flux + * spin/shimmer (GameTile/GameCard, 2.4s + 5s infinite) and the in-game pulse + * dot all run forever, compositing against 34 backdrop-filter rules. None of + * that is worth a single frame while the window is hidden, unfocused, or -- + * the case this launcher exists to serve -- while a game is running and the + * emulator wants every core and every slice of GPU it can get. + * + * Four inputs, OR'd together: + * + * - `document.hidden` -- fully occluded. The webview already throttles rAF + * here, but CSS animations are driven by the compositor and keep going, so + * this still needs saying out loud. + * - the native window being minimized, asked of Tauri rather than inferred. + * Measured: minimizing does not reliably produce a `blur` in the webview, + * and `document.hidden` stays false, so a launcher minimized straight from + * a focused state kept animating at ~11% of a core. This is the signal + * that catches it. + * - window focus -- the common case. Alt-tab away and the launcher is not + * being looked at even though it is still visible. + * - a game running -- the important one. With `autoCloseOnLaunch` off the + * launcher stays resident for the whole session, so without this it spends + * the entire game competing with the emulator for the GPU. + * + * Deliberately does NOT touch the rAF loops: nav/gamepadSource.ts's shared + * poll is what notices the pad being picked back up, so pausing it would + * strand an unfocused launcher with no way to wake on the pad. + * + * Paused animations resume exactly where they stopped, so an entrance caught + * mid-flight finishes correctly on the way back rather than snapping. + */ + +import { invoke } from "@tauri-apps/api/core"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { isRunningStore } from "../store/run"; + +// Seeded from the real state rather than assumed `true`: a window that is +// launched into the background, or that loses focus before this module runs, +// never fires the `blur` that would otherwise be the only thing to correct +// an optimistic default -- measured, that left the launcher animating at +// ~14% of a core while minimized, which is exactly the case this file +// exists to kill. `hasFocus()` is synchronous and right often enough to +// avoid a flash; the Tauri query in installIdleThrottle settles it. +let windowFocused = document.hasFocus(); +let windowMinimized = false; +let gameRunning = false; + +function apply(): void { + const idle = document.hidden || windowMinimized || !windowFocused || gameRunning; + if (idle) { + document.documentElement.dataset.idle = "1"; + } else { + delete document.documentElement.dataset.idle; + } +} + +export function installIdleThrottle(): void { + document.addEventListener("visibilitychange", apply); + + // DOM focus/blur is the synchronous signal and fires for native window + // focus changes in both WebView2 and WebKitGTK. Tauri's own event is + // subscribed to as well below, as the authoritative one. + window.addEventListener("focus", () => { + windowFocused = true; + apply(); + }); + window.addEventListener("blur", () => { + windowFocused = false; + apply(); + }); + + isRunningStore.subscribe(() => { + const next = isRunningStore.get(); + if (next === gameRunning) return; + gameRunning = next; + apply(); + // Windows only in practice (no-op elsewhere), and bound to this + // transition alone rather than to `apply()`: WebView2 documents the low + // level as dropping cached data and swapping to disk, which is worth it + // once for the length of a game session and not worth it on every + // alt-tab. Best effort -- an older WebView2 runtime simply lacks it. + void invoke("set_webview_memory_low", { low: next }).catch(() => undefined); + }); + + // Everything below is guarded: outside a Tauri host (plain `vite` in a + // browser, which is how the UI is sometimes poked at in isolation) there + // is no native window to ask, and the DOM listeners above carry the case + // on their own. + const win = (() => { + try { + return getCurrentWindow(); + } catch { + return null; + } + })(); + if (!win) return; + + /** Re-reads the native window rather than trusting the webview's own view + * of it. Both answers are needed: focus alone misses a minimize, and + * minimized alone misses an alt-tab. */ + const refresh = async () => { + const [focused, minimized] = await Promise.all([ + win.isFocused().catch(() => windowFocused), + win.isMinimized().catch(() => windowMinimized), + ]); + windowFocused = focused; + windowMinimized = minimized; + apply(); + }; + + void win.onFocusChanged(({ payload: focused }) => { + windowFocused = focused; + apply(); + // Still re-read: a focus change is also when a minimize becomes true. + void refresh(); + }); + // Minimize and restore both arrive as a resize. + void win.onResized(() => void refresh()); + void refresh(); + + apply(); +} diff --git a/src/launcher-tauri/src/lib/statusReport.ts b/src/launcher-tauri/src/lib/statusReport.ts new file mode 100644 index 000000000..a81e95db4 --- /dev/null +++ b/src/launcher-tauri/src/lib/statusReport.ts @@ -0,0 +1,76 @@ +/** Builds a prefilled "Game Emulation Status Report" for the upstream issue + * tracker. + * + * Nearly every issue on KytyPS5/KytyPS5 is a [GAME STATUS] or [GAME BUG] + * report, and they all get typed out by hand -- while the launcher is + * sitting on most of what the form asks for. The game's title and serial + * come from its own param.json, and the emulator build from the binary the + * launcher just ran, which is the field a hand-written report most often + * gets wrong or stale. Filling those in is the difference between a report + * that can be acted on and one that has to be chased. + * + * Deliberately opens the form rather than submitting anything: this returns + * a URL, the caller opens it in the browser, and the user reads, completes + * the parts only they know (what actually happened, steps, hardware) and + * presses submit themselves. Nothing is posted on their behalf. + * + * Field names are the `id:`s of .github/ISSUE_TEMPLATE/kytyps5-game-emulation.yaml. + * GitHub silently ignores a parameter whose id it does not recognise, so if + * the template is renamed upstream this degrades to a blank form rather + * than failing. + */ + +import type { GameStatus } from "../types"; + +const ISSUE_URL = "https://github.com/KytyPS5/KytyPS5/issues/new"; +const TEMPLATE = "kytyps5-game-emulation.yaml"; + +/** The template's dropdown takes its option labels verbatim, and they are + * not spelled the way GameStatus is. An unmatched value would leave the + * dropdown unset, so "Unknown" maps to nothing on purpose -- the user picks + * it themselves rather than having a guess pre-selected for them. */ +const STATUS_OPTION: Record = { + Unknown: null, + DoesntBoot: "Doesn't boot", + Logo: "Logo", + MainMenu: "Main menu", + InGame: "In game", +}; + +/** Coarse host OS for the `os` field, from the webview's own UA. Good enough + * to save typing "Windows" and no more -- the user still fills in the build + * number, and every other hardware field, which nothing here can see. */ +function hostOs(): string { + const ua = navigator.userAgent; + if (ua.includes("Windows")) return "Windows"; + if (ua.includes("Mac OS X") || ua.includes("Macintosh")) return "macOS"; + if (ua.includes("Linux")) return "Linux"; + return ""; +} + +export interface StatusReportInput { + name: string; + titleId: string; + /** The running emulator's self-reported build, e.g. + * "KytyPS5-2026-08-16-bc2f077". Empty when it could not be probed. */ + emulatorVersion: string; + status: GameStatus; +} + +export function buildStatusReportUrl(input: StatusReportInput): string { + const params = new URLSearchParams({ template: TEMPLATE }); + + // The template's own title prefix; GitHub appends nothing of its own. + params.set("title", `[GAME STATUS]: ${input.name || input.titleId}`); + if (input.name) params.set("game-title", input.name); + if (input.titleId) params.set("game-id", input.titleId); + if (input.emulatorVersion) params.set("kyty-version", input.emulatorVersion); + + const option = STATUS_OPTION[input.status]; + if (option) params.set("compatibility-status", option); + + const os = hostOs(); + if (os) params.set("os", os); + + return `${ISSUE_URL}?${params.toString()}`; +} diff --git a/src/launcher-tauri/src/main.tsx b/src/launcher-tauri/src/main.tsx index 409b13a5a..0d30476ef 100644 --- a/src/launcher-tauri/src/main.tsx +++ b/src/launcher-tauri/src/main.tsx @@ -19,7 +19,10 @@ import "@fontsource-variable/inter/wght.css"; // intended 220ms, both because theme.css was the one winning the tie. import "./theme.css"; import App from "./App"; +import { ErrorBoundary } from "./shell/ErrorBoundary"; import { runPerfProbe } from "./lib/perf"; +import { installIdleThrottle } from "./lib/idle"; +import { installContextMenuSuppression } from "./lib/contextMenu"; import { initLocale } from "./i18n"; initLocale(); @@ -27,7 +30,9 @@ initLocale(); try { ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + + + , ); } catch (e) { @@ -40,3 +45,5 @@ try { } runPerfProbe(); +installIdleThrottle(); +installContextMenuSuppression(); diff --git a/src/launcher-tauri/src/overlays/RestMode.tsx b/src/launcher-tauri/src/overlays/RestMode.tsx index bcdfd7c36..1b3145822 100644 --- a/src/launcher-tauri/src/overlays/RestMode.tsx +++ b/src/launcher-tauri/src/overlays/RestMode.tsx @@ -44,7 +44,7 @@ export function RestMode({ open, onClose }: { open: boolean; onClose: () => void return (
-
+
{t("restMode.status")}
); diff --git a/src/launcher-tauri/src/shell/ErrorBoundary.tsx b/src/launcher-tauri/src/shell/ErrorBoundary.tsx new file mode 100644 index 000000000..c9fc89eb4 --- /dev/null +++ b/src/launcher-tauri/src/shell/ErrorBoundary.tsx @@ -0,0 +1,115 @@ +/** Catches a render-time throw anywhere below it and shows something the + * user can act on, instead of the black window they got before. + * + * main.tsx wraps the initial `createRoot().render` in try/catch, but that + * only covers the first synchronous render. Anything that throws later -- + * a bad value arriving from an invoke, a null the view did not expect -- + * unmounts the whole tree and leaves an empty document. On a launcher + * driven by a gamepad, with no devtools in a release build, there is no way + * out of that except killing the process from Task Manager. + * + * Deliberately plain and dependency-free below this point: + * + * - No `useT()`. If the failure is in i18n (a malformed catalog, a bad + * locale import) then translating the error screen is exactly what + * cannot be relied on. English, always, so this can never be the thing + * that throws while reporting a throw. + * - Explicit colours rather than theme tokens, for the same reason. + * - Reload rather than "try again": re-rendering the same broken state + * usually just throws again, and a reload is the one recovery that + * reliably clears it. + * + * The stack is shown rather than hidden. Every bug report this project + * gets asks for one, and a user who cannot copy it out of a dead window + * cannot file a useful report. + */ + +import { Component, type ErrorInfo, type ReactNode } from "react"; + +interface Props { + children: ReactNode; +} + +interface State { + error: Error | null; +} + +export class ErrorBoundary extends Component { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + // Goes to the webview console in a dev build, and is the only record of + // a crash in a release one -- the UI below shows the same text. + console.error("Launcher crashed:", error, info.componentStack); + } + + render(): ReactNode { + const { error } = this.state; + if (!error) return this.props.children; + + return ( +
+

The launcher hit an error

+

+ Reloading usually clears it. The emulator itself is unaffected — a game that is already + running keeps running. +

+
+          {error.stack ?? `${error.name}: ${error.message}`}
+        
+ +
+ ); + } +} diff --git a/src/launcher-tauri/src/theme.css b/src/launcher-tauri/src/theme.css index 7fa7c8d9f..cecaf19da 100644 --- a/src/launcher-tauri/src/theme.css +++ b/src/launcher-tauri/src/theme.css @@ -374,7 +374,13 @@ body::after { .pill-button.primary:focus-visible, .pill-button.primary.ps-focused { outline-color: var(--focus-ring-dark); - box-shadow: 0 6px 20px -6px rgba(0, 0, 0, 0.5), 0 0 0 2px var(--focus-ring-dark) inset; + /* One ring, not two. This used to add `0 0 0 2px inset` on top of the + outline above, which drew a second concentric ring a couple of pixels + inside the first -- most obvious on Home's Play button, the largest + primary in the app. The outline follows border-radius on its own, so + the inset copy bought nothing but the doubling. The drop shadow stays: + that is the button's lift off the page, not a focus indicator. */ + box-shadow: 0 6px 20px -6px rgba(0, 0, 0, 0.5); } /* Shared range-slider primitive -- the app's only input[type=range] styling, @@ -482,8 +488,10 @@ input[type="range"]:disabled { } .icon-button:focus-visible, .icon-button.ps-focused { + /* Outline only -- the `0 0 0 2px` ring this used to also carry sat just + outside the outline and read as two stacked boxes, which is what the + modal close button looked like. */ outline-color: var(--accent); - box-shadow: 0 0 0 2px var(--focus-ring); } .icon-button:disabled { opacity: 0.35; @@ -771,3 +779,37 @@ select { scroll-behavior: auto !important; } } + +/* ---- idle throttle ------------------------------------------------------- + Set by lib/idle.ts whenever nobody is watching this window: hidden, + unfocused, or a game is running (the case that matters -- with + autoCloseOnLaunch off the launcher stays resident for the whole session, + and the emulator wants that GPU). + + Parking the animations rather than shortening them, unlike the + reduced-motion block above: this state is temporary and reverses, so + `paused` lets anything caught mid-flight resume from where it stopped + instead of snapping to its end. Measured cost of not doing this: ~17% of + one core at rest on Windows, ~13% of it inside WebView2's GPU process, + composited against the backdrop-filter rules the second selector flattens. + + Targeted at the *infinite* animations only -- the hero Ken Burns, the + tile/card flux ring and shimmer, and the pulse dot below -- never blanket + `*`. A blanket rule also froze the one-shot entrance animations, and + those reveal their elements: Home.module.css builds its tiles at + `opacity: 0` and animates up to 1 with `forwards`/`both`, so pausing + those left the game icons invisible until the idle state cleared rather + than saving anything. One-shot entrances are ~300ms and cost nothing; + they are deliberately left to run. Each component module pauses its own + looping animations under this same attribute. */ +:root[data-idle="1"] .status-dot { + /* ps-pulse is applied as an inline style (TopBar.tsx, Logs.tsx), so this + needs `!important` to win against it. */ + animation-play-state: paused !important; +} + +:root[data-idle="1"] .glass-panel { + backdrop-filter: none; + -webkit-backdrop-filter: none; + background: rgba(20, 24, 36, 0.92); +} diff --git a/src/launcher-tauri/src/types.ts b/src/launcher-tauri/src/types.ts index 9240568a8..db1ab13fe 100644 --- a/src/launcher-tauri/src/types.ts +++ b/src/launcher-tauri/src/types.ts @@ -129,6 +129,14 @@ export type GameStatus = "Unknown" | "InGame" | "Logo" | "DoesntBoot" | "MainMen export interface CompatibilityEntry { status: GameStatus; comment: string; + /** How many community reports back `status`. 0 for a local edit. */ + reports: number; + /** Emulator build the reports were filed against, "" when unstated. */ + version: string; + /** True when `status` is this platform's own figure rather than the + * feed's cross-platform aggregate -- the two disagree often enough that + * the UI has to say which one it is showing. */ + platformSpecific: boolean; } export type CompatibilityMap = Record;