Add secure five-digit LAN pairing with automatic host discovery, encrypted credential handoff, polished host/client setup UI, persistence, tests, and documentation.
Hop-State: A_06FNQN729W1JAHZXV6PY1N0 Hop-Proposal: R_06FNQN68SGDZRN85WXQGSWG Hop-Task: T_06FNQHGRCHN66DEC5171JF8 Hop-Attempt: AT_06FNQHGRCHPM6DR3BVFFMXG
This commit is contained in:
@@ -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
|
- 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
|
- 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
|
- 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
|
- 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 charcoal and onyx interface with coral reserved for active state and primary action
|
||||||
- A Tauri 2 desktop shell for macOS, Windows, and Linux development
|
- 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.
|
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.
|
- **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.
|
||||||
- **Existing server:** enter the URL and credentials for a Navidrome or OpenSubsonic server you already run.
|
- **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.
|
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
|
│ ├── library and artwork requests
|
||||||
│ └── authenticated stream URLs
|
│ └── authenticated stream URLs
|
||||||
└── Tauri shell
|
└── Tauri shell
|
||||||
|
├── one-time LAN pairing service
|
||||||
|
│ └── encrypted credential handoff
|
||||||
└── managed Navidrome sidecar
|
└── managed Navidrome sidecar
|
||||||
├── official, pinned, checksum-verified binary
|
├── official, pinned, checksum-verified binary
|
||||||
├── bundled FFmpeg transcoder
|
├── bundled FFmpeg transcoder
|
||||||
@@ -79,7 +82,9 @@ The protocol boundary is deliberate. Resonant owns the product experience withou
|
|||||||
|
|
||||||
## Network boundary
|
## 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
|
## Next milestones
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
"desktop:dev": "npm run sidecar:prepare && tauri dev",
|
"desktop:dev": "npm run sidecar:prepare && tauri dev",
|
||||||
"desktop:build": "npm run sidecar:prepare && tauri build",
|
"desktop:build": "npm run sidecar:prepare && tauri build",
|
||||||
"tauri": "npm run sidecar:prepare && tauri",
|
"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": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
|
|||||||
Generated
+121
@@ -8,6 +8,16 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
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]]
|
[[package]]
|
||||||
name = "aes"
|
name = "aes"
|
||||||
version = "0.8.4"
|
version = "0.8.4"
|
||||||
@@ -493,6 +503,30 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
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]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.45"
|
version = "0.4.45"
|
||||||
@@ -513,6 +547,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
"inout",
|
"inout",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -624,6 +659,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
|
"rand_core",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -666,6 +702,32 @@ version = "0.0.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
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]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.23.0"
|
version = "0.23.0"
|
||||||
@@ -1004,6 +1066,12 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fiat-crypto"
|
||||||
|
version = "0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "field-offset"
|
name = "field-offset"
|
||||||
version = "0.3.6"
|
version = "0.3.6"
|
||||||
@@ -2438,6 +2506,12 @@ version = "1.21.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opaque-debug"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "open"
|
name = "open"
|
||||||
version = "5.4.0"
|
version = "5.4.0"
|
||||||
@@ -2664,6 +2738,17 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -2777,6 +2862,15 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
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]]
|
[[package]]
|
||||||
name = "raw-window-handle"
|
name = "raw-window-handle"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -2890,9 +2984,14 @@ dependencies = [
|
|||||||
name = "resonant"
|
name = "resonant"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chacha20poly1305",
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"hkdf",
|
||||||
"keyring",
|
"keyring",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"spake2",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
@@ -3393,6 +3492,18 @@ dependencies = [
|
|||||||
"system-deps",
|
"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]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -4293,6 +4404,16 @@ version = "1.13.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
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]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
|
|||||||
@@ -13,9 +13,14 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
chacha20poly1305 = "0.10"
|
||||||
|
getrandom = "0.3"
|
||||||
|
hkdf = "0.12"
|
||||||
keyring = "4.1"
|
keyring = "4.1"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
sha2 = "0.10"
|
||||||
|
spake2 = { version = "0.4", features = ["getrandom"] }
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
|
|||||||
+62
-4
@@ -1,4 +1,7 @@
|
|||||||
|
mod pairing;
|
||||||
|
|
||||||
use keyring::{Entry, Error as KeyringError};
|
use keyring::{Entry, Error as KeyringError};
|
||||||
|
use pairing::{PairedServerConfig, PairingServiceState, PairingState, PairingStatus};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::{fs, net::UdpSocket, path::PathBuf, sync::Mutex};
|
use std::{fs, net::UdpSocket, path::PathBuf, sync::Mutex};
|
||||||
use tauri::{AppHandle, Manager, State, WindowEvent};
|
use tauri::{AppHandle, Manager, State, WindowEvent};
|
||||||
@@ -10,8 +13,8 @@ use tauri_plugin_shell::{
|
|||||||
const NAVIDROME_VERSION: &str = "0.61.2";
|
const NAVIDROME_VERSION: &str = "0.61.2";
|
||||||
const MANAGED_SERVER_URL: &str = "http://127.0.0.1:4533";
|
const MANAGED_SERVER_URL: &str = "http://127.0.0.1:4533";
|
||||||
const CREDENTIAL_SERVICE: &str = "xyz.gnosyslabs.resonant";
|
const CREDENTIAL_SERVICE: &str = "xyz.gnosyslabs.resonant";
|
||||||
const LOCAL_CREDENTIAL: &str = "local-admin";
|
pub(crate) const LOCAL_CREDENTIAL: &str = "local-admin";
|
||||||
const REMOTE_CREDENTIAL: &str = "remote-server";
|
pub(crate) const REMOTE_CREDENTIAL: &str = "remote-server";
|
||||||
|
|
||||||
struct ManagedServer {
|
struct ManagedServer {
|
||||||
child: CommandChild,
|
child: CommandChild,
|
||||||
@@ -46,7 +49,7 @@ fn credential_entry(account: &str) -> Result<Entry, String> {
|
|||||||
.map_err(|error| format!("The system credential store is unavailable: {error}"))
|
.map_err(|error| format!("The system credential store is unavailable: {error}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_password(account: &str) -> Result<Option<String>, String> {
|
pub(crate) fn read_password(account: &str) -> Result<Option<String>, String> {
|
||||||
match credential_entry(account)?.get_password() {
|
match credential_entry(account)?.get_password() {
|
||||||
Ok(password) => Ok(Some(password)),
|
Ok(password) => Ok(Some(password)),
|
||||||
Err(KeyringError::NoEntry) => Ok(None),
|
Err(KeyringError::NoEntry) => Ok(None),
|
||||||
@@ -54,7 +57,7 @@ fn read_password(account: &str) -> Result<Option<String>, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_password(account: &str, password: &str) -> Result<(), String> {
|
pub(crate) fn write_password(account: &str, password: &str) -> Result<(), String> {
|
||||||
credential_entry(account)?
|
credential_entry(account)?
|
||||||
.set_password(password)
|
.set_password(password)
|
||||||
.map_err(|error| format!("The credential could not be saved securely: {error}"))
|
.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)
|
write_password(REMOTE_CREDENTIAL, &password)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn pairing_status(
|
||||||
|
state: State<'_, PairingState>,
|
||||||
|
service: State<'_, PairingServiceState>,
|
||||||
|
) -> Result<PairingStatus, String> {
|
||||||
|
pairing::status(state.inner(), service.inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn begin_device_pairing(
|
||||||
|
managed_server: State<'_, ManagedServerState>,
|
||||||
|
state: State<'_, PairingState>,
|
||||||
|
service: State<'_, PairingServiceState>,
|
||||||
|
) -> Result<PairingStatus, String> {
|
||||||
|
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<PairingStatus, String> {
|
||||||
|
pairing::cancel(state.inner(), service.inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn pair_with_code(code: String) -> Result<PairedServerConfig, String> {
|
||||||
|
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> {
|
fn stop_server(state: &ManagedServerState) -> Result<(), String> {
|
||||||
let mut server = state
|
let mut server = state
|
||||||
.0
|
.0
|
||||||
@@ -145,6 +185,7 @@ fn managed_server_status(
|
|||||||
fn start_managed_server(
|
fn start_managed_server(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, ManagedServerState>,
|
state: State<'_, ManagedServerState>,
|
||||||
|
pairing_state: State<'_, PairingState>,
|
||||||
music_folder: String,
|
music_folder: String,
|
||||||
password: String,
|
password: String,
|
||||||
) -> Result<ManagedServerStatus, String> {
|
) -> Result<ManagedServerStatus, String> {
|
||||||
@@ -159,6 +200,7 @@ fn start_managed_server(
|
|||||||
return Err("Choose a folder that contains your music.".to_string());
|
return Err("Choose a folder that contains your music.".to_string());
|
||||||
}
|
}
|
||||||
write_password(LOCAL_CREDENTIAL, &password)?;
|
write_password(LOCAL_CREDENTIAL, &password)?;
|
||||||
|
pairing::clear(pairing_state.inner());
|
||||||
|
|
||||||
stop_server(&state)?;
|
stop_server(&state)?;
|
||||||
|
|
||||||
@@ -219,6 +261,8 @@ fn start_managed_server(
|
|||||||
server.take();
|
server.take();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let pairing_state = event_app.state::<PairingState>();
|
||||||
|
pairing::clear(pairing_state.inner());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,7 +280,9 @@ fn start_managed_server(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn stop_managed_server(
|
fn stop_managed_server(
|
||||||
state: State<'_, ManagedServerState>,
|
state: State<'_, ManagedServerState>,
|
||||||
|
pairing_state: State<'_, PairingState>,
|
||||||
) -> Result<ManagedServerStatus, String> {
|
) -> Result<ManagedServerStatus, String> {
|
||||||
|
pairing::clear(pairing_state.inner());
|
||||||
stop_server(&state)?;
|
stop_server(&state)?;
|
||||||
Ok(status_for(None))
|
Ok(status_for(None))
|
||||||
}
|
}
|
||||||
@@ -247,10 +293,20 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.manage(ManagedServerState::default())
|
.manage(ManagedServerState::default())
|
||||||
|
.manage(PairingState::default())
|
||||||
|
.manage(PairingServiceState::default())
|
||||||
|
.setup(|app| {
|
||||||
|
pairing::start_service(app.handle().clone());
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
managed_server_status,
|
managed_server_status,
|
||||||
load_saved_credentials,
|
load_saved_credentials,
|
||||||
save_remote_password,
|
save_remote_password,
|
||||||
|
pairing_status,
|
||||||
|
begin_device_pairing,
|
||||||
|
cancel_device_pairing,
|
||||||
|
pair_with_code,
|
||||||
start_managed_server,
|
start_managed_server,
|
||||||
stop_managed_server
|
stop_managed_server
|
||||||
])
|
])
|
||||||
@@ -258,6 +314,8 @@ pub fn run() {
|
|||||||
if matches!(event, WindowEvent::Destroyed) {
|
if matches!(event, WindowEvent::Destroyed) {
|
||||||
let state = window.app_handle().state::<ManagedServerState>();
|
let state = window.app_handle().state::<ManagedServerState>();
|
||||||
let _ = stop_server(&state);
|
let _ = stop_server(&state);
|
||||||
|
let pairing_state = window.app_handle().state::<PairingState>();
|
||||||
|
pairing::clear(pairing_state.inner());
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
|
|||||||
@@ -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<PairingSession>,
|
||||||
|
paired: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PairingSession {
|
||||||
|
code: String,
|
||||||
|
expires_at: SystemTime,
|
||||||
|
attempts: u8,
|
||||||
|
generation: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct PairingState(Mutex<PairingData>);
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ServiceHealth {
|
||||||
|
available: bool,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct PairingServiceState(Mutex<ServiceHealth>);
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct PairingStatus {
|
||||||
|
available: bool,
|
||||||
|
active: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
code: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
expires_at: Option<u64>,
|
||||||
|
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<u32, String> {
|
||||||
|
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<u64, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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::<Sha256>::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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<PairingStatus, String> {
|
||||||
|
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<PairingStatus, String> {
|
||||||
|
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<PairingStatus, String> {
|
||||||
|
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::<PairingState>();
|
||||||
|
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::<PairingState>();
|
||||||
|
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::<PairingState>();
|
||||||
|
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::<Ed25519Group>::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::<PairingServiceState>();
|
||||||
|
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<Vec<SocketAddr>, 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<PairedServerConfig, String> {
|
||||||
|
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::<Ed25519Group>::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<PairedServerConfig, String> {
|
||||||
|
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::<Ed25519Group>::start_a(
|
||||||
|
&Password::new(client_code.as_bytes()),
|
||||||
|
&Identity::new(CLIENT_ID),
|
||||||
|
&Identity::new(HOST_ID),
|
||||||
|
);
|
||||||
|
let (host, host_message) = Spake2::<Ed25519Group>::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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,13 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
|
|||||||
|
|
||||||
const managedServer = vi.hoisted(() => ({
|
const managedServer = vi.hoisted(() => ({
|
||||||
chooseMusicFolder: vi.fn(),
|
chooseMusicFolder: vi.fn(),
|
||||||
|
beginDevicePairing: vi.fn(),
|
||||||
|
cancelDevicePairing: vi.fn(),
|
||||||
getManagedServerStatus: vi.fn(),
|
getManagedServerStatus: vi.fn(),
|
||||||
|
getPairingStatus: vi.fn(),
|
||||||
getSavedCredentials: vi.fn(),
|
getSavedCredentials: vi.fn(),
|
||||||
isDesktopRuntime: vi.fn(),
|
isDesktopRuntime: vi.fn(),
|
||||||
|
pairWithCode: vi.fn(),
|
||||||
saveRemotePassword: vi.fn(),
|
saveRemotePassword: vi.fn(),
|
||||||
startManagedServer: vi.fn(),
|
startManagedServer: vi.fn(),
|
||||||
stopManagedServer: vi.fn(),
|
stopManagedServer: vi.fn(),
|
||||||
@@ -55,6 +59,7 @@ describe("App bootstrap", () => {
|
|||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
managedServer.isDesktopRuntime.mockReturnValue(false);
|
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", () => {
|
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();
|
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(<App />);
|
||||||
|
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 () => {
|
it("defaults Albums and Songs to A–Z and remembers reordered views", async () => {
|
||||||
await renderDemoApp();
|
await renderDemoApp();
|
||||||
|
|
||||||
|
|||||||
+117
-8
@@ -49,12 +49,17 @@ import { fetchAllPages, pollForLibrary } from "./lib/librarySync";
|
|||||||
import {
|
import {
|
||||||
chooseMusicFolder,
|
chooseMusicFolder,
|
||||||
getManagedServerStatus,
|
getManagedServerStatus,
|
||||||
|
getPairingStatus,
|
||||||
getSavedCredentials,
|
getSavedCredentials,
|
||||||
|
beginDevicePairing,
|
||||||
|
cancelDevicePairing,
|
||||||
isDesktopRuntime,
|
isDesktopRuntime,
|
||||||
|
pairWithCode,
|
||||||
saveRemotePassword,
|
saveRemotePassword,
|
||||||
startManagedServer,
|
startManagedServer,
|
||||||
stopManagedServer,
|
stopManagedServer,
|
||||||
type ManagedServerStatus,
|
type ManagedServerStatus,
|
||||||
|
type PairingStatus,
|
||||||
} from "./lib/managedServer";
|
} from "./lib/managedServer";
|
||||||
import { buildApiUrl, requestSubsonic, setSubsonicFavorite, streamUrl, submitSubsonicScrobble } from "./lib/subsonic";
|
import { buildApiUrl, requestSubsonic, setSubsonicFavorite, streamUrl, submitSubsonicScrobble } from "./lib/subsonic";
|
||||||
import type { Album, ConnectionStatus, CoverTone, ServerConfig, Track, View } from "./types";
|
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[] }
|
interface LoadedLibrary { albums: Album[]; tracks: Track[] }
|
||||||
type LibraryPhase = "ready" | "scanning" | "empty";
|
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() {
|
export default function App() {
|
||||||
const [view, setView] = useState<View>("home");
|
const [view, setView] = useState<View>("home");
|
||||||
const [mobileNav, setMobileNav] = useState(false);
|
const [mobileNav, setMobileNav] = useState(false);
|
||||||
@@ -167,6 +176,12 @@ export default function App() {
|
|||||||
const [libraryPhase, setLibraryPhase] = useState<LibraryPhase>("ready");
|
const [libraryPhase, setLibraryPhase] = useState<LibraryPhase>("ready");
|
||||||
const [sourceMode, setSourceMode] = useState<"local" | "remote">(() => localStorage.getItem("resonant.sourceMode") === "remote" ? "remote" : "local");
|
const [sourceMode, setSourceMode] = useState<"local" | "remote">(() => localStorage.getItem("resonant.sourceMode") === "remote" ? "remote" : "local");
|
||||||
const [managedStatus, setManagedStatus] = useState<ManagedServerStatus | null>(null);
|
const [managedStatus, setManagedStatus] = useState<ManagedServerStatus | null>(null);
|
||||||
|
const [hostPairing, setHostPairing] = useState<PairingStatus | null>(null);
|
||||||
|
const [hostPairingError, setHostPairingError] = useState<string | null>(null);
|
||||||
|
const [pairingCode, setPairingCode] = useState("");
|
||||||
|
const [pairingBusy, setPairingBusy] = useState(false);
|
||||||
|
const [pairingError, setPairingError] = useState<string | null>(null);
|
||||||
|
const [pairingNow, setPairingNow] = useState(Date.now());
|
||||||
const [localSetup, setLocalSetup] = useState({
|
const [localSetup, setLocalSetup] = useState({
|
||||||
musicFolder: localStorage.getItem("resonant.musicFolder") ?? "",
|
musicFolder: localStorage.getItem("resonant.musicFolder") ?? "",
|
||||||
password: "",
|
password: "",
|
||||||
@@ -391,6 +406,7 @@ export default function App() {
|
|||||||
|
|
||||||
const connect = async (event: FormEvent) => {
|
const connect = async (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
setPairingError(null);
|
||||||
setConnectionStatus("connecting");
|
setConnectionStatus("connecting");
|
||||||
setConnectionMessage("Reaching your library…");
|
setConnectionMessage("Reaching your library…");
|
||||||
try {
|
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 () => {
|
const pickMusicFolder = async () => {
|
||||||
if (!isDesktopRuntime()) {
|
if (!isDesktopRuntime()) {
|
||||||
setConnectionStatus("error");
|
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) => {
|
const goTo = (next: View) => {
|
||||||
if (next === "home") {
|
if (next === "home") {
|
||||||
setSpotlightTrackId((current) => pickRandomTrack(libraryTracks, current)?.id ?? "");
|
setSpotlightTrackId((current) => pickRandomTrack(libraryTracks, current)?.id ?? "");
|
||||||
@@ -616,7 +709,7 @@ export default function App() {
|
|||||||
|
|
||||||
<div className="source-switch" role="group" aria-label="Library source">
|
<div className="source-switch" role="group" aria-label="Library source">
|
||||||
<button type="button" aria-pressed={sourceMode === "local"} onClick={() => setSourceMode("local")}><HardDrive size={18} /><span><strong>This computer</strong><small>Managed by Resonant</small></span></button>
|
<button type="button" aria-pressed={sourceMode === "local"} onClick={() => setSourceMode("local")}><HardDrive size={18} /><span><strong>This computer</strong><small>Managed by Resonant</small></span></button>
|
||||||
<button type="button" aria-pressed={sourceMode === "remote"} onClick={() => setSourceMode("remote")}><Server size={18} /><span><strong>Existing server</strong><small>Navidrome or OpenSubsonic</small></span></button>
|
<button type="button" aria-pressed={sourceMode === "remote"} onClick={() => setSourceMode("remote")}><Server size={18} /><span><strong>Another computer</strong><small>Pair or connect manually</small></span></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sourceMode === "local" ? (
|
{sourceMode === "local" ? (
|
||||||
@@ -625,7 +718,14 @@ export default function App() {
|
|||||||
<label>Music folder<div className="folder-field"><input required readOnly placeholder="Choose the folder that holds your music" value={localSetup.musicFolder} /><button type="button" className="button-secondary" onClick={pickMusicFolder}><FolderOpen size={16} /> Choose folder</button></div></label>
|
<label>Music folder<div className="folder-field"><input required readOnly placeholder="Choose the folder that holds your music" value={localSetup.musicFolder} /><button type="button" className="button-secondary" onClick={pickMusicFolder}><FolderOpen size={16} /> Choose folder</button></div></label>
|
||||||
<label>Library password<input type="password" required minLength={8} autoComplete="current-password" placeholder="At least 8 characters" value={localSetup.password} onChange={(event) => setLocalSetup({ ...localSetup, password: event.target.value })} /><small className="field-hint">Saved in your operating system credential store and used by your other music apps.</small></label>
|
<label>Library password<input type="password" required minLength={8} autoComplete="current-password" placeholder="At least 8 characters" value={localSetup.password} onChange={(event) => setLocalSetup({ ...localSetup, password: event.target.value })} /><small className="field-hint">Saved in your operating system credential store and used by your other music apps.</small></label>
|
||||||
<div className={`connection-feedback connection-feedback--${connectionStatus}`} role="status"><span className={`status-dot status-dot--${connectionStatus}`} />{connectionMessage}</div>
|
<div className={`connection-feedback connection-feedback--${connectionStatus}`} role="status"><span className={`status-dot status-dot--${connectionStatus}`} />{connectionMessage}</div>
|
||||||
{managedStatus?.running && managedStatus.lanUrls.length > 0 && <div className="device-addresses"><strong>Listen from another computer</strong><p>Use this address in Resonant or any OpenSubsonic app on the same private network. Sign in as <b>admin</b> with your library password.</p>{managedStatus.lanUrls.map((url) => <code key={url}>{url}</code>)}</div>}
|
{managedStatus?.running && <div className="pairing-panel">
|
||||||
|
<div className="pairing-panel-head"><div><strong>Pair another computer</strong><p>Open Resonant there and enter a temporary code. Your server credentials stay hidden.</p></div>{hostPairing?.paired && <span className="pairing-success"><Check size={15} /> Device connected</span>}</div>
|
||||||
|
{hostPairing?.active && hostPairing.code ? <>
|
||||||
|
<div className="pairing-code-row"><output className="pairing-code" aria-label="Pairing code">{hostPairing.code}</output><span>Expires in {formatPairingTime(Math.max(0, Math.ceil(((hostPairing.expiresAt ?? pairingNow) - pairingNow) / 1000)))}<small>{hostPairing.attemptsRemaining} attempts remaining</small></span></div>
|
||||||
|
<div className="pairing-actions"><button type="button" className="button-secondary" onClick={showPairingCode}>New code</button><button type="button" className="button-quiet" onClick={cancelPairing}>Cancel pairing</button></div>
|
||||||
|
</> : <button type="button" className="button-secondary pairing-start" onClick={showPairingCode}>Show pairing code</button>}
|
||||||
|
{hostPairingError && <p className="pairing-error" role="alert">{hostPairingError}</p>}
|
||||||
|
</div>}
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
{managedStatus?.running && <button type="button" className="button-secondary button-danger" onClick={stopLocalLibrary}><Power size={16} /> Stop server</button>}
|
{managedStatus?.running && <button type="button" className="button-secondary button-danger" onClick={stopLocalLibrary}><Power size={16} /> Stop server</button>}
|
||||||
<button type="button" className="button-secondary" onClick={() => goTo("home")}>Cancel</button>
|
<button type="button" className="button-secondary" onClick={() => goTo("home")}>Cancel</button>
|
||||||
@@ -633,12 +733,21 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<form className="connection-form" onSubmit={connect}>
|
<div className="remote-connection-stack">
|
||||||
<label>Server address<input type="url" required placeholder="https://music.example.com" value={server.url} onChange={(event) => setServer({ ...server, url: event.target.value })} /></label>
|
<form className="connection-form pairing-form" onSubmit={pairComputer}>
|
||||||
<div className="form-row"><label>Username<input required autoComplete="username" value={server.username} onChange={(event) => setServer({ ...server, username: event.target.value })} /></label><label>Password<input type="password" required autoComplete="current-password" value={server.password} onChange={(event) => setServer({ ...server, password: event.target.value })} /></label></div>
|
<div className="pairing-heading"><div><strong>Pair with a Resonant host</strong><p>Make sure both computers are on the same network, then enter the code shown by the host.</p></div><span>5 digits</span></div>
|
||||||
<div className={`connection-feedback connection-feedback--${connectionStatus}`} role="status"><span className={`status-dot status-dot--${connectionStatus}`} />{connectionMessage}</div>
|
<label>Pairing code<div className="pairing-entry"><input inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{5}" maxLength={5} placeholder="12345" value={pairingCode} onChange={(event) => setPairingCode(event.target.value.replace(/\D/g, "").slice(0, 5))} /><button type="submit" className="button-primary" disabled={pairingBusy || pairingCode.length !== 5}>{pairingBusy ? "Pairing…" : "Pair computer"}<ChevronRight size={17} /></button></div></label>
|
||||||
<div className="form-actions"><button type="button" className="button-secondary" onClick={() => goTo("home")}>Cancel</button><button type="submit" className="button-primary" disabled={connectionStatus === "connecting"}>{connectionStatus === "connecting" ? "Connecting…" : "Connect library"}<ChevronRight size={17} /></button></div>
|
{pairingError && <p className="pairing-error" role="alert">{pairingError}</p>}
|
||||||
</form>
|
</form>
|
||||||
|
<div className="connection-divider"><span>or connect manually</span></div>
|
||||||
|
<form className="connection-form" onSubmit={connect}>
|
||||||
|
<div className="pairing-heading"><div><strong>Navidrome or OpenSubsonic server</strong><p>Use this for an existing server that is not hosted by Resonant.</p></div></div>
|
||||||
|
<label>Server address<input type="url" required placeholder="https://music.example.com" value={server.url} onChange={(event) => setServer({ ...server, url: event.target.value })} /></label>
|
||||||
|
<div className="form-row"><label>Username<input required autoComplete="username" value={server.username} onChange={(event) => setServer({ ...server, username: event.target.value })} /></label><label>Password<input type="password" required autoComplete="current-password" value={server.password} onChange={(event) => setServer({ ...server, password: event.target.value })} /></label></div>
|
||||||
|
{!pairingError && <div className={`connection-feedback connection-feedback--${connectionStatus}`} role="status"><span className={`status-dot status-dot--${connectionStatus}`} />{connectionMessage}</div>}
|
||||||
|
<div className="form-actions"><button type="button" className="button-secondary" onClick={() => goTo("home")}>Cancel</button><button type="submit" className="button-primary" disabled={connectionStatus === "connecting"}>{connectionStatus === "connecting" ? "Connecting…" : "Connect library"}<ChevronRight size={17} /></button></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="privacy-note"><Disc3 size={22} /><div><strong>{sourceMode === "local" ? "Available on your private network" : "Your server stays yours"}</strong><p>{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."}</p></div></div>
|
<div className="privacy-note"><Disc3 size={22} /><div><strong>{sourceMode === "local" ? "Available on your private network" : "Your server stays yours"}</strong><p>{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."}</p></div></div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { getManagedServerStatus, getSavedCredentials, isDesktopRuntime } from "./managedServer";
|
import { getManagedServerStatus, getPairingStatus, getSavedCredentials, isDesktopRuntime, pairWithCode } from "./managedServer";
|
||||||
|
|
||||||
describe("managed server bridge", () => {
|
describe("managed server bridge", () => {
|
||||||
it("reports the browser preview as a non-desktop runtime", () => {
|
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 () => {
|
it("does not expose credentials in the browser preview", async () => {
|
||||||
await expect(getSavedCredentials()).resolves.toEqual({});
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,28 @@ export interface SavedCredentials {
|
|||||||
remotePassword?: string;
|
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 {
|
export function isDesktopRuntime(): boolean {
|
||||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||||
}
|
}
|
||||||
@@ -45,6 +67,26 @@ export async function saveRemotePassword(password: string): Promise<void> {
|
|||||||
await invoke("save_remote_password", { password });
|
await invoke("save_remote_password", { password });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPairingStatus(): Promise<PairingStatus> {
|
||||||
|
if (!isDesktopRuntime()) return unavailablePairingStatus;
|
||||||
|
return invoke<PairingStatus>("pairing_status");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function beginDevicePairing(): Promise<PairingStatus> {
|
||||||
|
if (!isDesktopRuntime()) throw new Error("Device pairing is available in the Resonant desktop app.");
|
||||||
|
return invoke<PairingStatus>("begin_device_pairing");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelDevicePairing(): Promise<PairingStatus> {
|
||||||
|
if (!isDesktopRuntime()) return unavailablePairingStatus;
|
||||||
|
return invoke<PairingStatus>("cancel_device_pairing");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pairWithCode(code: string): Promise<PairedServerConfig> {
|
||||||
|
if (!isDesktopRuntime()) throw new Error("Device pairing is available in the Resonant desktop app.");
|
||||||
|
return invoke<PairedServerConfig>("pair_with_code", { code });
|
||||||
|
}
|
||||||
|
|
||||||
export async function startManagedServer(musicFolder: string, password: string): Promise<ManagedServerStatus> {
|
export async function startManagedServer(musicFolder: string, password: string): Promise<ManagedServerStatus> {
|
||||||
if (!isDesktopRuntime()) throw new Error("The managed library is available in the Resonant desktop app.");
|
if (!isDesktopRuntime()) throw new Error("The managed library is available in the Resonant desktop app.");
|
||||||
return invoke<ManagedServerStatus>("start_managed_server", { musicFolder, password });
|
return invoke<ManagedServerStatus>("start_managed_server", { musicFolder, password });
|
||||||
|
|||||||
+25
-4
@@ -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 { 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--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); }
|
.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); }
|
.remote-connection-stack { display: grid; gap: 14px; }
|
||||||
.device-addresses strong { font-size: 0.75rem; }
|
.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); }
|
||||||
.device-addresses p { margin: 0 0 3px; color: var(--muted); font-size: 0.68rem; line-height: 1.5; }
|
.pairing-panel-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||||
.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; }
|
.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); }
|
.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 { 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); }
|
.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; }
|
.source-switch { grid-template-columns: 1fr; }
|
||||||
.folder-field { grid-template-columns: 1fr; }
|
.folder-field { grid-template-columns: 1fr; }
|
||||||
.form-row { 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 { flex-direction: column-reverse; }
|
||||||
.form-actions button { width: 100%; }
|
.form-actions button { width: 100%; }
|
||||||
.player-bar { grid-template-columns: minmax(0, 1fr) auto; gap: 6px; padding: 8px 10px; }
|
.player-bar { grid-template-columns: minmax(0, 1fr) auto; gap: 6px; padding: 8px 10px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user