diff --git a/src/launcher-tauri/src-tauri/Cargo.lock b/src/launcher-tauri/src-tauri/Cargo.lock index 7a59bf74de..039a2de177 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 f1ee70f6e3..b42b2d611c 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 0000000000..34adc6b7f7 --- /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/audio.rs b/src/launcher-tauri/src-tauri/src/audio.rs index 5f24085e43..7b113eec73 100644 --- a/src/launcher-tauri/src-tauri/src/audio.rs +++ b/src/launcher-tauri/src-tauri/src/audio.rs @@ -1,37 +1,70 @@ -//! Audio output device enumeration and selection, backing -//! src/lib/audioSettings.ts's Settings > Audio category. +//! Audio device enumeration and selection, backing src/lib/audioSettings.ts's +//! Settings > Audio category. Covers both directions: output (what the game +//! plays through) and input (the microphone a game can record from). //! -//! Enumeration shells out to `pactl -f json list sinks`, following the same -//! "shell out directly, no shell plugin" convention as lib.rs's -//! system_color_scheme (`gsettings`) -- PulseAudio/PipeWire's `pactl` is the -//! standard, always-present CLI on any Linux desktop with audio, and its -//! JSON output (`pactl --version` 16+) is trivial to parse without a new -//! dependency. +//! What "selecting" a device can actually mean is not the same on every OS, +//! and the difference is visible in the UI rather than papered over: //! -//! Selection sets `PULSE_SINK` on this process's own environment rather -//! than calling `pactl set-default-sink` (which would change the *system* -//! default for every app, not just this one -- explicitly out of scope, -//! see the owner's "does not touch the OS-wide default sink" decision). -//! Because Tauri runs WebKitGTK in-process on Linux (unlike a multi-process -//! browser), this reaches the launcher's own UI-sound playback directly; -//! kyty_emulator picks it up too, since emulator.rs spawns it as a child -//! process that inherits this process's environment. +//! - Linux: PulseAudio/PipeWire honour `PULSE_SINK` and `PULSE_SOURCE` per +//! process. Setting them on *this* process is enough, because emulator.rs +//! spawns the emulator as a child that inherits this environment. Nothing +//! touches the OS-wide default, so picking a device here never changes +//! what the rest of the desktop plays through. +//! - Windows: devices enumerate fine (WinRT device enumeration), but there +//! is no per-process routing a launcher can apply to a child it did not +//! write. Windows exposes per-app device assignment only through its own +//! Settings UI, or to an app that selects its own endpoint -- which +//! kyty_emulator does not, and which no command-line flag exposes. So the +//! list is real and selection reports plainly that it cannot take effect. +//! +//! Enumeration shells out to `pactl` on Linux rather than taking a +//! dependency, following the same convention as lib.rs's system_color_scheme +//! (`gsettings`): its JSON output is trivial to parse and it is the standard +//! CLI on any Linux desktop with audio. use serde::Serialize; #[derive(Serialize)] -pub struct AudioSink { +pub struct AudioDevice { + /// Stable identifier, and the value handed back to `set_*_device`. pub name: String, + /// What the picker shows. pub description: String, } -/// Best-effort: returns an empty list on any failure (pactl missing, -/// not on PulseAudio/PipeWire, malformed output) rather than an error -- -/// the frontend already treats "no sinks" as a normal, unremarkable state -/// (falls back to just "System default" in the picker). -pub fn list_sinks() -> Vec { +/// Which half of the audio path a call refers to. The two are symmetric +/// everywhere except the environment variable and the `pactl` noun. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Direction { + Output, + Input, +} + +impl Direction { + #[cfg(not(windows))] + fn pactl_noun(self) -> &'static str { + match self { + Direction::Output => "sinks", + Direction::Input => "sources", + } + } + + #[cfg(not(windows))] + fn env_var(self) -> &'static str { + match self { + Direction::Output => "PULSE_SINK", + Direction::Input => "PULSE_SOURCE", + } + } +} + +/// Best-effort everywhere: an empty list is a normal answer (no audio +/// hardware, `pactl` missing, not on PulseAudio), and the frontend already +/// treats it as one by falling back to just "System default". +#[cfg(not(windows))] +pub fn list_devices(direction: Direction) -> Vec { let Ok(output) = std::process::Command::new("pactl") - .args(["-f", "json", "list", "sinks"]) + .args(["-f", "json", "list", direction.pactl_noun()]) .output() else { return Vec::new(); @@ -42,40 +75,132 @@ pub fn list_sinks() -> Vec { let Ok(json) = serde_json::from_slice::(&output.stdout) else { return Vec::new(); }; - let Some(sinks) = json.as_array() else { + let Some(entries) = json.as_array() else { return Vec::new(); }; - sinks + entries .iter() - .filter_map(|sink| { - let name = sink.get("name")?.as_str()?.to_string(); - let description = sink + .filter_map(|entry| { + let name = entry.get("name")?.as_str()?.to_string(); + let description = entry .get("description") .and_then(|d| d.as_str()) .unwrap_or(&name) .to_string(); - Some(AudioSink { name, description }) + Some(AudioDevice { name, description }) }) .collect() } -/// `sink` is validated against `list_sinks()`'s own output before being set -/// as a process environment variable, since it becomes part of a process -/// environment other code (kyty_emulator, launched later) inherits -- -/// this rejects an arbitrary/stale frontend-supplied string rather than -/// setting PULSE_SINK to whatever it happens to be. -pub fn set_output_sink(sink: Option) -> Result<(), String> { - match sink { +#[cfg(windows)] +pub fn list_devices(direction: Direction) -> Vec { + use windows::Devices::Enumeration::{DeviceClass, DeviceInformation}; + + let class = match direction { + Direction::Output => DeviceClass::AudioRender, + Direction::Input => DeviceClass::AudioCapture, + }; + + // WinRT enumeration is async and its handles are not `Send`, so it runs + // to completion on a throwaway single-threaded runtime here rather than + // making every caller async for a list that is read once when a settings + // page opens. + let Ok(runtime) = tokio::runtime::Builder::new_current_thread().enable_all().build() else { + return Vec::new(); + }; + runtime.block_on(async move { + let Ok(operation) = DeviceInformation::FindAllAsyncDeviceClass(class) else { + return Vec::new(); + }; + let Ok(collection) = operation.await else { + return Vec::new(); + }; + collection + .into_iter() + .filter_map(|info| { + // Disabled and unplugged endpoints still enumerate; listing + // one the user cannot actually pick is worse than omitting it. + if !info.IsEnabled().unwrap_or(false) { + return None; + } + let name = info.Id().ok()?.to_string_lossy(); + let description = info + .Name() + .map(|n| n.to_string_lossy()) + .ok() + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| name.clone()); + Some(AudioDevice { name, description }) + }) + .collect() + }) +} + +/// `device` is validated against `list_devices`'s own output before being +/// set, since it becomes part of an environment the emulator inherits -- +/// this rejects an arbitrary or stale frontend-supplied string rather than +/// setting the variable to whatever it happens to be. +#[cfg(not(windows))] +pub fn set_device(direction: Direction, device: Option) -> Result<(), String> { + let variable = direction.env_var(); + match device { None => { - std::env::remove_var("PULSE_SINK"); + std::env::remove_var(variable); Ok(()) } Some(name) => { - if !list_sinks().iter().any(|s| s.name == name) { - return Err(format!("unknown audio sink: {name}")); + if !list_devices(direction).iter().any(|d| d.name == name) { + return Err(format!("unknown audio device: {name}")); } - std::env::set_var("PULSE_SINK", &name); + std::env::set_var(variable, &name); Ok(()) } } } + +/// Windows has no per-process audio routing a launcher can apply to a child +/// process. Clearing the choice is fine (nothing was applied in the first +/// place); choosing one says so rather than silently doing nothing. +#[cfg(windows)] +pub fn set_device(_direction: Direction, device: Option) -> Result<(), String> { + match device { + None => Ok(()), + Some(_) => Err( + "Windows cannot route one app's audio from another. Assign the device to kyty_emulator \ + in Windows Settings > System > Sound > Volume mixer." + .to_string(), + ), + } +} + +/// Whether picking a device here actually changes where audio goes. The +/// Audio page reads this to decide between a working picker and an +/// explanation, instead of offering a control that quietly does nothing. +pub fn selection_is_supported() -> bool { + cfg!(not(windows)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enumeration_never_panics_in_either_direction() { + // Whatever this machine has (or does not have), listing is a + // question with an answer, never an error. + let _ = list_devices(Direction::Output); + let _ = list_devices(Direction::Input); + } + + #[test] + fn clearing_a_device_is_always_accepted() { + assert!(set_device(Direction::Output, None).is_ok()); + assert!(set_device(Direction::Input, None).is_ok()); + } + + #[test] + fn an_unknown_device_is_rejected() { + let result = set_device(Direction::Output, Some("definitely-not-a-real-device".to_string())); + assert!(result.is_err()); + } +} diff --git a/src/launcher-tauri/src-tauri/src/bluetooth.rs b/src/launcher-tauri/src-tauri/src/bluetooth.rs index 2c8f362bb6..06e772c34c 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 ade2a91628..842a35df43 100644 --- a/src/launcher-tauri/src-tauri/src/browse.rs +++ b/src/launcher-tauri/src-tauri/src/browse.rs @@ -25,6 +25,57 @@ 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"; + +/// 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)] + { + 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 +83,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 +113,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 +133,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 +153,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 +200,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 67ec63e7ac..357f6efe1d 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 0f360b0567..8c71af43aa 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); } @@ -339,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>, } @@ -372,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 }); } }); @@ -451,6 +499,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 +524,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 c5d4cfc75c..1e4626643a 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,20 +195,40 @@ 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(), }; - 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(()); } 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()) @@ -190,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)) @@ -549,16 +609,35 @@ fn poll_gamepad_state(state: State) -> Vec { .collect() } -// ---- Audio output device ------------------------------------------------------ +// ---- Audio devices ------------------------------------------------------------ #[tauri::command] -fn list_audio_sinks() -> Vec { - audio::list_sinks() +fn list_audio_sinks() -> Vec { + audio::list_devices(audio::Direction::Output) +} + +/// Microphones and other capture endpoints, for games that record input. +#[tauri::command] +fn list_audio_sources() -> Vec { + audio::list_devices(audio::Direction::Input) } #[tauri::command] fn set_audio_output_sink(sink: Option) -> Result<(), String> { - audio::set_output_sink(sink) + audio::set_device(audio::Direction::Output, sink) +} + +#[tauri::command] +fn set_audio_input_source(source: Option) -> Result<(), String> { + audio::set_device(audio::Direction::Input, source) +} + +/// False where the OS gives a launcher no way to route a child process's +/// audio (Windows). The Audio page shows an explanation instead of a +/// picker that would quietly do nothing. +#[tauri::command] +fn audio_selection_supported() -> bool { + audio::selection_is_supported() } // THROWAWAY — diagnosing the "Run does not launch" report. Removed once fixed. @@ -630,6 +709,7 @@ pub fn run() { stop_game, is_game_running, is_resumed_launch, + get_logs_dir, get_play_history, get_library_stats, record_play_stop, @@ -654,7 +734,11 @@ pub fn run() { list_gamepad_names, poll_gamepad_state, list_audio_sinks, + list_audio_sources, set_audio_output_sink, + set_audio_input_source, + audio_selection_supported, + 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/logs.rs b/src/launcher-tauri/src-tauri/src/logs.rs new file mode 100644 index 0000000000..4e4c29fa97 --- /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/qsettings.rs b/src/launcher-tauri/src-tauri/src/qsettings.rs index 08f6feefb2..5e7a33530d 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/scanner.rs b/src/launcher-tauri/src-tauri/src/scanner.rs index a1005d88f5..9694c2e3e2 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 0a4eb623de..f96b60c4fd 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()?; @@ -64,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 860d1cdf42..ac2a73d79d 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/FolderBrowserModal.tsx b/src/launcher-tauri/src/components/FolderBrowserModal.tsx index 14a0f00ab4..06283897a7 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/components/LibraryFilterPanel.tsx b/src/launcher-tauri/src/components/LibraryFilterPanel.tsx index 1aa06fde7f..3c5ebeaa3a 100644 --- a/src/launcher-tauri/src/components/LibraryFilterPanel.tsx +++ b/src/launcher-tauri/src/components/LibraryFilterPanel.tsx @@ -51,6 +51,17 @@ export function LibraryFilterPanel({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + const onPointerDown = (event: PointerEvent) => { + const target = event.target as HTMLElement | null; + if (!target || !rootRef.current) return; + if (rootRef.current.contains(target) || target.closest("[data-flyout-trigger]")) return; + onClose(); + }; + document.addEventListener("pointerdown", onPointerDown); + return () => document.removeEventListener("pointerdown", onPointerDown); + }, [onClose]); + useEffect(() => { if (!submenu || !submenuRef.current) return; const root = submenuRef.current; diff --git a/src/launcher-tauri/src/components/Toggle.module.css b/src/launcher-tauri/src/components/Toggle.module.css index 78c6373829..5abdfb0fc2 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 ff7804b385..61b0f9ea29 100644 --- a/src/launcher-tauri/src/i18n/locales/en.ts +++ b/src/launcher-tauri/src/i18n/locales/en.ts @@ -165,8 +165,10 @@ export interface Catalog { sfxEnabled: string; sfxVolume: string; outputDevice: string; + inputDevice: string; systemDefault: string; noDevicesFound: string; + routingUnsupported: string; }; bluetooth: { title: string; @@ -174,6 +176,8 @@ export interface Catalog { scan: string; scanning: string; noDevices: string; + noAdapter: string; + poweredOff: string; connect: string; disconnect: string; forget: string; @@ -282,6 +286,9 @@ export interface Catalog { delete: string; active: string; cannotDeleteLast: string; + confirmDelete: string; + profilesHeading: string; + preferencesHeading: string; trophyCount: string; }; profileView: { @@ -294,6 +301,8 @@ export interface Catalog { title: string; stopButton: string; copyLogs: string; + openLogsFolder: string; + clear: string; copiedToClipboard: string; lastRunExited: string; notRunning: string; @@ -501,8 +510,11 @@ const en: Catalog = { sfxEnabled: "UI sound effects", sfxVolume: "Effects volume ({percent}%)", outputDevice: "Output device", + inputDevice: "Input device", systemDefault: "System default", noDevicesFound: "No audio devices found.", + routingUnsupported: + "Windows cannot route one app's audio from another, so these devices are listed for reference only. To send a game to a specific device, assign kyty_emulator in Windows Settings > System > Sound > Volume mixer.", }, bluetooth: { title: "Bluetooth", @@ -510,6 +522,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", @@ -638,6 +652,9 @@ const en: Catalog = { delete: "Delete", active: "Active", cannotDeleteLast: "At least one profile must remain.", + confirmDelete: "Delete {name}?", + profilesHeading: "Profiles", + preferencesHeading: "{name}'s preferences", trophyCount: "{count} trophies", }, profileView: { @@ -650,6 +667,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/lib/audioSettings.ts b/src/launcher-tauri/src/lib/audioSettings.ts index d09ae0a699..f7be3194dd 100644 --- a/src/launcher-tauri/src/lib/audioSettings.ts +++ b/src/launcher-tauri/src/lib/audioSettings.ts @@ -12,10 +12,13 @@ export interface AudioSettings { sfxEnabled: boolean; sfxVolume: number; outputSink: string | null; + /** Capture device a game records from. Same "store the id, display the + * name" rule as outputSink; `null` means the system default. */ + inputSource: string | null; } const STORAGE_KEY = "kyty.audioSettings"; -const DEFAULT_SETTINGS: AudioSettings = { sfxEnabled: true, sfxVolume: 1, outputSink: null }; +const DEFAULT_SETTINGS: AudioSettings = { sfxEnabled: true, sfxVolume: 1, outputSink: null, inputSource: null }; function readStored(): AudioSettings { try { @@ -26,6 +29,7 @@ function readStored(): AudioSettings { sfxEnabled: typeof parsed.sfxEnabled === "boolean" ? parsed.sfxEnabled : DEFAULT_SETTINGS.sfxEnabled, sfxVolume: typeof parsed.sfxVolume === "number" ? Math.min(1, Math.max(0, parsed.sfxVolume)) : DEFAULT_SETTINGS.sfxVolume, outputSink: typeof parsed.outputSink === "string" ? parsed.outputSink : null, + inputSource: typeof parsed.inputSource === "string" ? parsed.inputSource : null, }; } catch { return DEFAULT_SETTINGS; @@ -72,12 +76,22 @@ export async function setOutputSink(sink: string | null): Promise { await invoke("set_audio_output_sink", { sink }); } -/** Re-applies the persisted sink choice to the current process -- call once - * at app startup, since PULSE_SINK does not survive a relaunch on its own - * (it is a process environment variable, not a system setting). */ +/** The capture half of setOutputSink: sets PULSE_SOURCE, which a game + * launched afterwards inherits. */ +export async function setInputSource(source: string | null): Promise { + persist({ ...audioSettingsStore.get(), inputSource: source }); + await invoke("set_audio_input_source", { source }); +} + +/** Re-applies the persisted device choices to the current process -- call + * once at app startup, since PULSE_SINK/PULSE_SOURCE do not survive a + * relaunch on their own (they are process environment variables, not system + * settings). Failures are swallowed: a device that has since been unplugged + * should leave the app running on the default, not break startup. */ export async function reapplyStoredOutputSink(): Promise { - const sink = audioSettingsStore.get().outputSink; - if (sink) await invoke("set_audio_output_sink", { sink }).catch(() => undefined); + const { outputSink, inputSource } = audioSettingsStore.get(); + if (outputSink) await invoke("set_audio_output_sink", { sink: outputSink }).catch(() => undefined); + if (inputSource) await invoke("set_audio_input_source", { source: inputSource }).catch(() => undefined); } export interface AudioSink { @@ -88,3 +102,14 @@ export interface AudioSink { export async function listAudioSinks(): Promise { return invoke("list_audio_sinks").catch(() => []); } + +export async function listAudioSources(): Promise { + return invoke("list_audio_sources").catch(() => []); +} + +/** False where the OS gives a launcher no way to route a child process's + * audio (Windows) -- the picker is then informational, and the page says so + * rather than pretending a choice took effect. */ +export async function audioSelectionSupported(): Promise { + return invoke("audio_selection_supported").catch(() => false); +} diff --git a/src/launcher-tauri/src/nav/FocusNav.tsx b/src/launcher-tauri/src/nav/FocusNav.tsx index ec73c8b4af..1609879828 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.module.css b/src/launcher-tauri/src/shell/TopBar.module.css index b32619a50f..a43fd77562 100644 --- a/src/launcher-tauri/src/shell/TopBar.module.css +++ b/src/launcher-tauri/src/shell/TopBar.module.css @@ -25,6 +25,8 @@ width: 40px; height: 40px; flex: none; + align-self: center; + margin-left: 10px; border: none; background: transparent; color: var(--text-secondary); diff --git a/src/launcher-tauri/src/shell/TopBar.tsx b/src/launcher-tauri/src/shell/TopBar.tsx index e0b3a543a1..5e7f38663f 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 0575f9f196..3b02f05140 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 e5494aa4d3..7fa7c8d9f0 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/types.ts b/src/launcher-tauri/src/types.ts index d8492f956a..9240568a83 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/Library.tsx b/src/launcher-tauri/src/views/Library.tsx index eeb78f0ee6..d6a0af0dea 100644 --- a/src/launcher-tauri/src/views/Library.tsx +++ b/src/launcher-tauri/src/views/Library.tsx @@ -163,6 +163,7 @@ export function LibraryView({ type="button" data-focusable data-focus-key="library-rail-filter" + data-flyout-trigger className={`icon-button ${filterOpen || hasActiveFilters(filters) ? "active" : ""}`} title={t("library.filters.button")} aria-label={t("library.filters.button")} diff --git a/src/launcher-tauri/src/views/Logs.tsx b/src/launcher-tauri/src/views/Logs.tsx index ca345bd043..93d68447f3 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}

} ); } @@ -378,6 +426,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 +443,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 +456,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 +558,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 && ( <> @@ -635,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(); @@ -655,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 ( @@ -664,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 }} /> @@ -701,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")}