From e5f4000b505686fc4e57dd2eab3805d9af264467 Mon Sep 17 00:00:00 2001 From: BlackCAT <52720489+psnwd@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:39:25 +0530 Subject: [PATCH 1/3] launcher-tauri: run on windows and macos The launcher only built on Linux. `bluer` is BlueZ over D-Bus and sat in plain [dependencies], so `libdbus-sys` was pulled in on every target and its build script failed looking for pkg-config. Gate it behind Linux and give bluetooth.rs a backend per platform instead: WinRT device enumeration on Windows, IOBluetooth on macOS, and an explicit unsupported error elsewhere. Where a platform has no app-facing call for something -- connect/disconnect on Windows, removing a pairing on macOS -- say so rather than offer a button that does nothing. Report adapter state through a new bluetooth_adapter_state command. A missing adapter and a switched-off one both yield an empty device list, so the panel now tells them apart. Listing devices no longer goes through the Linux helper that calls set_powered(true): asking whether the radio is on must not be what turns it on. Emulator discovery hardcoded _Build/linux/install, so Launch could never find the binary on another OS. The folder browser showed canonicalize()'s verbatim \?\C:\Users\name form, and a drive root has no parent, so there was no way to reach another drive. Trim the prefix and add a This PC level above the drive roots, which is also where the picker now opens on Windows. Write the supervisor's launch spec with tempfile rather than a predictable /tmp path: the spec names a binary the supervisor then executes, so a pre-created path was a way for another local account to choose it. Record the play start before handing off, since both processes read-modify-write the same playtime.json and the supervisor's matching stop can fire immediately when a game crashes on launch. Test suite was 67/72 on Windows, not the 70/72 the branch reported: - The ini fixtures are byte-exact, but nothing pinned their line endings, so core.autocrlf checked them out as CRLF against an LF writer. - build_args asserted a POSIX separator for a path built by Path::join. - bvh_stub_enabled defaulted to true while its own doc comment says off, and was written unconditionally -- so a Qt-written Kyty.ini could never round-trip, and every file this launcher touched gained a key the Qt launcher knows nothing about. Now 76/76 on Windows, clean under cargo check for aarch64-apple-darwin. --- src/launcher-tauri/src-tauri/Cargo.lock | 94 ++- src/launcher-tauri/src-tauri/Cargo.toml | 30 +- .../src-tauri/fixtures/.gitattributes | 6 + src/launcher-tauri/src-tauri/src/bluetooth.rs | 646 ++++++++++++++++-- src/launcher-tauri/src-tauri/src/browse.rs | 147 +++- src/launcher-tauri/src-tauri/src/config.rs | 15 +- src/launcher-tauri/src-tauri/src/emulator.rs | 21 +- src/launcher-tauri/src-tauri/src/lib.rs | 10 +- src/launcher-tauri/src-tauri/src/qsettings.rs | 10 + .../src-tauri/src/supervisor.rs | 22 +- .../src/components/FolderBrowserModal.tsx | 6 +- src/launcher-tauri/src/i18n/locales/en.ts | 4 + src/launcher-tauri/src/types.ts | 3 + src/launcher-tauri/src/views/Settings.tsx | 42 +- 14 files changed, 945 insertions(+), 111 deletions(-) create mode 100644 src/launcher-tauri/src-tauri/fixtures/.gitattributes diff --git a/src/launcher-tauri/src-tauri/Cargo.lock b/src/launcher-tauri/src-tauri/Cargo.lock index 7a59bf74d..039a2de17 100644 --- a/src/launcher-tauri/src-tauri/Cargo.lock +++ b/src/launcher-tauri/src-tauri/Cargo.lock @@ -1518,7 +1518,7 @@ dependencies = [ "vec_map", "wasm-bindgen", "web-sys", - "windows", + "windows 0.61.3", ] [[package]] @@ -2268,6 +2268,9 @@ dependencies = [ "dirs 5.0.1", "futures-util", "gilrs", + "objc2", + "objc2-foundation", + "objc2-io-bluetooth", "regex", "reqwest 0.12.28", "serde", @@ -2279,6 +2282,7 @@ dependencies = [ "tokio", "walkdir", "webkit2gtk", + "windows 0.62.2", ] [[package]] @@ -2752,8 +2756,21 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.1", "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-bluetooth" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558b52e7b77f6a68e1e2cc700ff2266779ede47bacb260a33d363b9a623cc761" +dependencies = [ + "libc", "objc2", "objc2-core-foundation", + "objc2-foundation", ] [[package]] @@ -4111,7 +4128,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -4190,7 +4207,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -4289,7 +4306,7 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.20", "url", - "windows", + "windows 0.61.3", "zbus", ] @@ -4315,7 +4332,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -4340,7 +4357,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -5150,7 +5167,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-implement", "windows-interface", @@ -5174,7 +5191,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.20", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -5230,11 +5247,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -5246,6 +5275,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -5280,7 +5318,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -5327,6 +5376,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -5474,6 +5533,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -5699,7 +5767,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", diff --git a/src/launcher-tauri/src-tauri/Cargo.toml b/src/launcher-tauri/src-tauri/Cargo.toml index f1ee70f6e..b42b2d611 100644 --- a/src/launcher-tauri/src-tauri/Cargo.toml +++ b/src/launcher-tauri/src-tauri/Cargo.toml @@ -21,12 +21,34 @@ regex = "1.12" reqwest = { version = "0.12", features = ["blocking", "json"] } dirs = "5" gilrs = { version = "0.11.2", features = ["serde"] } -bluer = { version = "0.17.4", features = ["bluetoothd"] } -tokio = { version = "1", features = ["time"] } +tokio = { version = "1", features = ["time", "rt"] } futures-util = "0.3" +tempfile = "3" [target.'cfg(target_os = "linux")'.dependencies] webkit2gtk = { version = "2.0", features = ["v2_38"] } +# Bluetooth has no cross-platform backend: every portable Rust crate is +# BLE-only, and the devices this panel targets (DualSense pads, headsets) +# pair over Bluetooth Classic instead. So each OS gets its native stack -- +# BlueZ here, WinRT below, IOBluetooth on macOS. See bluetooth.rs. +bluer = { version = "0.17.4", features = ["bluetoothd"] } -[dev-dependencies] -tempfile = "3" +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.62", features = [ + "Devices_Bluetooth", + "Devices_Enumeration", + "Devices_Radios", + "Foundation", + "Foundation_Collections", +] } + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-foundation = { version = "0.3", features = ["NSArray", "NSString"] } +objc2-io-bluetooth = { version = "0.3", features = [ + "IOBluetoothDevice", + "IOBluetoothDevicePair", + "IOBluetoothHostController", + "Bluetooth", + "IOBluetoothDeviceInquiry", +] } diff --git a/src/launcher-tauri/src-tauri/fixtures/.gitattributes b/src/launcher-tauri/src-tauri/fixtures/.gitattributes new file mode 100644 index 000000000..34adc6b7f --- /dev/null +++ b/src/launcher-tauri/src-tauri/fixtures/.gitattributes @@ -0,0 +1,6 @@ +# These are byte-exact fixtures: qsettings.rs and config.rs round-trip them +# through the writer and assert the result is identical to the file on disk. +# Git's autocrlf would rewrite them to CRLF on checkout under Windows and +# break that assertion for reasons that have nothing to do with the writer, +# so pin them to LF regardless of platform or the user's core.autocrlf. +*.ini -text eol=lf diff --git a/src/launcher-tauri/src-tauri/src/bluetooth.rs b/src/launcher-tauri/src-tauri/src/bluetooth.rs index 2c8f362bb..06e772c34 100644 --- a/src/launcher-tauri/src-tauri/src/bluetooth.rs +++ b/src/launcher-tauri/src-tauri/src/bluetooth.rs @@ -1,22 +1,31 @@ //! Bluetooth device pairing, backing src/views/Settings.tsx's Settings > -//! Bluetooth category. Talks to BlueZ over D-Bus via the `bluer` crate -- -//! the standard, always-present Bluetooth daemon on any Linux desktop -- -//! rather than shelling out to `bluetoothctl`, since pairing and connect -//! state need real async completion signals, not screen-scraped CLI output. +//! Bluetooth category. //! -//! Deliberately does NOT register a custom D-Bus pairing agent: BlueZ's -//! default agent (the one GNOME's own Bluetooth settings already registers) -//! handles "Just Works" pairing for the devices this UI targets (headsets, -//! gamepads) with no PIN prompt. Registering a second agent here would -//! fight GNOME's for the DisplayYesNo/RequestPasskey role -- out of scope, -//! and exactly the kind of surprise interaction the owner's Remote Desktop -//! popup objection during this same session says to avoid. - -use bluer::{Address, Session}; -use futures_util::StreamExt; +//! There is no portable backend to reach for. Every cross-platform Rust +//! Bluetooth crate is BLE-only, while the devices this panel exists for -- +//! DualSense pads, headsets -- pair over Bluetooth Classic (BR/EDR). So +//! each OS gets its own `platform` module below, talking to that system's +//! native stack: +//! +//! - Linux: BlueZ over D-Bus through `bluer`, rather than screen-scraping +//! `bluetoothctl`, since pairing and connect state need real async +//! completion signals. Deliberately does NOT register a custom D-Bus +//! pairing agent: BlueZ's default agent (the one GNOME's own Bluetooth +//! settings already registers) handles "Just Works" pairing for these +//! devices with no PIN prompt, and a second agent would fight GNOME's for +//! the DisplayYesNo/RequestPasskey role. +//! - Windows: WinRT device enumeration (`Windows.Devices.Enumeration`), +//! filtered to the Bluetooth association endpoints. Pairing goes through +//! `DeviceInformationPairing`, which raises the system's own consent UI +//! for devices that need one. +//! - macOS: IOBluetooth. `IOBluetoothDevice::pairedDevices` for the list, +//! `IOBluetoothDevicePair` to pair. +//! +//! All four modules expose the same six functions, so the commands at the +//! bottom of this file are platform-independent and stay registered on +//! every target. + use serde::Serialize; -use std::str::FromStr; -use std::time::Duration; #[derive(Serialize, Clone)] pub struct BtDevice { @@ -26,24 +35,555 @@ pub struct BtDevice { pub connected: bool, } -async fn default_adapter() -> Result { - let session = Session::new().await.map_err(|e| e.to_string())?; - let adapter = session.default_adapter().await.map_err(|e| e.to_string())?; - adapter.set_powered(true).await.map_err(|e| e.to_string())?; - Ok(adapter) +/// Whether this machine can do Bluetooth at all, and whether the radio is +/// currently switched on. The two are separate answers on purpose: a +/// machine with no adapter and a machine whose adapter is turned off both +/// produce an empty device list, but only one of them is worth telling the +/// user to go and fix. +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct BtAdapter { + /// An adapter exists on this machine. + pub present: bool, + /// The adapter exists and its radio is on. + pub powered: bool, +} + +impl BtAdapter { + fn missing() -> Self { + BtAdapter { present: false, powered: false } + } +} + +#[cfg(target_os = "linux")] +mod platform { + use super::{BtAdapter, BtDevice}; + use bluer::{Address, Session}; + use futures_util::StreamExt; + use std::str::FromStr; + use std::time::Duration; + + /// Deliberately does not go through `default_adapter`, which powers the + /// adapter on: asking whether the radio is on must not be what turns it + /// on. + pub async fn adapter_state() -> BtAdapter { + let Ok(adapter) = read_only_adapter().await else { return BtAdapter::missing() }; + BtAdapter { present: true, powered: adapter.is_powered().await.unwrap_or(false) } + } + + /// For actions the user explicitly asked for (scanning, pairing, + /// connecting), which cannot work with the radio off -- so this powers + /// it on. Merely *listing* devices must not, or opening the Bluetooth + /// settings page would silently switch the user's radio on behind them; + /// `read_only_adapter` is for that. + pub async fn default_adapter() -> Result { + let adapter = read_only_adapter().await?; + adapter.set_powered(true).await.map_err(|e| e.to_string())?; + Ok(adapter) + } + + async fn read_only_adapter() -> Result { + let session = Session::new().await.map_err(|e| e.to_string())?; + session.default_adapter().await.map_err(|e| e.to_string()) + } + + async fn describe(adapter: &bluer::Adapter, addr: Address) -> Option { + let device = adapter.device(addr).ok()?; + let name = device + .name() + .await + .ok() + .flatten() + .unwrap_or_else(|| addr.to_string()); + let paired = device.is_paired().await.unwrap_or(false); + let connected = device.is_connected().await.unwrap_or(false); + Some(BtDevice { address: addr.to_string(), name, paired, connected }) + } + + pub async fn list() -> Result, String> { + let adapter = read_only_adapter().await?; + let addrs = adapter.device_addresses().await.map_err(|e| e.to_string())?; + let mut devices = Vec::new(); + for addr in addrs { + if let Some(d) = describe(&adapter, addr).await { + devices.push(d); + } + } + Ok(devices) + } + + pub async fn scan() -> Result, String> { + let adapter = default_adapter().await?; + let mut events = adapter.discover_devices().await.map_err(|e| e.to_string())?; + let deadline = tokio::time::sleep(Duration::from_secs(8)); + tokio::pin!(deadline); + loop { + tokio::select! { + _ = &mut deadline => break, + ev = events.next() => if ev.is_none() { break }, + } + } + list().await + } + + async fn device(address: &str) -> Result { + let addr = Address::from_str(address).map_err(|e| e.to_string())?; + let adapter = default_adapter().await?; + adapter.device(addr).map_err(|e| e.to_string()) + } + + pub async fn pair(address: String) -> Result<(), String> { + let device = device(&address).await?; + if !device.is_paired().await.unwrap_or(false) { + device.pair().await.map_err(|e| e.to_string())?; + } + device.connect().await.map_err(|e| e.to_string()) + } + + pub async fn connect(address: String) -> Result<(), String> { + device(&address).await?.connect().await.map_err(|e| e.to_string()) + } + + pub async fn disconnect(address: String) -> Result<(), String> { + device(&address).await?.disconnect().await.map_err(|e| e.to_string()) + } + + pub async fn forget(address: String) -> Result<(), String> { + let addr = Address::from_str(&address).map_err(|e| e.to_string())?; + let adapter = default_adapter().await?; + adapter.remove_device(addr).await.map_err(|e| e.to_string()) + } } -async fn describe(adapter: &bluer::Adapter, addr: Address) -> Option { - let device = adapter.device(addr).ok()?; - let name = device - .name() +#[cfg(target_os = "windows")] +mod platform { + use super::{BtAdapter, BtDevice}; + use windows::Devices::Bluetooth::{BluetoothAdapter, BluetoothConnectionStatus, BluetoothDevice}; + use windows::Devices::Radios::RadioState; + use windows::Devices::Enumeration::{ + DeviceInformation, DevicePairingResultStatus, DeviceUnpairingResultStatus, + }; + + /// WinRT objects are apartment-bound and not `Send`, and every call here + /// has to hold one across an `.await` on the matching `IAsyncOperation`. + /// Tauri needs the command futures to be `Send`, so the WinRT half runs + /// on a blocking thread with its own single-threaded runtime, and only + /// the plain `BtDevice` data crosses back. These are user-initiated and + /// infrequent, so standing a runtime up per call is not worth avoiding. + async fn off_thread(work: F) -> Result + where + T: Send + 'static, + F: FnOnce() -> Fut + Send + 'static, + Fut: std::future::Future>, + { + tauri::async_runtime::spawn_blocking(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| e.to_string())? + .block_on(work()) + }) + .await + .map_err(|e| e.to_string())? + } + + /// WinRT hands back a Bluetooth address as a plain 48-bit integer, which + /// is not what anyone recognizes as a device address, and not what the + /// other commands here are given to look one up by. + fn format_address(address: u64) -> String { + let b = address.to_be_bytes(); + format!( + "{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}", + b[2], b[3], b[4], b[5], b[6], b[7] + ) + } + + /// Device ids look like `Bluetooth#Bluetooth-`, so the + /// address is the tail. Only used when opening the `BluetoothDevice` + /// fails, which happens for devices seen in a scan but not yet paired: + /// listing one without an address would give the user a row they cannot + /// then act on. + fn address_from_id(id: &str) -> Option { + let tail = id.rsplit('-').next()?; + let cleaned: String = tail.chars().filter(|c| c.is_ascii_hexdigit()).collect(); + (cleaned.len() == 12).then(|| { + cleaned + .as_bytes() + .chunks(2) + .map(|pair| String::from_utf8_lossy(pair).to_uppercase()) + .collect::>() + .join(":") + }) + } + + /// `paired` comes from the selector the device was found through, not + /// from `info.Pairing().IsPaired()`. On the `DeviceInformation` behind a + /// `BluetoothDevice` that property reports false even for devices + /// Windows itself lists as paired (it tracks the association endpoint, + /// which is a different object), so trusting it put every paired device + /// in the "available" half of the panel, offering Connect on something + /// already paired. + async fn describe(info: &DeviceInformation, paired: bool) -> Option { + let id = info.Id().ok()?; + let name = info + .Name() + .map(|n| n.to_string_lossy()) + .ok() + .filter(|n| !n.is_empty()); + + // The full device object carries the address and the live connection + // state, but it can only be opened for devices Windows has a real + // radio association with, so fall back to the id for the rest. + match BluetoothDevice::FromIdAsync(&id).ok()?.await { + Ok(device) => { + let address = format_address(device.BluetoothAddress().ok()?); + Some(BtDevice { + name: name + .or_else(|| device.Name().map(|n| n.to_string_lossy()).ok()) + .unwrap_or_else(|| address.clone()), + connected: device + .ConnectionStatus() + .map(|s| s == BluetoothConnectionStatus::Connected) + .unwrap_or(false), + paired, + address, + }) + } + Err(_) => { + let address = address_from_id(&id.to_string_lossy())?; + Some(BtDevice { + name: name.unwrap_or_else(|| address.clone()), + address, + paired, + connected: false, + }) + } + } + } + + /// One enumeration pass over devices in the given pairing state. This is + /// a snapshot rather than a live `DeviceWatcher`: a watcher would suit a + /// panel that fills in as devices answer, but these commands are + /// request/response over IPC with nowhere to push later updates, so the + /// frontend re-invokes instead. + async fn enumerate(paired: bool) -> Result, String> { + let selector = BluetoothDevice::GetDeviceSelectorFromPairingState(paired).map_err(|e| e.message())?; + let collection = DeviceInformation::FindAllAsyncAqsFilter(&selector) + .map_err(|e| e.message())? + .await + .map_err(|e| e.message())?; + + let mut devices = Vec::new(); + for info in collection { + if let Some(device) = describe(&info, paired).await { + devices.push(device); + } + } + Ok(devices) + } + + async fn find_by_address(address: &str) -> Result { + let wanted = address.to_uppercase(); + for paired in [true, false] { + let selector = + BluetoothDevice::GetDeviceSelectorFromPairingState(paired).map_err(|e| e.message())?; + let collection = DeviceInformation::FindAllAsyncAqsFilter(&selector) + .map_err(|e| e.message())? + .await + .map_err(|e| e.message())?; + for info in collection { + if describe(&info, paired).await.map(|d| d.address) == Some(wanted.clone()) { + return Ok(info); + } + } + } + Err(format!("No Bluetooth device with address {address} was found.")) + } + + /// `GetDefaultAsync` resolves to nothing when the machine has no radio; + /// the radio's own on/off switch is a separate object, reached through + /// the adapter, so a present-but-disabled adapter still answers here. + pub async fn adapter_state() -> BtAdapter { + off_thread(|| async { + let Ok(operation) = BluetoothAdapter::GetDefaultAsync() else { + return Ok(BtAdapter::missing()); + }; + let Ok(adapter) = operation.await else { + return Ok(BtAdapter::missing()); + }; + let powered = match adapter.GetRadioAsync() { + Ok(radio) => radio + .await + .ok() + .and_then(|r| r.State().ok()) + .map(|state| state == RadioState::On) + .unwrap_or(false), + Err(_) => false, + }; + Ok(BtAdapter { present: true, powered }) + }) + .await + .unwrap_or_else(|_| BtAdapter::missing()) + } + + pub async fn list() -> Result, String> { + off_thread(|| enumerate(true)).await + } + + /// Paired devices plus whatever is in range but unpaired, which is the + /// half a scan exists to surface. + pub async fn scan() -> Result, String> { + off_thread(|| async { + let mut devices = enumerate(true).await?; + let known: Vec = devices.iter().map(|d| d.address.clone()).collect(); + for device in enumerate(false).await? { + if !known.contains(&device.address) { + devices.push(device); + } + } + Ok(devices) + }) + .await + } + + pub async fn pair(address: String) -> Result<(), String> { + off_thread(move || async move { + let pairing = find_by_address(&address).await?.Pairing().map_err(|e| e.message())?; + if pairing.IsPaired().unwrap_or(false) { + return Ok(()); + } + let result = pairing + .PairAsync() + .map_err(|e| e.message())? + .await + .map_err(|e| e.message())?; + match result.Status().map_err(|e| e.message())? { + DevicePairingResultStatus::Paired | DevicePairingResultStatus::AlreadyPaired => Ok(()), + other => Err(format!("Pairing failed ({other:?}).")), + } + }) + .await + } + + /// Windows exposes no app-facing "connect this paired device" call for + /// Bluetooth Classic -- the profile drivers bring the link up themselves + /// once the device is paired and in range. Saying so beats a button that + /// silently does nothing. + pub async fn connect(_address: String) -> Result<(), String> { + Err("Windows reconnects paired Bluetooth devices by itself; switch the device on and it will connect." + .to_string()) + } + + pub async fn disconnect(_address: String) -> Result<(), String> { + Err("Windows does not let an app disconnect a paired Bluetooth device; use Remove instead.".to_string()) + } + + pub async fn forget(address: String) -> Result<(), String> { + off_thread(move || async move { + let pairing = find_by_address(&address).await?.Pairing().map_err(|e| e.message())?; + let result = pairing + .UnpairAsync() + .map_err(|e| e.message())? + .await + .map_err(|e| e.message())?; + match result.Status().map_err(|e| e.message())? { + DeviceUnpairingResultStatus::Unpaired | DeviceUnpairingResultStatus::AlreadyUnpaired => Ok(()), + other => Err(format!("Removing the pairing failed ({other:?}).")), + } + }) .await - .ok() - .flatten() - .unwrap_or_else(|| addr.to_string()); - let paired = device.is_paired().await.unwrap_or(false); - let connected = device.is_connected().await.unwrap_or(false); - Some(BtDevice { address: addr.to_string(), name, paired, connected }) + } +} + +#[cfg(target_os = "macos")] +mod platform { + use super::{BtAdapter, BtDevice}; + use objc2_foundation::{NSArray, NSString}; + use objc2_io_bluetooth::{BluetoothHCIPowerState, IOBluetoothDevice, IOBluetoothDevicePair, IOBluetoothHostController}; + + /// `IOReturn` is a plain status code; every call here treats zero + /// (`kIOReturnSuccess`) as the only success. + const IO_RETURN_SUCCESS: i32 = 0; + + fn check(status: i32, what: &str) -> Result<(), String> { + if status == IO_RETURN_SUCCESS { + Ok(()) + } else { + Err(format!("{what} failed (IOReturn {status}).")) + } + } + + /// Everything Objective-C stays inside these sync helpers. The async + /// wrappers below hold no `Retained` handle across an await, which keeps + /// the futures `Send` for the command layer. + fn describe(device: &IOBluetoothDevice) -> Option { + // Without an address there is nothing the other commands could look + // the device up by later, so the row would be dead weight. + let address = unsafe { device.addressString() }?.to_string(); + let name = unsafe { device.nameOrAddress() } + .map(|n| n.to_string()) + .unwrap_or_else(|| address.clone()); + Some(BtDevice { + address, + name, + paired: unsafe { device.isPaired() }, + connected: unsafe { device.isConnected() }, + }) + } + + fn collect(array: Option>) -> Vec { + let Some(array) = array else { return Vec::new() }; + let mut devices = Vec::new(); + for index in 0..array.count() { + let object = array.objectAtIndex(index); + if let Ok(device) = object.downcast::() { + if let Some(described) = describe(&device) { + devices.push(described); + } + } + } + devices + } + + fn find(address: &str) -> Result, String> { + let key = NSString::from_str(address); + unsafe { IOBluetoothDevice::deviceWithAddressString(Some(&key)) } + .ok_or_else(|| format!("No Bluetooth device with address {address} was found.")) + } + + fn list_sync() -> Vec { + collect(unsafe { IOBluetoothDevice::pairedDevices() }) + } + + /// macOS has no scan that fits here. A live inquiry means + /// `IOBluetoothDeviceInquiry`, which reports results to an Objective-C + /// delegate and needs a CFRunLoop turning on the calling thread -- these + /// commands run on a worker thread with no run loop, so an inquiry would + /// simply never report anything. `recentDevices` is the honest + /// substitute: devices the system has seen before, paired or not. + fn scan_sync() -> Vec { + let mut devices = list_sync(); + let known: Vec = devices.iter().map(|d| d.address.clone()).collect(); + for device in collect(unsafe { IOBluetoothDevice::recentDevices(0) }) { + if !known.contains(&device.address) { + devices.push(device); + } + } + devices + } + + fn pair_sync(address: &str) -> Result<(), String> { + let device = find(address)?; + if unsafe { device.isPaired() } { + return Ok(()); + } + let pair = unsafe { IOBluetoothDevicePair::pairWithDevice(Some(&device)) } + .ok_or_else(|| "Could not start pairing with this device.".to_string())?; + // No delegate is set, so this covers "Just Works" devices (pads, + // headsets) only. Anything demanding a PIN or numeric confirmation + // needs a delegate to answer the challenge, which would mean an + // Objective-C class and a run loop to deliver the callbacks on. + check(unsafe { pair.start() }, "Pairing") + } + + fn connect_sync(address: &str) -> Result<(), String> { + check(unsafe { find(address)?.openConnection() }, "Connecting") + } + + fn disconnect_sync(address: &str) -> Result<(), String> { + check(unsafe { find(address)?.closeConnection() }, "Disconnecting") + } + + /// `defaultController` returns nothing when the Mac has no Bluetooth + /// hardware at all; a controller that exists but is switched off still + /// answers, with a power state of OFF. + pub async fn adapter_state() -> BtAdapter { + let Some(controller) = (unsafe { IOBluetoothHostController::defaultController() }) else { + return BtAdapter::missing(); + }; + BtAdapter { + present: true, + powered: unsafe { controller.powerState() } == BluetoothHCIPowerState::ON, + } + } + + pub async fn list() -> Result, String> { + Ok(list_sync()) + } + + pub async fn scan() -> Result, String> { + Ok(scan_sync()) + } + + pub async fn pair(address: String) -> Result<(), String> { + pair_sync(&address) + } + + pub async fn connect(address: String) -> Result<(), String> { + connect_sync(&address) + } + + pub async fn disconnect(address: String) -> Result<(), String> { + disconnect_sync(&address) + } + + /// IOBluetooth exposes no supported way to remove a pairing: the one + /// call that did (`IOBluetoothDevice::remove`) has never been public + /// API. Saying so beats a button that reports success and changes + /// nothing. + pub async fn forget(_address: String) -> Result<(), String> { + Err("macOS does not let an app remove a Bluetooth pairing; use System Settings > Bluetooth.".to_string()) + } +} + +#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] +mod platform { + use super::{BtAdapter, BtDevice}; + + pub async fn adapter_state() -> BtAdapter { + BtAdapter::missing() + } + + /// Platforms with no backend here. An explicit error beats an empty + /// list, which in the UI is indistinguishable from an adapter that is + /// simply switched off. + fn unsupported() -> Result { + Err("Bluetooth is not supported on this platform in this launcher.".to_string()) + } + + pub async fn list() -> Result, String> { + unsupported() + } + + pub async fn scan() -> Result, String> { + unsupported() + } + + pub async fn pair(_address: String) -> Result<(), String> { + unsupported() + } + + pub async fn connect(_address: String) -> Result<(), String> { + unsupported() + } + + pub async fn disconnect(_address: String) -> Result<(), String> { + unsupported() + } + + pub async fn forget(_address: String) -> Result<(), String> { + unsupported() + } +} + +/// Whether this machine has a Bluetooth adapter and whether it is switched +/// on. The panel asks first, so it can say "turn Bluetooth on" or "no +/// adapter" instead of showing an empty device list that looks the same in +/// both cases. Never fails: a machine with no Bluetooth is a normal answer, +/// not an error. +#[tauri::command] +pub async fn bluetooth_adapter_state() -> BtAdapter { + platform::adapter_state().await } /// Every device BlueZ currently knows about (paired, or seen in a prior @@ -52,15 +592,7 @@ async fn describe(adapter: &bluer::Adapter, addr: Address) -> Option { /// already establishes for Settings categories. #[tauri::command] pub async fn list_bluetooth_devices() -> Result, String> { - let adapter = default_adapter().await?; - let addrs = adapter.device_addresses().await.map_err(|e| e.to_string())?; - let mut devices = Vec::new(); - for addr in addrs { - if let Some(d) = describe(&adapter, addr).await { - devices.push(d); - } - } - Ok(devices) + platform::list().await } /// Discovers nearby devices for a fixed window, then returns the same @@ -69,49 +601,25 @@ pub async fn list_bluetooth_devices() -> Result, String> { /// them to `device_addresses()` at all. #[tauri::command] pub async fn scan_bluetooth_devices() -> Result, String> { - let adapter = default_adapter().await?; - let mut events = adapter.discover_devices().await.map_err(|e| e.to_string())?; - let deadline = tokio::time::sleep(Duration::from_secs(8)); - tokio::pin!(deadline); - loop { - tokio::select! { - _ = &mut deadline => break, - ev = events.next() => if ev.is_none() { break }, - } - } - list_bluetooth_devices().await + platform::scan().await } #[tauri::command] pub async fn pair_bluetooth_device(address: String) -> Result<(), String> { - let addr = Address::from_str(&address).map_err(|e| e.to_string())?; - let adapter = default_adapter().await?; - let device = adapter.device(addr).map_err(|e| e.to_string())?; - if !device.is_paired().await.unwrap_or(false) { - device.pair().await.map_err(|e| e.to_string())?; - } - device.connect().await.map_err(|e| e.to_string()) + platform::pair(address).await } #[tauri::command] pub async fn connect_bluetooth_device(address: String) -> Result<(), String> { - let addr = Address::from_str(&address).map_err(|e| e.to_string())?; - let adapter = default_adapter().await?; - let device = adapter.device(addr).map_err(|e| e.to_string())?; - device.connect().await.map_err(|e| e.to_string()) + platform::connect(address).await } #[tauri::command] pub async fn disconnect_bluetooth_device(address: String) -> Result<(), String> { - let addr = Address::from_str(&address).map_err(|e| e.to_string())?; - let adapter = default_adapter().await?; - let device = adapter.device(addr).map_err(|e| e.to_string())?; - device.disconnect().await.map_err(|e| e.to_string()) + platform::disconnect(address).await } #[tauri::command] pub async fn forget_bluetooth_device(address: String) -> Result<(), String> { - let addr = Address::from_str(&address).map_err(|e| e.to_string())?; - let adapter = default_adapter().await?; - adapter.remove_device(addr).await.map_err(|e| e.to_string()) + platform::forget(address).await } diff --git a/src/launcher-tauri/src-tauri/src/browse.rs b/src/launcher-tauri/src-tauri/src/browse.rs index ade2a9162..223a79f7c 100644 --- a/src/launcher-tauri/src-tauri/src/browse.rs +++ b/src/launcher-tauri/src-tauri/src/browse.rs @@ -25,6 +25,53 @@ pub struct BrowseResult { pub parent: Option, pub home: String, pub entries: Vec, + /// True for the Windows drive list, which is a made-up level rather than + /// a real directory: it has no path you could hand to the emulator, so + /// the UI keeps its "select this folder" action disabled while it shows. + pub is_virtual: bool, +} + +/// Stand-in path for "the level above a drive root" on Windows, i.e. what +/// Explorer calls This PC. `C:\` has no parent, so without this there is no +/// way to get from one drive to another in the browser. +#[cfg(windows)] +pub const DRIVE_ROOT: &str = "::drives"; + +/// Paths for display and for navigating back in. `canonicalize` on Windows +/// hands back the verbatim `\\?\C:\Users\you` form, which is correct but is +/// not what anyone recognizes as their own path, so trim it back to the +/// ordinary spelling. UNC paths canonicalize to `\\?\UNC\server\share` and +/// come back as `\\server\share`. +fn display_path(path: &Path) -> String { + let text = path.to_string_lossy().to_string(); + #[cfg(windows)] + { + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + return format!(r"\\{rest}"); + } + if let Some(rest) = text.strip_prefix(r"\\?\") { + return rest.to_string(); + } + } + text +} + +/// Every drive letter currently mounted, in the shape the browser lists +/// folders in. Probing A-Z with a `is_dir` check keeps this dependency-free; +/// the alternative is `GetLogicalDrives` through a winapi crate, which is a +/// lot of surface for 26 stat calls. +#[cfg(windows)] +fn drive_entries() -> Vec { + (b'A'..=b'Z') + .map(|letter| format!("{}:\\", letter as char)) + .filter(|root| Path::new(root).is_dir()) + .map(|root| BrowseEntry { + name: root.clone(), + path: root, + is_dir: true, + looks_like_game: false, + }) + .collect() } /// `file_extensions`, when given, also lists files whose extension matches @@ -32,6 +79,29 @@ pub struct BrowseResult { /// browse for an image instead of a folder. pub fn browse_folder(path: Option<&str>, file_extensions: Option<&[String]>) -> Result { let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); + + // The drive list, either asked for outright or as the Windows opening + // view: a games library usually lives on some other drive than the one + // the home folder is on, so starting at This PC saves walking up out of + // `C:\Users\` every time. If no drive can be listed at all, fall + // through to the home folder rather than showing an empty picker. + #[cfg(windows)] + { + let wants_drive_root = path == Some(DRIVE_ROOT) || path.map_or(true, |p| p.is_empty()); + if wants_drive_root { + let entries = drive_entries(); + if !entries.is_empty() { + return Ok(BrowseResult { + path: DRIVE_ROOT.to_string(), + parent: None, + home: display_path(&home), + entries, + is_virtual: true, + }); + } + } + } + let target = match path { Some(p) if !p.is_empty() => PathBuf::from(p), _ => home.clone(), @@ -39,7 +109,7 @@ pub fn browse_folder(path: Option<&str>, file_extensions: Option<&[String]>) -> let target = target.canonicalize().unwrap_or(target); if !target.is_dir() { - return Err(format!("{} is not a folder.", target.display())); + return Err(format!("{} is not a folder.", display_path(&target))); } let wanted_ext = |p: &Path| -> bool { @@ -59,14 +129,14 @@ pub fn browse_folder(path: Option<&str>, file_extensions: Option<&[String]>) -> name: e.file_name().to_string_lossy().to_string(), looks_like_game: path.join("eboot.bin").is_file(), is_dir: true, - path: path.to_string_lossy().to_string(), + path: display_path(&path), }) } else if wanted_ext(&path) { Some(BrowseEntry { name: e.file_name().to_string_lossy().to_string(), looks_like_game: false, is_dir: false, - path: path.to_string_lossy().to_string(), + path: display_path(&path), }) } else { None @@ -79,19 +149,31 @@ pub fn browse_folder(path: Option<&str>, file_extensions: Option<&[String]>) -> _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()), }); - let parent = parent_of(&target).map(|p| p.to_string_lossy().to_string()); - Ok(BrowseResult { - path: target.to_string_lossy().to_string(), - parent, - home: home.to_string_lossy().to_string(), + path: display_path(&target), + parent: parent_of(&target), + home: display_path(&home), entries, + is_virtual: false, }) } -fn parent_of(path: &Path) -> Option { - let parent = path.parent()?; - (parent != path).then(|| parent.to_path_buf()) +fn parent_of(path: &Path) -> Option { + match path.parent() { + Some(parent) if parent != path => Some(display_path(parent)), + // Already at a filesystem root. On Windows that is a drive root, and + // the level above it is the drive list; on Unix `/` really is the top. + _ => { + #[cfg(windows)] + { + Some(DRIVE_ROOT.to_string()) + } + #[cfg(not(windows))] + { + None + } + } + } } #[cfg(test)] @@ -114,6 +196,49 @@ mod tests { assert!(!empty.looks_like_game); } + #[test] + #[cfg(windows)] + fn display_path_trims_the_verbatim_prefix() { + assert_eq!(display_path(Path::new(r"\\?\C:\Users\you")), r"C:\Users\you"); + assert_eq!(display_path(Path::new(r"\\?\UNC\server\share")), r"\\server\share"); + assert_eq!(display_path(Path::new(r"C:\Users\you")), r"C:\Users\you"); + } + + #[test] + #[cfg(windows)] + fn drive_root_is_the_parent_of_a_drive() { + assert_eq!(parent_of(Path::new(r"C:\")).as_deref(), Some(DRIVE_ROOT)); + assert_eq!(parent_of(Path::new(r"C:\Users")).as_deref(), Some(r"C:\")); + } + + #[test] + #[cfg(windows)] + fn opens_on_the_drive_list_when_no_path_is_given() { + for path in [None, Some("")] { + let result = browse_folder(path, None).unwrap(); + assert!(result.is_virtual, "{path:?} should open This PC, not the home folder"); + assert!(!result.entries.is_empty()); + } + } + + #[test] + #[cfg(not(windows))] + fn opens_on_the_home_folder_when_no_path_is_given() { + let result = browse_folder(None, None).unwrap(); + assert!(!result.is_virtual); + assert_eq!(result.path, result.home); + } + + #[test] + #[cfg(windows)] + fn drive_list_is_virtual_and_lists_mounted_drives() { + let result = browse_folder(Some(DRIVE_ROOT), None).unwrap(); + assert!(result.is_virtual); + assert!(result.parent.is_none()); + assert!(!result.entries.is_empty(), "expected at least one mounted drive"); + assert!(result.entries.iter().all(|e| e.is_dir)); + } + #[test] fn rejects_a_file_path() { 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 67ec63e7a..357f6efe1 100644 --- a/src/launcher-tauri/src-tauri/src/config.rs +++ b/src/launcher-tauri/src-tauri/src/config.rs @@ -202,7 +202,7 @@ impl Default for Configuration { printf_output_file: "_kyty.txt".to_string(), profiler_direction: ProfilerDirection::default(), renderdoc_enabled: false, - bvh_stub_enabled: true, + bvh_stub_enabled: false, host_input_mapping: Vec::new(), elf: "eboot.bin".to_string(), title_id: String::new(), @@ -262,7 +262,18 @@ impl Configuration { doc.set(section, &k("printf_output_file"), encode_string(&self.printf_output_file)); doc.set(section, &k("profiler_direction"), self.profiler_direction.as_ini_text().to_string()); doc.set(section, &k("renderdoc_enabled"), encode_bool(self.renderdoc_enabled)); - doc.set(section, &k("bvh_stub_enabled"), encode_bool(self.bvh_stub_enabled)); + // Written only when switched on. This key is a launcher-tauri + // addition that the Qt launcher knows nothing about, so emitting it + // unconditionally would inject a foreign line into every Kyty.ini + // this launcher touches -- including files the user goes back and + // forth to the Qt launcher with. Off is the default, so absent and + // `false` mean the same thing on load; leaving it out keeps a + // Qt-written file byte-identical through a load/save cycle. + if self.bvh_stub_enabled { + doc.set(section, &k("bvh_stub_enabled"), encode_bool(true)); + } else { + doc.remove(section, &k("bvh_stub_enabled")); + } doc.set(section, &k("host_input_mapping"), encode_string_list(&self.host_input_mapping)); doc.set(section, &k("elf"), encode_string(&self.elf)); } diff --git a/src/launcher-tauri/src-tauri/src/emulator.rs b/src/launcher-tauri/src-tauri/src/emulator.rs index 0f360b056..979c19b9f 100644 --- a/src/launcher-tauri/src-tauri/src/emulator.rs +++ b/src/launcher-tauri/src-tauri/src/emulator.rs @@ -81,8 +81,18 @@ pub fn find_terminal() -> Option<(PathBuf, Vec)> { None } +/// The per-OS half of the dev build layout: CMake configures into +/// `_Build//`, so the `install/` tree to probe for is named after the +/// platform this launcher was built for, not always `linux`. +#[cfg(target_os = "linux")] +const BUILD_INSTALL_DIR: &str = "_Build/linux/install"; +#[cfg(windows)] +const BUILD_INSTALL_DIR: &str = "_Build/windows/install"; +#[cfg(target_os = "macos")] +const BUILD_INSTALL_DIR: &str = "_Build/macos/install"; + /// Find `kyty_emulator` next to the app binary, its parent, walking up to a -/// `_Build/linux/install/kyty_emulator` (the local dev build layout), or on +/// `_Build//install/kyty_emulator` (the local dev build layout), or on /// `$PATH`. pub fn discover_emulator(app_binary_dir: &Path) -> Option { let candidate = app_binary_dir.join(EMULATOR_EXE); @@ -98,7 +108,7 @@ pub fn discover_emulator(app_binary_dir: &Path) -> Option { let mut dir = app_binary_dir.to_path_buf(); for _ in 0..8 { - let candidate = dir.join("_Build/linux/install").join(EMULATOR_EXE); + let candidate = dir.join(BUILD_INSTALL_DIR).join(EMULATOR_EXE); if candidate.is_file() { return Some(candidate); } @@ -451,6 +461,11 @@ mod tests { info.host_input_mapping = vec!["Cross=J".to_string(), "Circle=L".to_string()]; let args = build_args(&info, None, &[], 0.0); + // `--game` is built with `Path::join`, so its separator is the host's + // (`\` on Windows). That is the correct thing to hand the emulator on + // each platform; only this literal expectation is POSIX-shaped, so + // normalize rather than assert one platform's spelling everywhere. + let expected_game = Path::new("/games/Astro").join("eboot.bin").to_string_lossy().to_string(); assert_eq!( args, vec![ @@ -471,7 +486,7 @@ mod tests { "--spirv-debug-printf", "false", "--keymap", "Cross=J", "--keymap", "Circle=L", - "--game", "/games/Astro/eboot.bin", + "--game", expected_game.as_str(), ] ); } diff --git a/src/launcher-tauri/src-tauri/src/lib.rs b/src/launcher-tauri/src-tauri/src/lib.rs index c5d4cfc75..753337a89 100644 --- a/src/launcher-tauri/src-tauri/src/lib.rs +++ b/src/launcher-tauri/src-tauri/src/lib.rs @@ -168,8 +168,15 @@ fn run_game( app_data_dir: app_data_dir(&app), game_path: info.game_path.clone(), }; - supervisor::spawn(&spec).map_err(|e| e.to_string())?; + // Record the start *before* handing off. Both processes do a + // read-modify-write of the same playtime.json, and the supervisor's + // matching `record_stop` fires the moment the emulator exits -- + // which can be almost immediately, if the game crashes on launch. + // Writing the start first means the supervisor always reads a file + // that already has this session's `play_count` bump in it, instead + // of racing this process and losing whichever write lands second. let _ = playtime::record_start(&app_data_dir(&app), &info.game_path); + supervisor::spawn(&spec).map_err(|e| e.to_string())?; app.exit(0); return Ok(()); } @@ -655,6 +662,7 @@ pub fn run() { poll_gamepad_state, list_audio_sinks, set_audio_output_sink, + bluetooth::bluetooth_adapter_state, bluetooth::list_bluetooth_devices, bluetooth::scan_bluetooth_devices, bluetooth::pair_bluetooth_device, diff --git a/src/launcher-tauri/src-tauri/src/qsettings.rs b/src/launcher-tauri/src-tauri/src/qsettings.rs index 08f6feefb..5e7a33530 100644 --- a/src/launcher-tauri/src-tauri/src/qsettings.rs +++ b/src/launcher-tauri/src-tauri/src/qsettings.rs @@ -83,6 +83,16 @@ impl IniDocument { self.0.entry(section.to_string()).or_default().insert(key.to_string(), raw_value); } + /// Drop a single key, leaving the rest of its section alone. Needed for + /// settings that are meant to be *absent* rather than written as a + /// falsy value, so that a Kyty.ini the Qt launcher wrote round-trips + /// through this one byte-for-byte. + pub fn remove(&mut self, section: &str, key: &str) { + if let Some(entries) = self.0.get_mut(section) { + entries.remove(key); + } + } + pub fn remove_section(&mut self, section: &str) { self.0.remove(section); } diff --git a/src/launcher-tauri/src-tauri/src/supervisor.rs b/src/launcher-tauri/src-tauri/src/supervisor.rs index 0a4eb623d..78c7d6cab 100644 --- a/src/launcher-tauri/src-tauri/src/supervisor.rs +++ b/src/launcher-tauri/src-tauri/src/supervisor.rs @@ -21,6 +21,7 @@ //! relaunches a fresh instance of the real launcher, and exits. use serde::{Deserialize, Serialize}; +use std::io::Write; use std::path::PathBuf; use std::process::{Command, Stdio}; @@ -45,8 +46,25 @@ pub struct SupervisedLaunch { /// returns `Ok`, so nothing here can depend on the current process still /// being alive afterward. pub fn spawn(spec: &SupervisedLaunch) -> std::io::Result<()> { - let spec_path = std::env::temp_dir().join(format!("kyty-supervise-{}.json", std::process::id())); - std::fs::write(&spec_path, serde_json::to_string(spec)?)?; + // The spec names an executable and its arguments, and the supervisor + // runs whatever it finds there -- so writing it to a predictable path + // in a world-writable /tmp would be handing any other local account a + // way to pre-create that path (as a symlink, to redirect this write; or + // as a file, to choose the binary that then gets executed). `tempfile` + // creates with O_EXCL under a random name, mode 0600 on Unix, which + // closes both: the create fails outright if anything is already there, + // and nothing but this user can read or replace it afterwards. + let mut file = tempfile::Builder::new() + .prefix("kyty-supervise-") + .suffix(".json") + .tempfile()?; + file.write_all(serde_json::to_string(spec)?.as_bytes())?; + file.flush()?; + + // The supervisor is a separate process that outlives this one, so the + // file has to survive this `TempFile` being dropped; the supervisor + // deletes it itself as soon as it has read it. + let spec_path = file.into_temp_path().keep().map_err(|e| e.error)?; let exe = std::env::current_exe()?; Command::new(exe).arg(SUPERVISE_FLAG).arg(&spec_path).spawn()?; diff --git a/src/launcher-tauri/src/components/FolderBrowserModal.tsx b/src/launcher-tauri/src/components/FolderBrowserModal.tsx index 14a0f00ab..06283897a 100644 --- a/src/launcher-tauri/src/components/FolderBrowserModal.tsx +++ b/src/launcher-tauri/src/components/FolderBrowserModal.tsx @@ -46,8 +46,8 @@ export function FolderBrowserModal({ @@ -70,7 +70,7 @@ export function FolderBrowserModal({ textAlign: "right", }} > - {result?.path ?? "…"} + {result ? (result.isVirtual ? "This PC" : result.path) : "…"} diff --git a/src/launcher-tauri/src/i18n/locales/en.ts b/src/launcher-tauri/src/i18n/locales/en.ts index ff7804b38..c9199c16d 100644 --- a/src/launcher-tauri/src/i18n/locales/en.ts +++ b/src/launcher-tauri/src/i18n/locales/en.ts @@ -174,6 +174,8 @@ export interface Catalog { scan: string; scanning: string; noDevices: string; + noAdapter: string; + poweredOff: string; connect: string; disconnect: string; forget: string; @@ -510,6 +512,8 @@ const en: Catalog = { scan: "Scan for devices", scanning: "Scanning…", noDevices: "No devices found yet.", + noAdapter: "No Bluetooth adapter was found on this system.", + poweredOff: "Bluetooth is switched off. Turn it on in your system settings, then reopen this page.", connect: "Connect", disconnect: "Disconnect", forget: "Forget", diff --git a/src/launcher-tauri/src/types.ts b/src/launcher-tauri/src/types.ts index d8492f956..9240568a8 100644 --- a/src/launcher-tauri/src/types.ts +++ b/src/launcher-tauri/src/types.ts @@ -92,6 +92,9 @@ export interface BrowseResult { parent: string | null; home: string; entries: BrowseEntry[]; + /** True for the Windows drive list, which is not a real directory and so + * cannot itself be picked. */ + isVirtual: boolean; } export interface PatchEntry { diff --git a/src/launcher-tauri/src/views/Settings.tsx b/src/launcher-tauri/src/views/Settings.tsx index 6f99b190a..3a03c6d5d 100644 --- a/src/launcher-tauri/src/views/Settings.tsx +++ b/src/launcher-tauri/src/views/Settings.tsx @@ -378,6 +378,11 @@ interface BtDevice { connected: boolean; } +interface BtAdapter { + present: boolean; + powered: boolean; +} + /** Pairing has no capture-overlay equivalent -- there is no button chord to * catch, so this is a plain list + async action buttons, the same shape as * FoldersCategory just above. Loading `list_bluetooth_devices` on mount @@ -390,6 +395,9 @@ function BluetoothCategory() { const [scanning, setScanning] = useState(false); const [busyAddress, setBusyAddress] = useState(null); const [loadError, setLoadError] = useState(false); + // `null` while the first adapter query is still in flight, so the panel + // does not flash "no Bluetooth adapter" before it knows either way. + const [adapter, setAdapter] = useState(null); const refresh = async () => { try { @@ -400,8 +408,19 @@ function BluetoothCategory() { } }; + const refreshAdapter = async () => { + try { + setAdapter(await invoke("bluetooth_adapter_state")); + } catch { + setAdapter({ present: false, powered: false }); + } + }; + useEffect(() => { - void refresh(); + void (async () => { + await refreshAdapter(); + await refresh(); + })(); }, []); const scan = async () => { @@ -491,12 +510,29 @@ function BluetoothCategory() {

{t("settings.bluetooth.description")}

-
- {loadError &&

{t("settings.bluetooth.noDevices")}

} + {/* An adapter that is missing and one that is switched off both yield + an empty device list, so say which it is rather than leaving the + user to guess why scanning finds nothing. */} + {adapter !== null && !adapter.present && ( +

{t("settings.bluetooth.noAdapter")}

+ )} + {adapter?.present && !adapter.powered && ( +

{t("settings.bluetooth.poweredOff")}

+ )} + + {loadError && adapter?.powered && ( +

{t("settings.bluetooth.noDevices")}

+ )} {paired.length > 0 && ( <> From f05361528e2718133fd3ff8c3ddb1b0ecb1a12ca Mon Sep 17 00:00:00 2001 From: BlackCAT <52720489+psnwd@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:19:09 +0530 Subject: [PATCH 2/3] launcher-tauri: keep session logs and fix navigation snags Backing out of a game opened from Home dumped the user in the Library grid. The detail pane lives inside Library, so "up one level" meant the grid -- right when the grid is where you came from, wrong when Home's "View details" jumped straight past it. Track which view opened the pane and return there. handleBack now reads `view` directly rather than calling setSelectedGamePath inside a setView updater, which fired a side effect during the update. Returning to Home also landed on the TopBar's Search icon. FocusNav remembers each view's last focused element, and the TopBar is in every view, so leaving Home through its Search icon recorded that icon as Home's focus for good. Mark the bar as chrome and skip remembering it. Game folders reached the emulator as `\?\F:\Games`: scanner canonicalizes each one to dedupe spellings, and on Windows that returns the verbatim form, which then became the `--game` argument, the key in playtime.json and the path shown in the UI. Trim it with the same helper the folder browser uses. The console cleared itself on every launch, so the output explaining why a run just died was destroyed by the next attempt. Keep it, separated per run, until the user clears it or closes the app. Session logs are now written to `logs/session-YYYYMMDD-HHMMSS.log`, one per launch, headed with the game and full argv, newest 20 kept. Both launch paths feed it: the in-app reader tees each line it streams, and the supervisor redirects the child's output into it. The Console page can open the folder, so reporting a crash means attaching a file. Timestamps are UTC -- a filename that shifts with the reader's timezone is not stable to sort or cite. That folder is `/Kyty/Launcher/` rather than Tauri's `app_data_dir()`, which names it after the bundle identifier (`io.github.kytyps5.launcher`). Fine for a folder nobody opens; not fine now that the app tells people to fetch a log out of it. Kyty.ini already lives in `/Kyty/`. Existing data is moved across on first run. The emulator's own console window is suppressed on Windows, with no setting: its output is already captured twice over, and a user who wants a live terminal picks the External terminal launch mode, which is what that mode is for. Also: text selection off outside real text entry (this is a gamepad dashboard, and dragging across a game title only ever left a stray highlight), and the 1px hover underline on toggles is gone -- row-based toggles carry no inline caption, so it rendered as a dash floating under the switch. --- src/launcher-tauri/src-tauri/src/browse.rs | 16 +- src/launcher-tauri/src-tauri/src/emulator.rs | 44 ++++- src/launcher-tauri/src-tauri/src/lib.rs | 62 +++++- src/launcher-tauri/src-tauri/src/logs.rs | 184 ++++++++++++++++++ src/launcher-tauri/src-tauri/src/scanner.rs | 9 +- .../src-tauri/src/supervisor.rs | 1 + src/launcher-tauri/src/App.tsx | 33 +++- .../src/components/Toggle.module.css | 26 +-- src/launcher-tauri/src/i18n/locales/en.ts | 4 + src/launcher-tauri/src/nav/FocusNav.tsx | 9 +- src/launcher-tauri/src/shell/TopBar.tsx | 2 +- src/launcher-tauri/src/store/run.ts | 13 +- src/launcher-tauri/src/theme.css | 16 ++ src/launcher-tauri/src/views/Logs.tsx | 20 +- 14 files changed, 391 insertions(+), 48 deletions(-) create mode 100644 src/launcher-tauri/src-tauri/src/logs.rs diff --git a/src/launcher-tauri/src-tauri/src/browse.rs b/src/launcher-tauri/src-tauri/src/browse.rs index 223a79f7c..842a35df4 100644 --- a/src/launcher-tauri/src-tauri/src/browse.rs +++ b/src/launcher-tauri/src-tauri/src/browse.rs @@ -37,12 +37,16 @@ pub struct BrowseResult { #[cfg(windows)] pub const DRIVE_ROOT: &str = "::drives"; -/// Paths for display and for navigating back in. `canonicalize` on Windows -/// hands back the verbatim `\\?\C:\Users\you` form, which is correct but is -/// not what anyone recognizes as their own path, so trim it back to the -/// ordinary spelling. UNC paths canonicalize to `\\?\UNC\server\share` and -/// come back as `\\server\share`. -fn display_path(path: &Path) -> String { +/// Trim a canonicalized path back to its ordinary spelling. `canonicalize` +/// on Windows hands back the verbatim `\\?\C:\Users\you` form, which is +/// correct but is not what anyone recognizes as their own path, and is not +/// a spelling every program accepts as an argument. UNC paths canonicalize +/// to `\\?\UNC\server\share` and come back as `\\server\share`. +/// +/// Shared with scanner.rs, which canonicalizes game folders and would +/// otherwise carry the prefix into `--game`, into playtime.json's keys and +/// into the UI. +pub fn display_path(path: &Path) -> String { let text = path.to_string_lossy().to_string(); #[cfg(windows)] { diff --git a/src/launcher-tauri/src-tauri/src/emulator.rs b/src/launcher-tauri/src-tauri/src/emulator.rs index 979c19b9f..8c71af43a 100644 --- a/src/launcher-tauri/src-tauri/src/emulator.rs +++ b/src/launcher-tauri/src-tauri/src/emulator.rs @@ -349,6 +349,30 @@ pub fn launch_external_terminal( Ok(()) } +/// `kyty_emulator` is a console subsystem program, so launching it from a +/// windowed process makes Windows allocate a console window for it. Every +/// line it prints is already captured -- streamed to the in-app console and +/// teed to the session log, or redirected wholesale to the session log +/// under the supervisor -- so that window shows nothing the launcher has +/// not already got, and for anyone just playing a game it is pure noise. +/// +/// There is no setting for it: a user who wants a live terminal picks +/// "External terminal" as the launch mode, which is what that mode is for. +/// No effect off Windows, where a GUI-launched child inherits no terminal +/// to begin with. +pub fn hide_console(command: &mut Command) { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + #[cfg(not(windows))] + { + let _ = command; + } +} + pub struct RunState { pub child: Mutex>, } @@ -382,26 +406,40 @@ pub fn spawn_in_app( interpreter: &Path, args: &[String], working_dir: &Path, + session_log: Option, ) -> std::io::Result<()> { - let mut child = Command::new(interpreter) + let mut command = Command::new(interpreter); + command .args(args) .current_dir(working_dir) .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn()?; + .stderr(Stdio::piped()); + hide_console(&mut command); + let mut child = command.spawn()?; + // Each reader tees to two places: the in-app console, which is live but + // lost when the app closes, and the session file, which is what a user + // can actually attach to a bug report. if let Some(stdout) = child.stdout.take() { let app = app.clone(); + let log = session_log.clone(); std::thread::spawn(move || { for line in BufReader::new(stdout).lines().map_while(Result::ok) { + if let Some(log) = log.as_ref() { + log.write_line("stdout", &line); + } let _ = app.emit("emulator-log", LogLine { stream: "stdout", line }); } }); } if let Some(stderr) = child.stderr.take() { let app = app.clone(); + let log = session_log.clone(); std::thread::spawn(move || { for line in BufReader::new(stderr).lines().map_while(Result::ok) { + if let Some(log) = log.as_ref() { + log.write_line("stderr", &line); + } let _ = app.emit("emulator-log", LogLine { stream: "stderr", line }); } }); diff --git a/src/launcher-tauri/src-tauri/src/lib.rs b/src/launcher-tauri/src-tauri/src/lib.rs index 753337a89..33ae55206 100644 --- a/src/launcher-tauri/src-tauri/src/lib.rs +++ b/src/launcher-tauri/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod compatibility; mod config; mod emulator; mod first_seen; +mod logs; mod gamepad; mod patches; mod playtime; @@ -67,8 +68,38 @@ fn emulator_binary(app: &tauri::AppHandle, state: &AppState) -> Result/Kyty/`, so this sits beside +/// it as `/Kyty/Launcher/`. +/// +/// Anything already written under the old identifier folder is moved across +/// the first time this runs, so upgrading keeps play counts and settings. fn app_data_dir(app: &tauri::AppHandle) -> PathBuf { - app.path().app_data_dir().unwrap_or_else(|_| PathBuf::from(".")) + let dir = dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("Kyty") + .join("Launcher"); + + if !dir.exists() { + if let Ok(legacy) = app.path().app_data_dir() { + if legacy.is_dir() { + if let Some(parent) = dir.parent() { + let _ = std::fs::create_dir_all(parent); + } + // A rename across the same volume is atomic and cheap. If it + // fails (different volume, file in use), fall through and + // just start fresh in the new location rather than failing. + let _ = std::fs::rename(&legacy, &dir); + } + } + } + dir } // ---- Settings (Kyty.ini) -------------------------------------------------- @@ -164,7 +195,8 @@ fn run_game( interpreter: binary, args, working_dir: dir, - log_path: app_data_dir(&app).join("emulator-session.log"), + log_path: logs::new_session_path(&app_data_dir(&app)) + .unwrap_or_else(|| app_data_dir(&app).join("emulator-session.log")), app_data_dir: app_data_dir(&app), game_path: info.game_path.clone(), }; @@ -183,8 +215,20 @@ fn run_game( let result = match launcher_prefs.launch_mode { prefs::LaunchMode::InApp => { - emulator::spawn_in_app(app.clone(), state.run_state.clone(), &binary, &args, &dir) - .map_err(|e| e.to_string()) + let session_log = logs::new_session_path(&app_data_dir(&app)) + .and_then(|path| logs::SessionLog::create(&path)); + if let Some(log) = session_log.as_ref() { + log.write_header(&info.game_path, &args); + } + emulator::spawn_in_app( + app.clone(), + state.run_state.clone(), + &binary, + &args, + &dir, + session_log, + ) + .map_err(|e| e.to_string()) } prefs::LaunchMode::Terminal => { emulator::launch_external_terminal(&binary, &args, &dir).map_err(|e| e.to_string()) @@ -197,6 +241,15 @@ fn run_game( result } +/// Absolute path of the session-log folder, for the Console page's "open +/// logs folder" action -- the thing a user attaches to a bug report. +#[tauri::command] +fn get_logs_dir(app: tauri::AppHandle) -> String { + let dir = logs::logs_dir(&app_data_dir(&app)); + let _ = std::fs::create_dir_all(&dir); + dir.to_string_lossy().to_string() +} + #[tauri::command] fn get_play_history(app: tauri::AppHandle) -> playtime::PlayHistory { playtime::load(&app_data_dir(&app)) @@ -637,6 +690,7 @@ pub fn run() { stop_game, is_game_running, is_resumed_launch, + get_logs_dir, get_play_history, get_library_stats, record_play_stop, diff --git a/src/launcher-tauri/src-tauri/src/logs.rs b/src/launcher-tauri/src-tauri/src/logs.rs new file mode 100644 index 000000000..4e4c29fa9 --- /dev/null +++ b/src/launcher-tauri/src-tauri/src/logs.rs @@ -0,0 +1,184 @@ +//! On-disk session logs, so a crash can be reported with a file attached +//! rather than a description from memory. +//! +//! Every launch gets its own `logs/session-YYYYMMDD-HHMMSS.log` under the +//! app data dir, written by whichever path actually started the emulator: +//! `emulator::spawn_in_app` tees the lines it is already streaming to the +//! in-app console, and `supervisor::run` redirects the child's stdout and +//! stderr straight into the file (there is no console left to stream to +//! once auto-close has taken the GUI away). Old files are pruned so this +//! never grows without bound. + +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +/// How many session files to keep. Enough to cover "it broke a few runs +/// ago, which one was it", without turning into an unbounded pile. +const KEEP_SESSIONS: usize = 20; + +const DIR_NAME: &str = "logs"; + +pub fn logs_dir(app_data_dir: &Path) -> PathBuf { + app_data_dir.join(DIR_NAME) +} + +/// `YYYYMMDD-HHMMSS` in UTC, from the civil-from-days algorithm rather than +/// a date crate: this is the only place the app formats a date, and one +/// filename is not worth a dependency (or the build-time cost of one). +fn timestamp(unix_seconds: u64) -> String { + let days = (unix_seconds / 86_400) as i64; + let seconds_of_day = unix_seconds % 86_400; + + // Howard Hinnant's civil_from_days, shifted to an era starting 0000-03-01. + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + + format!( + "{:04}{:02}{:02}-{:02}{:02}{:02}", + y, + m, + d, + seconds_of_day / 3600, + (seconds_of_day % 3600) / 60, + seconds_of_day % 60 + ) +} + +fn now_unix_seconds() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Delete all but the newest `KEEP_SESSIONS` files. Sorted by name, which +/// is why the timestamp is big-endian: lexical order is chronological. +fn prune(dir: &Path) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + let mut sessions: Vec = entries + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("session-") && n.ends_with(".log")) + }) + .collect(); + if sessions.len() <= KEEP_SESSIONS { + return; + } + sessions.sort(); + for stale in &sessions[..sessions.len() - KEEP_SESSIONS] { + let _ = std::fs::remove_file(stale); + } +} + +/// Path for a new session's log file, with the directory created and older +/// sessions pruned. Returns `None` only if the directory cannot be made, in +/// which case the caller carries on without a log rather than failing the +/// launch: not being able to write a log is never a reason not to play. +pub fn new_session_path(app_data_dir: &Path) -> Option { + let dir = logs_dir(app_data_dir); + std::fs::create_dir_all(&dir).ok()?; + prune(&dir); + Some(dir.join(format!("session-{}.log", timestamp(now_unix_seconds())))) +} + +/// A session log opened for writing, shared between the stdout and stderr +/// reader threads. Every write is best-effort: a full disk should not take +/// the running game down with it. +#[derive(Clone)] +pub struct SessionLog(Arc>); + +impl SessionLog { + pub fn create(path: &Path) -> Option { + File::create(path).ok().map(|f| SessionLog(Arc::new(Mutex::new(f)))) + } + + pub fn write_line(&self, stream: &str, line: &str) { + if let Ok(mut file) = self.0.lock() { + // stderr is tagged rather than separated: interleaving order is + // the useful part when diagnosing where a run went wrong. + let _ = if stream == "stderr" { + writeln!(file, "[stderr] {line}") + } else { + writeln!(file, "{line}") + }; + } + } + + pub fn write_header(&self, game_path: &str, args: &[String]) { + if let Ok(mut file) = self.0.lock() { + let _ = writeln!(file, "=== Kyty Launcher session {} ===", timestamp(now_unix_seconds())); + let _ = writeln!(file, "game: {game_path}"); + let _ = writeln!(file, "args: {}", args.join(" ")); + let _ = writeln!(file, "---"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn timestamp_is_sortable_and_correct() { + // All UTC, deliberately: a log filename that shifts with the + // reader's timezone is not a stable thing to sort or cite. + assert_eq!(timestamp(1_789_167_967), "20260911-230607"); + // Epoch itself, and a date before the 2000 leap-year special case. + assert_eq!(timestamp(0), "19700101-000000"); + assert_eq!(timestamp(951_782_400), "20000229-000000"); + } + + #[test] + fn lexical_order_is_chronological() { + let earlier = timestamp(1_789_167_967); + let later = timestamp(1_789_167_968 + 86_400); + assert!(earlier < later); + } + + #[test] + fn prune_keeps_only_the_newest_sessions() { + let dir = tempfile::tempdir().unwrap(); + for i in 0..KEEP_SESSIONS + 5 { + std::fs::write(dir.path().join(format!("session-2026090{i:02}-000000.log")), b"x").unwrap(); + } + // An unrelated file must survive: pruning only owns what it named. + std::fs::write(dir.path().join("notes.txt"), b"x").unwrap(); + + prune(dir.path()); + + let remaining: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + assert_eq!(remaining.iter().filter(|n| n.starts_with("session-")).count(), KEEP_SESSIONS); + assert!(remaining.iter().any(|n| n == "notes.txt")); + } + + #[test] + fn session_log_writes_lines_and_tags_stderr() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("s.log"); + let log = SessionLog::create(&path).unwrap(); + log.write_line("stdout", "hello"); + log.write_line("stderr", "boom"); + drop(log); + + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("hello")); + assert!(text.contains("[stderr] boom")); + } +} diff --git a/src/launcher-tauri/src-tauri/src/scanner.rs b/src/launcher-tauri/src-tauri/src/scanner.rs index a1005d88f..9694c2e3e 100644 --- a/src/launcher-tauri/src-tauri/src/scanner.rs +++ b/src/launcher-tauri/src-tauri/src/scanner.rs @@ -150,8 +150,15 @@ fn existing_asset(game_dir: &Path, relative: &str) -> Option { path.is_file().then(|| path.to_string_lossy().to_string()) } +/// `canonicalize` resolves symlinks and relative segments, which is what +/// makes two spellings of the same game folder dedupe to one entry. On +/// Windows it also returns the verbatim `\\?\F:\Games` form, and that +/// spelling then reaches the emulator as `--game`, becomes the key in +/// playtime.json and shows up in the UI, so trim it straight back off. fn normalize_dir(dir: &Path) -> PathBuf { - dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()) + let canonical = dir.canonicalize(); + let resolved = canonical.as_deref().unwrap_or(dir); + PathBuf::from(crate::browse::display_path(resolved)) } /// Scan every configured game folder for `eboot.bin`, mirroring the diff --git a/src/launcher-tauri/src-tauri/src/supervisor.rs b/src/launcher-tauri/src-tauri/src/supervisor.rs index 78c7d6cab..f96b60c4f 100644 --- a/src/launcher-tauri/src-tauri/src/supervisor.rs +++ b/src/launcher-tauri/src-tauri/src/supervisor.rs @@ -82,6 +82,7 @@ pub fn run(spec_path: &str) { let log_file = std::fs::File::create(&spec.log_path).ok(); let mut cmd = Command::new(&spec.interpreter); cmd.args(&spec.args).current_dir(&spec.working_dir); + crate::emulator::hide_console(&mut cmd); if let Some(out) = log_file.as_ref().and_then(|f| f.try_clone().ok()) { cmd.stdout(Stdio::from(out)); } diff --git a/src/launcher-tauri/src/App.tsx b/src/launcher-tauri/src/App.tsx index 860d1cdf4..ac2a73d79 100644 --- a/src/launcher-tauri/src/App.tsx +++ b/src/launcher-tauri/src/App.tsx @@ -31,6 +31,11 @@ export type ViewId = "home" | "library" | "settings" | "logs" | "profile" | "exi export default function App() { const [view, setView] = useState("home"); const [selectedGamePath, setSelectedGamePath] = useState(null); + // Which view the game-detail pane was opened from. The pane itself always + // lives inside Library, but Home's "View details" jumps straight into it + // without the user ever seeing the Library grid -- so backing out of it + // has to return to Home, not strand them in a grid they never opened. + const [detailOrigin, setDetailOrigin] = useState("library"); const [dataReady, setDataReady] = useState(false); // null = not yet known. BootController reads this only once, at mount // (its own useState initializer), so nothing renders below until this @@ -71,14 +76,15 @@ export default function App() { // leaving the view at all, mirroring the real PS5's own "circle backs out // one level at a time" behavior. const handleBack = useCallback(() => { - setView((current) => { - if (current === "library" && selectedGamePath) { - setSelectedGamePath(null); - return current; - } - return current === "home" ? current : "home"; - }); - }, [selectedGamePath]); + if (view === "library" && selectedGamePath) { + setSelectedGamePath(null); + // Opened from Home, so the grid was never a step the user took -- + // going "up one level" from here means Home itself. + if (detailOrigin === "home") setView("home"); + return; + } + if (view !== "home") setView("home"); + }, [view, selectedGamePath, detailOrigin]); // "menu" (was TRIANGLE): opens the control center overlay over the live // scene (ps5_tauri_ui_guidelines/03's "control-center / quick-menu @@ -117,12 +123,21 @@ export default function App() { onNavigate={setView} onViewDetails={(path) => { setSelectedGamePath(path); + setDetailOrigin("home"); setView("library"); }} /> )} {view === "library" && ( - + { + // Picked out of the grid, so the grid is where back returns. + setDetailOrigin("library"); + setSelectedGamePath(path); + }} + onNavigate={setView} + /> )} {view === "settings" && } {view === "logs" && } diff --git a/src/launcher-tauri/src/components/Toggle.module.css b/src/launcher-tauri/src/components/Toggle.module.css index 78c637382..5abdfb0fc 100644 --- a/src/launcher-tauri/src/components/Toggle.module.css +++ b/src/launcher-tauri/src/components/Toggle.module.css @@ -9,22 +9,6 @@ border-radius: var(--radius-sm); } -/* Hover indicator: a single 1px line under the whole row, offset 3px below - it. Positioned off the row's own bottom edge (top: 100%) rather than a - border, so it doesn't consume layout space or shift the row when it - appears. */ -.label::after { - content: ""; - position: absolute; - left: 0; - right: 0; - top: 100%; - margin-top: 3px; - height: 1px; - background: transparent; - transition: background var(--t-hover) var(--ease-ui); -} - .track { width: 38px; height: 22px; @@ -64,11 +48,15 @@ box-shadow: none; } -/* Hover: the single underline is the whole effect, guarded against gamepad +/* Hover: the track itself brightens. There used to be a 1px underline under + the whole row as well, but a row-based Toggle carries no inline caption + (Settings.tsx passes only ariaLabel), so the label box is just the 38px + track and that line read as a stray dash floating under the switch. The + track is the control; lighting it is enough. Guarded against gamepad input the same way every other hover rule in this pass is (see Settings.module.css's .railItem:hover comment for why). */ -:root:not([data-gamepad-active="true"]) .label:hover::after { - background: var(--text-primary); +:root:not([data-gamepad-active="true"]) .label:hover .track { + border-color: var(--text-primary); } /* Focused (gamepad/keyboard): same quiet-fill language as everywhere else diff --git a/src/launcher-tauri/src/i18n/locales/en.ts b/src/launcher-tauri/src/i18n/locales/en.ts index c9199c16d..bd10d820d 100644 --- a/src/launcher-tauri/src/i18n/locales/en.ts +++ b/src/launcher-tauri/src/i18n/locales/en.ts @@ -296,6 +296,8 @@ export interface Catalog { title: string; stopButton: string; copyLogs: string; + openLogsFolder: string; + clear: string; copiedToClipboard: string; lastRunExited: string; notRunning: string; @@ -654,6 +656,8 @@ const en: Catalog = { title: "Emulator console", stopButton: "Stop", copyLogs: "Copy logs", + openLogsFolder: "Open logs folder", + clear: "Clear", copiedToClipboard: "Logs copied to clipboard", lastRunExited: "Last run exited with code {code}", notRunning: "Not running", diff --git a/src/launcher-tauri/src/nav/FocusNav.tsx b/src/launcher-tauri/src/nav/FocusNav.tsx index ec73c8b4a..160987982 100644 --- a/src/launcher-tauri/src/nav/FocusNav.tsx +++ b/src/launcher-tauri/src/nav/FocusNav.tsx @@ -633,8 +633,13 @@ export function FocusNavProvider({ columnAnchorXRef.current = null; // Remember the outgoing view's focus (by its own declared key) before - // clearing, so returning to it later can restore it. - const outgoingKey = focusedRef.current?.dataset.focusKey; + // clearing, so returning to it later can restore it. Controls inside + // global chrome (the TopBar) are excluded: they sit in every view + // rather than belonging to the one being left, so recording one would + // make the chrome button used to navigate away become that view's + // remembered focus for good. + const outgoing = focusedRef.current; + const outgoingKey = outgoing?.closest("[data-focus-chrome]") ? undefined : outgoing?.dataset.focusKey; if (outgoingKey) focusMemoRef.current.set(prevResetKeyRef.current, outgoingKey); if (focusedRef.current) focusedRef.current.classList.remove("ps-focused"); diff --git a/src/launcher-tauri/src/shell/TopBar.tsx b/src/launcher-tauri/src/shell/TopBar.tsx index e0b3a543a..5e7f38663 100644 --- a/src/launcher-tauri/src/shell/TopBar.tsx +++ b/src/launcher-tauri/src/shell/TopBar.tsx @@ -34,7 +34,7 @@ export function TopBar({ view, onNavigate, onBack }: { view: ViewId; onNavigate: }; return ( -
+
{/* No brand mark here anymore -- it's the OS app icon now (see src-tauri/icons), not a clickable in-window control. Home shows nothing in this slot (there's nowhere further "back" to go); every diff --git a/src/launcher-tauri/src/store/run.ts b/src/launcher-tauri/src/store/run.ts index 0575f9f19..3b02f0514 100644 --- a/src/launcher-tauri/src/store/run.ts +++ b/src/launcher-tauri/src/store/run.ts @@ -42,9 +42,20 @@ export function ensureRunListeners(): Promise { return listenersReady; } +/** Wipes the console. Only ever called from the Console page's own Clear + * button -- starting a game deliberately does not, so output from the run + * that just crashed is still there when the launcher comes back. */ +export function clearLogs(): void { + logLinesStore.set([]); +} + export async function runGame(info: Configuration, titleId: string): Promise { await ensureRunListeners(); - logLinesStore.set([]); + // A separator rather than a wipe: the previous run's output is usually + // the reason the user is looking at this page at all. + if (logLinesStore.get().length > 0) { + logLinesStore.update((lines) => [...lines, { stream: "stdout", line: "" }, { stream: "stdout", line: `--- ${info.name || info.gamePath} ---` }]); + } lastExitCodeStore.set(null); runningGameStore.set(info.gamePath); isRunningStore.set(true); diff --git a/src/launcher-tauri/src/theme.css b/src/launcher-tauri/src/theme.css index e5494aa4d..7fa7c8d9f 100644 --- a/src/launcher-tauri/src/theme.css +++ b/src/launcher-tauri/src/theme.css @@ -196,6 +196,22 @@ body { font-family: var(--font-sans); -webkit-font-smoothing: antialiased; position: relative; + /* This is a dashboard driven by a gamepad, not a document: dragging + across a game title or a settings label only ever produced a stray blue + highlight that then had to be clicked away, and a double-click on a + tile selected its caption. Real text entry is exempted below. */ + user-select: none; + -webkit-user-select: none; +} + +/* Anywhere the user genuinely types or copies: the config fields, the + emulator log pane, anything explicitly marked selectable. */ +input, +textarea, +[contenteditable="true"], +[data-selectable] { + user-select: text; + -webkit-user-select: text; } button, diff --git a/src/launcher-tauri/src/views/Logs.tsx b/src/launcher-tauri/src/views/Logs.tsx index ca345bd04..93d68447f 100644 --- a/src/launcher-tauri/src/views/Logs.tsx +++ b/src/launcher-tauri/src/views/Logs.tsx @@ -1,7 +1,9 @@ import { useEffect, useRef, useState } from "react"; -import { Copy, Terminal } from "lucide-react"; +import { Copy, FolderOpen, Terminal, Trash2 } from "lucide-react"; +import { invoke } from "@tauri-apps/api/core"; +import { openPath } from "@tauri-apps/plugin-opener"; import { useStore } from "../store/observable"; -import { isRunningStore, lastExitCodeStore, logLinesStore, runningGameStore, stopGame } from "../store/run"; +import { clearLogs, isRunningStore, lastExitCodeStore, logLinesStore, runningGameStore, stopGame } from "../store/run"; import { gamesStore } from "../store/library"; import { GlassPanel } from "../components/GlassPanel"; import { useT } from "../i18n"; @@ -24,6 +26,11 @@ export function LogsView() { setTimeout(() => setCopied(false), 2000); }; + const openLogsFolder = async () => { + const dir = await invoke("get_logs_dir"); + await openPath(dir); + }; + const scrollRef = useRef(null); useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); @@ -79,6 +86,14 @@ export function LogsView() { + {/* The session files, which outlive this pane -- what a user + attaches to a bug report rather than retyping an error. */} + + {running && (
- {sinks.length === 0 &&

{t("settings.audio.noDevicesFound")}

} + +
+ {t("settings.audio.inputDevice")} +
+ void applyDevice(setInputSource(v || null))} + options={inputOptions} + /> +
+
+ + {sinks.length === 0 && sources.length === 0 &&

{t("settings.audio.noDevicesFound")}

} + {/* Stated up front rather than only after a failed pick: the devices + listed above are real, but on Windows choosing one cannot move a + game's audio. */} + {!selectable &&

{t("settings.audio.routingUnsupported")}

} + {selectError &&

{selectError}

} ); } @@ -671,6 +719,10 @@ function ProfileCategory() { const [newName, setNewName] = useState(""); const [renamingId, setRenamingId] = useState(null); const [renameDraft, setRenameDraft] = useState(""); + // Deleting a profile cannot be undone, so it asks first -- in place, the + // same two-step GameDetail's remove-save-data uses, rather than firing on + // the first click of a trash icon. + const [confirmDeleteId, setConfirmDeleteId] = useState(null); const submitNew = () => { const name = newName.trim(); @@ -691,6 +743,12 @@ function ProfileCategory() { <>

{t("profile.description")}

+ {/* Three unlabelled groups ran together before: the profile list, the + field that creates one, and the preferences that belong to + whichever profile is active. The headings say which settings follow + the active profile rather than the app. */} +

{t("profile.profilesHeading")}

+ {profiles.map((p: LauncherProfile) => { const isActive = p.id === active?.id; return ( @@ -700,31 +758,19 @@ function ProfileCategory() { className={isActive ? styles.profileRowActive : styles.profileRow} onClick={() => !isActive && void setActiveProfile(p.id)} > -
- {p.name.slice(0, 1).toUpperCase()} -
+
{p.name.slice(0, 1).toUpperCase()}
{renamingId === p.id ? ( setRenameDraft(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && submitRename(p.id)} + onKeyDown={(e) => { + if (e.key === "Enter") submitRename(p.id); + // Escape abandons the edit. Without this the only way + // out of a rename was to commit it. + if (e.key === "Escape") setRenamingId(null); + }} onBlur={() => submitRename(p.id)} style={{ maxWidth: 220 }} /> @@ -737,46 +783,69 @@ function ProfileCategory() { )}
-
- - -
+ {confirmDeleteId === p.id ? ( +
+ {t("profile.confirmDelete", { name: p.name })} + + +
+ ) : ( +
+ + +
+ )} ); })} -
+
setNewName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && submitNew()} - style={{ flex: 1, maxWidth: 260 }} /> -
+ {active &&

{t("profile.preferencesHeading", { name: active.name })}

} + {active && (
{t("profile.language")}