diff --git a/README.md b/README.md index bbabba7..dc6ae2d 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Resonant is a desktop-first music player for personal collections. The desktop a - An offline demo library with locally synthesized audio, so the app is useful immediately after cloning - OpenSubsonic token authentication, connection testing, complete album/song pagination, artwork, and streaming URLs - Managed Navidrome 0.61.2 and FFmpeg sidecars, with native folder selection, first-user setup, transcoding, LAN access, process lifecycle control, and app-private server data +- One-time five-digit pairing for connecting another Resonant computer on the same network without entering a server address or credentials - Official release download and SHA-256 verification for Apple Silicon/Intel macOS, x64/ARM64 Linux, and x64 Windows desktop builds - A charcoal and onyx interface with coral reserved for active state and primary action - A Tauri 2 desktop shell for macOS, Windows, and Linux development @@ -47,8 +48,8 @@ npm run check Open the library control in the lower-left corner. -- **This computer:** choose a music folder and a library password. Resonant starts its bundled Navidrome process on port `4533`, stores the Navidrome database under the app data directory, and stops it when Resonant closes. It shows the LAN address other computers can use, clears demo content while scanning, and waits for real indexed tracks before showing the library. -- **Existing server:** enter the URL and credentials for a Navidrome or OpenSubsonic server you already run. +- **This computer:** choose a music folder and a library password. Resonant starts its bundled Navidrome process on port `4533`, stores the Navidrome database under the app data directory, and stops it when Resonant closes. To connect another Resonant computer, show a temporary five-digit code on the host and enter it on the other computer. +- **Another computer:** enter the host's pairing code, or use the manual path by entering the URL and credentials for a Navidrome or OpenSubsonic server you already run. Passwords are saved in Keychain on macOS, Credential Manager on Windows, or Secret Service on Linux. Browser development can preview the interface and connect to servers that allow the development origin, but secure credential persistence, native folder selection, and the managed server require the Tauri desktop app. @@ -63,6 +64,8 @@ Resonant desktop client │ ├── library and artwork requests │ └── authenticated stream URLs └── Tauri shell + ├── one-time LAN pairing service + │ └── encrypted credential handoff └── managed Navidrome sidecar ├── official, pinned, checksum-verified binary ├── bundled FFmpeg transcoder @@ -79,7 +82,9 @@ The protocol boundary is deliberate. Resonant owns the product experience withou ## Network boundary -The managed server is reachable by devices on the same LAN and is protected by the library password. It intentionally does not configure routers, public DNS, certificates, or internet exposure. Do not forward port `4533` directly to the public internet; remote access should use a trusted VPN or a TLS reverse proxy. +The managed server is reachable by devices on the same LAN and is protected by the library password. A pairing code expires after two minutes, can be used once, and stops accepting connections after five attempts. Resonant uses the code in a password-authenticated key exchange, then encrypts the credential handoff; the library password is never sent in plain text. Pairing uses local broadcast discovery, so segmented Wi-Fi or VLAN networks may require the manual connection path. + +Resonant intentionally does not configure routers, public DNS, certificates, or internet exposure. Do not forward port `4533` directly to the public internet; remote access should use a trusted VPN or a TLS reverse proxy. ## Next milestones diff --git a/package.json b/package.json index fc5c263..9306c9f 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "desktop:dev": "npm run sidecar:prepare && tauri dev", "desktop:build": "npm run sidecar:prepare && tauri build", "tauri": "npm run sidecar:prepare && tauri", - "check": "npm run sidecar:prepare && npm run test && npm run build && cargo check --manifest-path src-tauri/Cargo.toml" + "check": "npm run sidecar:prepare && npm run test && npm run build && cargo test --manifest-path src-tauri/Cargo.toml --lib" }, "dependencies": { "@tauri-apps/api": "^2.11.1", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5c1ad6e..75a044a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aes" version = "0.8.4" @@ -493,6 +503,30 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -513,6 +547,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -624,6 +659,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core", "typenum", ] @@ -666,6 +702,32 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "fiat-crypto", + "rand_core", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "darling" version = "0.23.0" @@ -1004,6 +1066,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -2438,6 +2506,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.4.0" @@ -2664,6 +2738,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2777,6 +2862,15 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2890,9 +2984,14 @@ dependencies = [ name = "resonant" version = "0.1.0" dependencies = [ + "chacha20poly1305", + "getrandom 0.3.4", + "hkdf", "keyring", "serde", "serde_json", + "sha2", + "spake2", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -3393,6 +3492,18 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spake2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5482afe85a0b6ce956c945401598dbc527593c77ba51d0a87a586938b1b893a" +dependencies = [ + "curve25519-dalek", + "hkdf", + "rand_core", + "sha2", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4293,6 +4404,16 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "url" version = "2.5.8" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 501064b..e2ccaf3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -13,9 +13,14 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] +chacha20poly1305 = "0.10" +getrandom = "0.3" +hkdf = "0.12" keyring = "4.1" serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" +spake2 = { version = "0.4", features = ["getrandom"] } tauri = { version = "2", features = [] } tauri-plugin-dialog = "2" tauri-plugin-shell = "2" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3d294b5..f3469d2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,7 @@ +mod pairing; + use keyring::{Entry, Error as KeyringError}; +use pairing::{PairedServerConfig, PairingServiceState, PairingState, PairingStatus}; use serde::Serialize; use std::{fs, net::UdpSocket, path::PathBuf, sync::Mutex}; use tauri::{AppHandle, Manager, State, WindowEvent}; @@ -10,8 +13,8 @@ use tauri_plugin_shell::{ const NAVIDROME_VERSION: &str = "0.61.2"; const MANAGED_SERVER_URL: &str = "http://127.0.0.1:4533"; const CREDENTIAL_SERVICE: &str = "xyz.gnosyslabs.resonant"; -const LOCAL_CREDENTIAL: &str = "local-admin"; -const REMOTE_CREDENTIAL: &str = "remote-server"; +pub(crate) const LOCAL_CREDENTIAL: &str = "local-admin"; +pub(crate) const REMOTE_CREDENTIAL: &str = "remote-server"; struct ManagedServer { child: CommandChild, @@ -46,7 +49,7 @@ fn credential_entry(account: &str) -> Result { .map_err(|error| format!("The system credential store is unavailable: {error}")) } -fn read_password(account: &str) -> Result, String> { +pub(crate) fn read_password(account: &str) -> Result, String> { match credential_entry(account)?.get_password() { Ok(password) => Ok(Some(password)), Err(KeyringError::NoEntry) => Ok(None), @@ -54,7 +57,7 @@ fn read_password(account: &str) -> Result, String> { } } -fn write_password(account: &str, password: &str) -> Result<(), String> { +pub(crate) fn write_password(account: &str, password: &str) -> Result<(), String> { credential_entry(account)? .set_password(password) .map_err(|error| format!("The credential could not be saved securely: {error}")) @@ -116,6 +119,43 @@ fn save_remote_password(password: String) -> Result<(), String> { write_password(REMOTE_CREDENTIAL, &password) } +#[tauri::command] +fn pairing_status( + state: State<'_, PairingState>, + service: State<'_, PairingServiceState>, +) -> Result { + pairing::status(state.inner(), service.inner()) +} + +#[tauri::command] +fn begin_device_pairing( + managed_server: State<'_, ManagedServerState>, + state: State<'_, PairingState>, + service: State<'_, PairingServiceState>, +) -> Result { + let running = managed_server + .0 + .lock() + .map_err(|_| "The local server state could not be accessed.".to_string())? + .is_some(); + pairing::begin(running, state.inner(), service.inner()) +} + +#[tauri::command] +fn cancel_device_pairing( + state: State<'_, PairingState>, + service: State<'_, PairingServiceState>, +) -> Result { + pairing::cancel(state.inner(), service.inner()) +} + +#[tauri::command] +async fn pair_with_code(code: String) -> Result { + tauri::async_runtime::spawn_blocking(move || pairing::pair_with_code(code)) + .await + .map_err(|error| format!("The pairing task could not finish: {error}"))? +} + fn stop_server(state: &ManagedServerState) -> Result<(), String> { let mut server = state .0 @@ -145,6 +185,7 @@ fn managed_server_status( fn start_managed_server( app: AppHandle, state: State<'_, ManagedServerState>, + pairing_state: State<'_, PairingState>, music_folder: String, password: String, ) -> Result { @@ -159,6 +200,7 @@ fn start_managed_server( return Err("Choose a folder that contains your music.".to_string()); } write_password(LOCAL_CREDENTIAL, &password)?; + pairing::clear(pairing_state.inner()); stop_server(&state)?; @@ -219,6 +261,8 @@ fn start_managed_server( server.take(); } } + let pairing_state = event_app.state::(); + pairing::clear(pairing_state.inner()); break; } } @@ -236,7 +280,9 @@ fn start_managed_server( #[tauri::command] fn stop_managed_server( state: State<'_, ManagedServerState>, + pairing_state: State<'_, PairingState>, ) -> Result { + pairing::clear(pairing_state.inner()); stop_server(&state)?; Ok(status_for(None)) } @@ -247,10 +293,20 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_shell::init()) .manage(ManagedServerState::default()) + .manage(PairingState::default()) + .manage(PairingServiceState::default()) + .setup(|app| { + pairing::start_service(app.handle().clone()); + Ok(()) + }) .invoke_handler(tauri::generate_handler![ managed_server_status, load_saved_credentials, save_remote_password, + pairing_status, + begin_device_pairing, + cancel_device_pairing, + pair_with_code, start_managed_server, stop_managed_server ]) @@ -258,6 +314,8 @@ pub fn run() { if matches!(event, WindowEvent::Destroyed) { let state = window.app_handle().state::(); let _ = stop_server(&state); + let pairing_state = window.app_handle().state::(); + pairing::clear(pairing_state.inner()); } }) .run(tauri::generate_context!()) diff --git a/src-tauri/src/pairing.rs b/src-tauri/src/pairing.rs new file mode 100644 index 0000000..e3b8167 --- /dev/null +++ b/src-tauri/src/pairing.rs @@ -0,0 +1,579 @@ +use chacha20poly1305::{ + aead::{Aead, KeyInit, Payload}, + ChaCha20Poly1305, Nonce, +}; +use getrandom::fill as fill_random; +use hkdf::Hkdf; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use spake2::{Ed25519Group, Identity, Password, Spake2}; +use std::{ + collections::HashSet, + io::{ErrorKind, Read, Write}, + net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener, TcpStream, UdpSocket}, + sync::Mutex, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; +use tauri::{AppHandle, Manager}; + +const PAIRING_PORT: u16 = 4534; +const PAIRING_LIFETIME: Duration = Duration::from_secs(120); +const PAIRING_ATTEMPTS: u8 = 5; +const DISCOVERY_WINDOW: Duration = Duration::from_secs(2); +const CONNECTION_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_FRAME_SIZE: usize = 4096; +const DISCOVERY_REQUEST: &[u8] = b"resonant-pair-discover-v1"; +const DISCOVERY_RESPONSE: &[u8] = b"resonant-pair-host-v1"; +const CLIENT_ID: &[u8] = b"resonant-client-v1"; +const HOST_ID: &[u8] = b"resonant-host-v1"; +const KEY_CONTEXT: &[u8] = b"resonant-lan-pairing-v1"; +const CREDENTIAL_CONTEXT: &[u8] = b"resonant-pairing-credentials-v1"; +const ACK_CONTEXT: &[u8] = b"resonant-pairing-ack-v1"; +const ACK_MESSAGE: &[u8] = b"credentials-received"; + +#[derive(Default)] +struct PairingData { + session: Option, + paired: bool, +} + +struct PairingSession { + code: String, + expires_at: SystemTime, + attempts: u8, + generation: u64, +} + +#[derive(Default)] +pub(crate) struct PairingState(Mutex); + +#[derive(Default)] +struct ServiceHealth { + available: bool, + error: Option, +} + +#[derive(Default)] +pub(crate) struct PairingServiceState(Mutex); + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PairingStatus { + available: bool, + active: bool, + #[serde(skip_serializing_if = "Option::is_none")] + code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + expires_at: Option, + attempts_remaining: u8, + paired: bool, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PairedServerConfig { + pub(crate) url: String, + pub(crate) username: String, + pub(crate) password: String, +} + +fn unix_millis(time: SystemTime) -> u64 { + time.duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +fn clear_expired(data: &mut PairingData) { + if data + .session + .as_ref() + .is_some_and(|session| SystemTime::now() >= session.expires_at) + { + data.session = None; + data.paired = false; + } +} + +fn random_u32() -> Result { + let mut bytes = [0_u8; 4]; + fill_random(&mut bytes) + .map_err(|error| format!("Secure randomness is unavailable: {error}"))?; + Ok(u32::from_le_bytes(bytes)) +} + +fn random_u64() -> Result { + let mut bytes = [0_u8; 8]; + fill_random(&mut bytes) + .map_err(|error| format!("Secure randomness is unavailable: {error}"))?; + Ok(u64::from_le_bytes(bytes)) +} + +fn generate_pairing_code() -> Result { + const CODE_SPACE: u32 = 100_000; + const UNBIASED_LIMIT: u32 = u32::MAX - (u32::MAX % CODE_SPACE); + loop { + let value = random_u32()?; + if value < UNBIASED_LIMIT { + return Ok(format!("{:05}", value % CODE_SPACE)); + } + } +} + +fn derive_key(shared_secret: &[u8]) -> Result<[u8; 32], String> { + let hkdf = Hkdf::::new(Some(KEY_CONTEXT), shared_secret); + let mut key = [0_u8; 32]; + hkdf.expand(b"credential-channel", &mut key) + .map_err(|_| "The pairing session key could not be derived.".to_string())?; + Ok(key) +} + +fn seal(key: &[u8; 32], plaintext: &[u8], context: &[u8]) -> Result, String> { + let cipher = ChaCha20Poly1305::new_from_slice(key) + .map_err(|_| "The pairing cipher could not be initialized.".to_string())?; + let mut nonce = [0_u8; 12]; + fill_random(&mut nonce) + .map_err(|error| format!("Secure randomness is unavailable: {error}"))?; + let ciphertext = cipher + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: plaintext, + aad: context, + }, + ) + .map_err(|_| "The pairing message could not be encrypted.".to_string())?; + let mut message = nonce.to_vec(); + message.extend_from_slice(&ciphertext); + Ok(message) +} + +fn open(key: &[u8; 32], message: &[u8], context: &[u8]) -> Result, String> { + if message.len() < 12 { + return Err("The pairing response was incomplete.".to_string()); + } + let cipher = ChaCha20Poly1305::new_from_slice(key) + .map_err(|_| "The pairing cipher could not be initialized.".to_string())?; + cipher + .decrypt( + Nonce::from_slice(&message[..12]), + Payload { + msg: &message[12..], + aad: context, + }, + ) + .map_err(|_| "That pairing code was not accepted.".to_string()) +} + +fn write_frame(stream: &mut TcpStream, payload: &[u8]) -> Result<(), String> { + if payload.len() > MAX_FRAME_SIZE { + return Err("The pairing message was too large.".to_string()); + } + stream + .write_all(&(payload.len() as u32).to_be_bytes()) + .and_then(|_| stream.write_all(payload)) + .map_err(|error| format!("The pairing message could not be sent: {error}")) +} + +fn read_frame(stream: &mut TcpStream) -> Result, String> { + let mut length = [0_u8; 4]; + stream + .read_exact(&mut length) + .map_err(|error| format!("The pairing response could not be read: {error}"))?; + let length = u32::from_be_bytes(length) as usize; + if length == 0 || length > MAX_FRAME_SIZE { + return Err("The pairing response had an invalid size.".to_string()); + } + let mut payload = vec![0_u8; length]; + stream + .read_exact(&mut payload) + .map_err(|error| format!("The pairing response could not be read: {error}"))?; + Ok(payload) +} + +fn service_status(service: &PairingServiceState) -> Result<(), String> { + let health = service + .0 + .lock() + .map_err(|_| "The pairing service state could not be accessed.".to_string())?; + if health.available { + Ok(()) + } else { + Err(health + .error + .clone() + .unwrap_or_else(|| "Device pairing is unavailable on this computer.".to_string())) + } +} + +pub(crate) fn status( + state: &PairingState, + service: &PairingServiceState, +) -> Result { + let available = { + let health = service + .0 + .lock() + .map_err(|_| "The pairing service state could not be accessed.".to_string())?; + health.available + }; + let mut data = state + .0 + .lock() + .map_err(|_| "The pairing state could not be accessed.".to_string())?; + clear_expired(&mut data); + let session = data.session.as_ref(); + Ok(PairingStatus { + available, + active: session.is_some(), + code: session.map(|session| session.code.clone()), + expires_at: session.map(|session| unix_millis(session.expires_at)), + attempts_remaining: session + .map(|session| PAIRING_ATTEMPTS.saturating_sub(session.attempts)) + .unwrap_or(0), + paired: data.paired, + }) +} + +pub(crate) fn begin( + managed_server_running: bool, + state: &PairingState, + service: &PairingServiceState, +) -> Result { + if !managed_server_running { + return Err("Start this computer’s library before pairing another device.".to_string()); + } + service_status(service)?; + if crate::read_password(crate::LOCAL_CREDENTIAL)?.is_none() { + return Err( + "The saved library credential is unavailable. Restart the library first.".to_string(), + ); + } + let session = PairingSession { + code: generate_pairing_code()?, + expires_at: SystemTime::now() + PAIRING_LIFETIME, + attempts: 0, + generation: random_u64()?, + }; + { + let mut data = state + .0 + .lock() + .map_err(|_| "The pairing state could not be accessed.".to_string())?; + data.session = Some(session); + data.paired = false; + } + status(state, service) +} + +pub(crate) fn cancel( + state: &PairingState, + service: &PairingServiceState, +) -> Result { + clear(state); + status(state, service) +} + +pub(crate) fn clear(state: &PairingState) { + if let Ok(mut data) = state.0.lock() { + data.session = None; + data.paired = false; + } +} + +fn pairing_is_active(app: &AppHandle) -> bool { + let state = app.state::(); + let Ok(mut data) = state.0.lock() else { + return false; + }; + clear_expired(&mut data); + data.session.is_some() +} + +fn reserve_attempt(app: &AppHandle) -> Result<(String, u64), String> { + let state = app.state::(); + let mut data = state + .0 + .lock() + .map_err(|_| "The pairing state could not be accessed.".to_string())?; + clear_expired(&mut data); + let session = data + .session + .as_mut() + .ok_or_else(|| "No pairing code is active.".to_string())?; + if session.attempts >= PAIRING_ATTEMPTS { + return Err("The pairing attempt limit has been reached.".to_string()); + } + session.attempts += 1; + Ok((session.code.clone(), session.generation)) +} + +fn finish_attempt(app: &AppHandle, generation: u64, paired: bool) { + let state = app.state::(); + let Ok(mut data) = state.0.lock() else { + return; + }; + let Some(session) = data.session.as_ref() else { + return; + }; + if session.generation != generation { + return; + } + if paired { + data.session = None; + data.paired = true; + } else if session.attempts >= PAIRING_ATTEMPTS { + data.session = None; + data.paired = false; + } +} + +fn handle_host_connection(mut stream: TcpStream, code: &str) -> Result<(), String> { + stream + .set_read_timeout(Some(CONNECTION_TIMEOUT)) + .and_then(|_| stream.set_write_timeout(Some(CONNECTION_TIMEOUT))) + .map_err(|error| format!("The pairing connection could not be configured: {error}"))?; + let host_address = stream + .local_addr() + .map_err(|error| format!("The host address could not be read: {error}"))?; + let client_message = read_frame(&mut stream)?; + let (exchange, host_message) = Spake2::::start_b( + &Password::new(code.as_bytes()), + &Identity::new(CLIENT_ID), + &Identity::new(HOST_ID), + ); + let shared_secret = exchange + .finish(&client_message) + .map_err(|_| "The pairing request was invalid.".to_string())?; + write_frame(&mut stream, &host_message)?; + let key = derive_key(&shared_secret)?; + let password = crate::read_password(crate::LOCAL_CREDENTIAL)? + .ok_or_else(|| "The library credential is unavailable.".to_string())?; + let credentials = PairedServerConfig { + url: format!("http://{}:4533", host_address.ip()), + username: "admin".to_string(), + password, + }; + let credentials = serde_json::to_vec(&credentials) + .map_err(|error| format!("The pairing credentials could not be encoded: {error}"))?; + let encrypted_credentials = seal(&key, &credentials, CREDENTIAL_CONTEXT)?; + write_frame(&mut stream, &encrypted_credentials)?; + let encrypted_ack = read_frame(&mut stream)?; + let ack = open(&key, &encrypted_ack, ACK_CONTEXT)?; + if ack != ACK_MESSAGE { + return Err("The paired device did not confirm receipt.".to_string()); + } + Ok(()) +} + +fn discovery_loop(app: AppHandle, socket: UdpSocket) { + let mut buffer = [0_u8; 64]; + loop { + match socket.recv_from(&mut buffer) { + Ok((length, source)) if &buffer[..length] == DISCOVERY_REQUEST => { + if pairing_is_active(&app) { + let _ = socket.send_to(DISCOVERY_RESPONSE, source); + } + } + Ok(_) => {} + Err(error) if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {} + Err(_) => thread::sleep(Duration::from_millis(250)), + } + } +} + +fn connection_loop(app: AppHandle, listener: TcpListener) { + loop { + match listener.accept() { + Ok((stream, _)) => { + let Ok((code, generation)) = reserve_attempt(&app) else { + continue; + }; + let connection_app = app.clone(); + thread::spawn(move || { + let paired = handle_host_connection(stream, &code).is_ok(); + finish_attempt(&connection_app, generation, paired); + }); + } + Err(error) if error.kind() == ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(100)); + } + Err(_) => thread::sleep(Duration::from_millis(250)), + } + } +} + +pub(crate) fn start_service(app: AppHandle) { + let service = app.state::(); + let result = (|| -> Result<(UdpSocket, TcpListener), String> { + let udp = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, PAIRING_PORT)).map_err(|error| { + format!("Device pairing could not use UDP port {PAIRING_PORT}: {error}") + })?; + udp.set_read_timeout(Some(Duration::from_millis(500))) + .map_err(|error| format!("Device pairing discovery could not start: {error}"))?; + let tcp = TcpListener::bind((Ipv4Addr::UNSPECIFIED, PAIRING_PORT)).map_err(|error| { + format!("Device pairing could not use TCP port {PAIRING_PORT}: {error}") + })?; + tcp.set_nonblocking(true) + .map_err(|error| format!("Device pairing could not start: {error}"))?; + Ok((udp, tcp)) + })(); + + match result { + Ok((udp, tcp)) => { + if let Ok(mut health) = service.0.lock() { + health.available = true; + health.error = None; + } + let discovery_app = app.clone(); + thread::spawn(move || discovery_loop(discovery_app, udp)); + thread::spawn(move || connection_loop(app, tcp)); + } + Err(error) => { + if let Ok(mut health) = service.0.lock() { + health.available = false; + health.error = Some(error); + } + } + } +} + +fn discover_hosts() -> Result, String> { + let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) + .map_err(|error| format!("Device discovery could not start: {error}"))?; + socket + .set_broadcast(true) + .and_then(|_| socket.set_read_timeout(Some(Duration::from_millis(250)))) + .map_err(|error| format!("Device discovery could not be configured: {error}"))?; + + let destinations = [ + SocketAddrV4::new(Ipv4Addr::BROADCAST, PAIRING_PORT), + SocketAddrV4::new(Ipv4Addr::LOCALHOST, PAIRING_PORT), + ]; + let mut sent = false; + for destination in destinations { + sent |= socket.send_to(DISCOVERY_REQUEST, destination).is_ok(); + } + if !sent { + return Err("The local network could not be searched for a Resonant host.".to_string()); + } + + let deadline = Instant::now() + DISCOVERY_WINDOW; + let mut hosts = HashSet::new(); + let mut buffer = [0_u8; 64]; + while Instant::now() < deadline { + match socket.recv_from(&mut buffer) { + Ok((length, source)) if &buffer[..length] == DISCOVERY_RESPONSE => { + if matches!(source.ip(), IpAddr::V4(_)) { + hosts.insert(SocketAddr::new(source.ip(), PAIRING_PORT)); + } + } + Ok(_) => {} + Err(error) if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {} + Err(error) => return Err(format!("Device discovery failed: {error}")), + } + } + Ok(hosts.into_iter().collect()) +} + +fn pair_with_host(host: SocketAddr, code: &str) -> Result { + let mut stream = TcpStream::connect_timeout(&host, CONNECTION_TIMEOUT) + .map_err(|error| format!("The Resonant host could not be reached: {error}"))?; + stream + .set_read_timeout(Some(CONNECTION_TIMEOUT)) + .and_then(|_| stream.set_write_timeout(Some(CONNECTION_TIMEOUT))) + .map_err(|error| format!("The pairing connection could not be configured: {error}"))?; + let (exchange, client_message) = Spake2::::start_a( + &Password::new(code.as_bytes()), + &Identity::new(CLIENT_ID), + &Identity::new(HOST_ID), + ); + write_frame(&mut stream, &client_message)?; + let host_message = read_frame(&mut stream)?; + let shared_secret = exchange + .finish(&host_message) + .map_err(|_| "The host returned an invalid pairing response.".to_string())?; + let key = derive_key(&shared_secret)?; + let encrypted_credentials = read_frame(&mut stream)?; + let credentials = open(&key, &encrypted_credentials, CREDENTIAL_CONTEXT)?; + let credentials: PairedServerConfig = serde_json::from_slice(&credentials) + .map_err(|_| "The host returned invalid library details.".to_string())?; + if credentials.username.is_empty() + || credentials.password.is_empty() + || !credentials.url.starts_with("http://") + { + return Err("The host returned incomplete library details.".to_string()); + } + let ack = seal(&key, ACK_MESSAGE, ACK_CONTEXT)?; + write_frame(&mut stream, &ack)?; + Ok(credentials) +} + +pub(crate) fn pair_with_code(code: String) -> Result { + let code = code.trim(); + if code.len() != 5 || !code.bytes().all(|byte| byte.is_ascii_digit()) { + return Err("Enter the five-digit code shown on the host computer.".to_string()); + } + let hosts = discover_hosts()?; + if hosts.is_empty() { + return Err("No Resonant host is sharing a pairing code on this network.".to_string()); + } + let mut last_error = None; + for host in hosts { + match pair_with_host(host, code) { + Ok(credentials) => { + crate::write_password(crate::REMOTE_CREDENTIAL, &credentials.password)?; + return Ok(credentials); + } + Err(error) => last_error = Some(error), + } + } + Err(last_error.unwrap_or_else(|| "That pairing code was not accepted.".to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn exchange_keys(client_code: &str, host_code: &str) -> ([u8; 32], [u8; 32]) { + let (client, client_message) = Spake2::::start_a( + &Password::new(client_code.as_bytes()), + &Identity::new(CLIENT_ID), + &Identity::new(HOST_ID), + ); + let (host, host_message) = Spake2::::start_b( + &Password::new(host_code.as_bytes()), + &Identity::new(CLIENT_ID), + &Identity::new(HOST_ID), + ); + let client_key = derive_key(&client.finish(&host_message).unwrap()).unwrap(); + let host_key = derive_key(&host.finish(&client_message).unwrap()).unwrap(); + (client_key, host_key) + } + + #[test] + fn generated_codes_are_exactly_five_digits() { + for _ in 0..100 { + let code = generate_pairing_code().unwrap(); + assert_eq!(code.len(), 5); + assert!(code.bytes().all(|byte| byte.is_ascii_digit())); + } + } + + #[test] + fn matching_codes_create_an_authenticated_channel() { + let (client_key, host_key) = exchange_keys("12345", "12345"); + let sealed = seal(&host_key, b"library-secret", CREDENTIAL_CONTEXT).unwrap(); + assert_eq!( + open(&client_key, &sealed, CREDENTIAL_CONTEXT).unwrap(), + b"library-secret" + ); + } + + #[test] + fn a_wrong_code_cannot_decrypt_credentials() { + let (client_key, host_key) = exchange_keys("12345", "54321"); + let sealed = seal(&host_key, b"library-secret", CREDENTIAL_CONTEXT).unwrap(); + assert!(open(&client_key, &sealed, CREDENTIAL_CONTEXT).is_err()); + } +} diff --git a/src/App.test.tsx b/src/App.test.tsx index 229ac94..a405d7c 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -5,9 +5,13 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } const managedServer = vi.hoisted(() => ({ chooseMusicFolder: vi.fn(), + beginDevicePairing: vi.fn(), + cancelDevicePairing: vi.fn(), getManagedServerStatus: vi.fn(), + getPairingStatus: vi.fn(), getSavedCredentials: vi.fn(), isDesktopRuntime: vi.fn(), + pairWithCode: vi.fn(), saveRemotePassword: vi.fn(), startManagedServer: vi.fn(), stopManagedServer: vi.fn(), @@ -55,6 +59,7 @@ describe("App bootstrap", () => { localStorage.clear(); vi.clearAllMocks(); managedServer.isDesktopRuntime.mockReturnValue(false); + managedServer.getPairingStatus.mockResolvedValue({ available: false, active: false, attemptsRemaining: 0, paired: false }); }); it("keeps a saved library behind the launch gate while credentials load", () => { @@ -108,6 +113,39 @@ describe("App bootstrap", () => { expect(screen.queryByRole("heading", { level: 2, name: "Heavy rotation" })).toBeNull(); }); + it("pairs with another Resonant computer using only five digits", async () => { + const message = "No Resonant host is sharing a pairing code on this network."; + managedServer.pairWithCode.mockRejectedValue(new Error(message)); + await renderDemoApp(); + + fireEvent.click(screen.getByRole("button", { name: /Demo library/ })); + fireEvent.click(screen.getByRole("button", { name: /Another computer/ })); + const code = screen.getByLabelText("Pairing code") as HTMLInputElement; + fireEvent.change(code, { target: { value: "12a345" } }); + + expect(code.value).toBe("12345"); + fireEvent.click(screen.getByRole("button", { name: "Pair computer" })); + + expect((await screen.findByRole("alert")).textContent).toBe(message); + expect(managedServer.pairWithCode).toHaveBeenCalledWith("12345"); + }); + + it("shows a temporary five-digit code on a running host", async () => { + managedServer.isDesktopRuntime.mockReturnValue(true); + managedServer.getManagedServerStatus.mockResolvedValue({ running: true, url: "http://127.0.0.1:4533", lanUrls: ["http://192.168.1.10:4533"], version: "0.61.2" }); + managedServer.getSavedCredentials.mockResolvedValue({}); + managedServer.getPairingStatus.mockResolvedValue({ available: true, active: false, attemptsRemaining: 0, paired: false }); + managedServer.beginDevicePairing.mockResolvedValue({ available: true, active: true, code: "40721", expiresAt: Date.now() + 120_000, attemptsRemaining: 5, paired: false }); + render(); + await screen.findByText("Let it find you again."); + + fireEvent.click(screen.getByRole("button", { name: /This computer/ })); + fireEvent.click(screen.getByRole("button", { name: "Show pairing code" })); + + expect((await screen.findByLabelText("Pairing code")).textContent).toBe("40721"); + expect(screen.queryByText("http://192.168.1.10:4533")).toBeNull(); + }); + it("defaults Albums and Songs to A–Z and remembers reordered views", async () => { await renderDemoApp(); diff --git a/src/App.tsx b/src/App.tsx index 7037f0d..dbd8657 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -49,12 +49,17 @@ import { fetchAllPages, pollForLibrary } from "./lib/librarySync"; import { chooseMusicFolder, getManagedServerStatus, + getPairingStatus, getSavedCredentials, + beginDevicePairing, + cancelDevicePairing, isDesktopRuntime, + pairWithCode, saveRemotePassword, startManagedServer, stopManagedServer, type ManagedServerStatus, + type PairingStatus, } from "./lib/managedServer"; import { buildApiUrl, requestSubsonic, setSubsonicFavorite, streamUrl, submitSubsonicScrobble } from "./lib/subsonic"; import type { Album, ConnectionStatus, CoverTone, ServerConfig, Track, View } from "./types"; @@ -141,6 +146,10 @@ interface ServerSong { id: string; title: string; artist?: string; album?: strin interface LoadedLibrary { albums: Album[]; tracks: Track[] } type LibraryPhase = "ready" | "scanning" | "empty"; +function formatPairingTime(seconds: number): string { + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; +} + export default function App() { const [view, setView] = useState("home"); const [mobileNav, setMobileNav] = useState(false); @@ -167,6 +176,12 @@ export default function App() { const [libraryPhase, setLibraryPhase] = useState("ready"); const [sourceMode, setSourceMode] = useState<"local" | "remote">(() => localStorage.getItem("resonant.sourceMode") === "remote" ? "remote" : "local"); const [managedStatus, setManagedStatus] = useState(null); + const [hostPairing, setHostPairing] = useState(null); + const [hostPairingError, setHostPairingError] = useState(null); + const [pairingCode, setPairingCode] = useState(""); + const [pairingBusy, setPairingBusy] = useState(false); + const [pairingError, setPairingError] = useState(null); + const [pairingNow, setPairingNow] = useState(Date.now()); const [localSetup, setLocalSetup] = useState({ musicFolder: localStorage.getItem("resonant.musicFolder") ?? "", password: "", @@ -391,6 +406,7 @@ export default function App() { const connect = async (event: FormEvent) => { event.preventDefault(); + setPairingError(null); setConnectionStatus("connecting"); setConnectionMessage("Reaching your library…"); try { @@ -405,6 +421,54 @@ export default function App() { } }; + const showPairingCode = async () => { + setHostPairingError(null); + try { + setHostPairing(await beginDevicePairing()); + setPairingNow(Date.now()); + } catch (error) { + setHostPairingError(error instanceof Error ? error.message : "A pairing code could not be created."); + } + }; + + const cancelPairing = async () => { + setHostPairingError(null); + try { + setHostPairing(await cancelDevicePairing()); + } catch (error) { + setHostPairingError(error instanceof Error ? error.message : "Pairing could not be cancelled."); + } + }; + + const pairComputer = async (event: FormEvent) => { + event.preventDefault(); + if (!/^\d{5}$/.test(pairingCode)) { + setPairingError("Enter the five-digit code shown on the host computer."); + return; + } + + setPairingBusy(true); + setPairingError(null); + setConnectionStatus("connecting"); + setConnectionMessage("Looking for your Resonant host…"); + try { + const config = await pairWithCode(pairingCode); + setServer(config); + localStorage.setItem("resonant.serverUrl", config.url); + localStorage.setItem("resonant.username", config.username); + localStorage.setItem("resonant.sourceMode", "remote"); + await loadServerLibrary(config, "Paired with your Resonant host"); + setPairingCode(""); + } catch (error) { + const message = error instanceof Error ? error.message : "This computer could not be paired."; + setPairingError(message); + setConnectionStatus("error"); + setConnectionMessage(message); + } finally { + setPairingBusy(false); + } + }; + const pickMusicFolder = async () => { if (!isDesktopRuntime()) { setConnectionStatus("error"); @@ -552,6 +616,35 @@ export default function App() { }; }, []); + useEffect(() => { + if (!managedStatus?.running || !isDesktopRuntime()) { + setHostPairing(null); + return; + } + + let active = true; + const refresh = async () => { + try { + const next = await getPairingStatus(); + if (active) { + setHostPairing(next); + setHostPairingError(next.available ? null : "Device pairing is unavailable on this computer."); + } + } catch (error) { + if (active) setHostPairingError(error instanceof Error ? error.message : "Pairing status is unavailable."); + } + }; + void refresh(); + const timer = window.setInterval(() => { + setPairingNow(Date.now()); + void refresh(); + }, 1000); + return () => { + active = false; + window.clearInterval(timer); + }; + }, [managedStatus?.running]); + const goTo = (next: View) => { if (next === "home") { setSpotlightTrackId((current) => pickRandomTrack(libraryTracks, current)?.id ?? ""); @@ -616,7 +709,7 @@ export default function App() {
- +
{sourceMode === "local" ? ( @@ -625,7 +718,14 @@ export default function App() {
{connectionMessage}
- {managedStatus?.running && managedStatus.lanUrls.length > 0 &&
Listen from another computer

Use this address in Resonant or any OpenSubsonic app on the same private network. Sign in as admin with your library password.

{managedStatus.lanUrls.map((url) => {url})}
} + {managedStatus?.running &&
+
Pair another computer

Open Resonant there and enter a temporary code. Your server credentials stay hidden.

{hostPairing?.paired && Device connected}
+ {hostPairing?.active && hostPairing.code ? <> +
{hostPairing.code}Expires in {formatPairingTime(Math.max(0, Math.ceil(((hostPairing.expiresAt ?? pairingNow) - pairingNow) / 1000)))}{hostPairing.attemptsRemaining} attempts remaining
+
+ : } + {hostPairingError &&

{hostPairingError}

} +
}
{managedStatus?.running && } @@ -633,12 +733,21 @@ export default function App() {
) : ( -
- -
-
{connectionMessage}
-
-
+
+
+
Pair with a Resonant host

Make sure both computers are on the same network, then enter the code shown by the host.

5 digits
+ + {pairingError &&

{pairingError}

} +
+
or connect manually
+
+
Navidrome or OpenSubsonic server

Use this for an existing server that is not hosted by Resonant.

+ +
+ {!pairingError &&
{connectionMessage}
} +
+
+
)}
{sourceMode === "local" ? "Available on your private network" : "Your server stays yours"}

{sourceMode === "local" ? "The managed server accepts devices on your LAN, keeps its database in Resonant’s app data, and stops when Resonant closes. Avoid exposing port 4533 directly to the internet." : "Resonant speaks OpenSubsonic directly and saves the password in your operating system credential store."}

diff --git a/src/lib/managedServer.test.ts b/src/lib/managedServer.test.ts index 5393516..06d5612 100644 --- a/src/lib/managedServer.test.ts +++ b/src/lib/managedServer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { getManagedServerStatus, getSavedCredentials, isDesktopRuntime } from "./managedServer"; +import { getManagedServerStatus, getPairingStatus, getSavedCredentials, isDesktopRuntime, pairWithCode } from "./managedServer"; describe("managed server bridge", () => { it("reports the browser preview as a non-desktop runtime", () => { @@ -18,4 +18,14 @@ describe("managed server bridge", () => { it("does not expose credentials in the browser preview", async () => { await expect(getSavedCredentials()).resolves.toEqual({}); }); + + it("keeps device pairing inside the desktop app", async () => { + await expect(getPairingStatus()).resolves.toEqual({ + available: false, + active: false, + attemptsRemaining: 0, + paired: false, + }); + await expect(pairWithCode("12345")).rejects.toThrow("desktop app"); + }); }); diff --git a/src/lib/managedServer.ts b/src/lib/managedServer.ts index 43e246d..19fefe6 100644 --- a/src/lib/managedServer.ts +++ b/src/lib/managedServer.ts @@ -14,6 +14,28 @@ export interface SavedCredentials { remotePassword?: string; } +export interface PairingStatus { + available: boolean; + active: boolean; + code?: string; + expiresAt?: number; + attemptsRemaining: number; + paired: boolean; +} + +export interface PairedServerConfig { + url: string; + username: string; + password: string; +} + +const unavailablePairingStatus: PairingStatus = { + available: false, + active: false, + attemptsRemaining: 0, + paired: false, +}; + export function isDesktopRuntime(): boolean { return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; } @@ -45,6 +67,26 @@ export async function saveRemotePassword(password: string): Promise { await invoke("save_remote_password", { password }); } +export async function getPairingStatus(): Promise { + if (!isDesktopRuntime()) return unavailablePairingStatus; + return invoke("pairing_status"); +} + +export async function beginDevicePairing(): Promise { + if (!isDesktopRuntime()) throw new Error("Device pairing is available in the Resonant desktop app."); + return invoke("begin_device_pairing"); +} + +export async function cancelDevicePairing(): Promise { + if (!isDesktopRuntime()) return unavailablePairingStatus; + return invoke("cancel_device_pairing"); +} + +export async function pairWithCode(code: string): Promise { + if (!isDesktopRuntime()) throw new Error("Device pairing is available in the Resonant desktop app."); + return invoke("pair_with_code", { code }); +} + export async function startManagedServer(musicFolder: string, password: string): Promise { if (!isDesktopRuntime()) throw new Error("The managed library is available in the Resonant desktop app."); return invoke("start_managed_server", { musicFolder, password }); diff --git a/src/styles.css b/src/styles.css index 79dc1eb..ee27440 100644 --- a/src/styles.css +++ b/src/styles.css @@ -220,10 +220,27 @@ main { min-width: 0; height: calc(100vh - var(--player-height)); overflow-y: aut .connection-feedback { display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 12px; border-radius: 8px; color: var(--muted); background: oklch(0.21 0.012 275); font-size: 0.73rem; } .connection-feedback--error { color: oklch(0.81 0.09 25); background: oklch(0.28 0.055 24); } .connection-feedback--connected { color: oklch(0.81 0.08 145); background: oklch(0.26 0.05 145); } -.device-addresses { display: grid; gap: 6px; padding: 14px; border: 1px solid oklch(0.39 0.035 145); border-radius: 10px; background: oklch(0.21 0.025 145); } -.device-addresses strong { font-size: 0.75rem; } -.device-addresses p { margin: 0 0 3px; color: var(--muted); font-size: 0.68rem; line-height: 1.5; } -.device-addresses code { width: fit-content; padding: 5px 8px; border: 1px solid var(--line); border-radius: 6px; color: oklch(0.84 0.08 145); background: oklch(0.14 0.012 275); font-size: 0.7rem; user-select: all; } +.remote-connection-stack { display: grid; gap: 14px; } +.pairing-panel { display: grid; gap: 15px; padding: 17px; border: 1px solid oklch(0.38 0.02 275); border-radius: 12px; background: oklch(0.18 0.011 275); } +.pairing-panel-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } +.pairing-panel-head strong, .pairing-heading strong { color: var(--ink); font-size: 0.8rem; } +.pairing-panel-head p, .pairing-heading p { max-width: 54ch; margin: 4px 0 0; color: var(--faint); font-size: 0.68rem; line-height: 1.5; } +.pairing-success { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 5px; color: oklch(0.82 0.08 145); font-size: 0.66rem; font-weight: 700; } +.pairing-code-row { display: flex; align-items: center; gap: 19px; } +.pairing-code { color: oklch(0.94 0.008 275); font-size: 2rem; font-weight: 650; letter-spacing: 0.18em; font-variant-numeric: tabular-nums; } +.pairing-code-row > span { color: var(--muted); font-size: 0.68rem; font-variant-numeric: tabular-nums; } +.pairing-code-row small { display: block; margin-top: 3px; color: var(--faint); } +.pairing-actions { display: flex; align-items: center; gap: 8px; } +.pairing-actions .button-quiet { border-color: transparent; background: transparent; } +.pairing-start { width: fit-content; } +.pairing-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 17px; border-bottom: 1px solid var(--line); } +.pairing-heading > span { padding: 4px 7px; border: 1px solid var(--line); border-radius: 6px; color: var(--faint); font-size: 0.6rem; font-variant-numeric: tabular-nums; } +.pairing-entry { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 9px; } +.pairing-entry input { height: 47px; font-size: 1.12rem; font-weight: 650; letter-spacing: 0.2em; font-variant-numeric: tabular-nums; } +.pairing-entry .button-primary { min-width: 146px; } +.pairing-error { margin: -4px 0 0; color: oklch(0.81 0.09 25); font-size: 0.7rem; line-height: 1.45; } +.connection-divider { display: flex; align-items: center; gap: 13px; color: var(--faint); font-size: 0.62rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; } +.connection-divider::before, .connection-divider::after { height: 1px; flex: 1; background: var(--line); content: ""; } .button-danger { margin-right: auto; color: oklch(0.78 0.1 25); } .privacy-note { display: flex; gap: 14px; margin-top: 20px; padding: 7px 14px; color: var(--muted); } .privacy-note svg { flex: 0 0 auto; margin-top: 3px; color: var(--accent-bright); } @@ -339,6 +356,10 @@ input[type="range"]:hover::-webkit-slider-thumb, input[type="range"]:focus-visib .source-switch { grid-template-columns: 1fr; } .folder-field { grid-template-columns: 1fr; } .form-row { grid-template-columns: 1fr; } + .pairing-panel-head, .pairing-code-row { align-items: flex-start; flex-direction: column; gap: 10px; } + .pairing-entry { grid-template-columns: 1fr; } + .pairing-entry .button-primary, .pairing-start { width: 100%; } + .pairing-actions { align-items: stretch; flex-direction: column; } .form-actions { flex-direction: column-reverse; } .form-actions button { width: 100%; } .player-bar { grid-template-columns: minmax(0, 1fr) auto; gap: 6px; padding: 8px 10px; }