Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/launcher-tauri/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
151 changes: 151 additions & 0 deletions src/launcher-tauri/RUNNING.md
Original file line number Diff line number Diff line change
@@ -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 <pid>
```

### 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 |
Binary file removed src/launcher-tauri/public/art/ambient_boot.png
Binary file not shown.
Binary file added src/launcher-tauri/public/art/ambient_boot.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed src/launcher-tauri/public/art/ambient_idle.png
Binary file not shown.
Binary file added src/launcher-tauri/public/art/ambient_idle.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed src/launcher-tauri/public/art/controller_diagram.png
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed src/launcher-tauri/public/art/kyty_mark.png
Binary file not shown.
Binary file added src/launcher-tauri/public/art/kyty_mark.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 7 additions & 1 deletion src/launcher-tauri/scripts/i18n-coverage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>[\s\S]*?:\s*T;\n/, "")
.replace(/^import[^\n]*\n/gm, "")
Expand Down
2 changes: 2 additions & 0 deletions src/launcher-tauri/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions src/launcher-tauri/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [] }

Expand All @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/launcher-tauri/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
113 changes: 110 additions & 3 deletions src/launcher-tauri/src-tauri/src/compatibility.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, CompatibilityEntry>;
Expand All @@ -72,13 +107,25 @@ fn parse(data: &str) -> Result<CompatibilityMap, String> {
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)
}
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading