feat(qbz-slint): QConnect transport config + credential discovery (slice 6, phase S)

Ports the Tauri qconnect/transport.rs config layer to Slint: resolve_transport_config
(env overrides -> qws/createToken auto-discovery -> WsTransportConfig with require_jwt
+ 60s idle-retry + default channels) and fetch_qconnect_transport_credentials (POST
/qws/createToken with X-App-Id / X-User-Auth-Token), plus the device-identity helpers
(uuid via shared qbz_app::qconnect_identity, hostname, friendly name, persisted
device-name in qconnect_settings.db) and the hex-channel parsers.

Frontend difference handled: the Qobuz client is reached via runtime.core().client()
(Arc<RwLock<Option<QobuzClient>>>, may be None before API init) and is gated/ok_or'd,
unlike Tauri's always-present AppState.client. Uses qbz_qobuz::endpoints::build_url
(same base) instead of the Tauri-local one.

Deps added: qbz-qobuz (build_url), qconnect-transport-ws (WsTransportConfig /
NativeWsTransport). allow(dead_code) until the service wires it. cargo check
-p qbz-slint green (only the pre-existing unrelated rename_folder warning).

cc
This commit is contained in:
vicrodh committed 2026-06-02 20:52:51 -06:00
1 parent 88e72c3e61
commit 8e61568dc0
4 files changed
+344

No files matched your search

+1
View File
@@ -40,6 +40,7 @@ mod play_history;
mod strip_html;
mod playback;
mod qconnect_engine;
mod qconnect_transport;
mod queue;
mod drag;
mod folders;
+339
View File
@@ -0,0 +1,339 @@
//! QConnect transport config + credential discovery + device identity for the
//! Slint frontend (slice 6, Phase S).
//!
//! Ports the Tauri `src-tauri/src/qconnect/transport.rs` config layer. The only
//! frontend-specific change: the Qobuz client is reached via
//! `runtime.core().client()` — an `Arc<RwLock<Option<QobuzClient>>>` (may be
//! `None` before the API is initialized) — instead of Tauri's always-present
//! `AppState.client`. The device-uuid + settings-DB path delegate to the shared
//! `qbz_app::qconnect_identity` so Tauri and Slint resolve the SAME identity.
//!
//! Wired by the Slint QConnect service (next Phase S step); unused until then.
#![allow(dead_code)]
use std::sync::Arc;
use qbz_app::shell::AppRuntime;
use qconnect_transport_ws::WsTransportConfig;
use serde_json::Value;
use crate::adapter::SlintAdapter;
const DEFAULT_QCONNECT_DEVICE_BRAND: &str = "QBZ";
const DEFAULT_QCONNECT_DEVICE_MODEL: &str = "QBZ";
const DEFAULT_QCONNECT_DEVICE_TYPE: i32 = 5; // computer
const DEFAULT_QCONNECT_SOFTWARE_PREFIX: &str = "qbz";
const QCONNECT_QWS_TOKEN_KIND: &str = "jwt_qws";
const QCONNECT_QWS_CREATE_TOKEN_PATH: &str = "/qws/createToken";
type Runtime = Arc<AppRuntime<SlintAdapter>>;
/// Persistent QConnect device identity — delegates to the shared module so the
/// Slint install resolves the SAME uuid as Tauri (same DB path/key/env override).
pub fn resolve_qconnect_device_uuid() -> String {
qbz_app::qconnect_identity::resolve_qconnect_device_uuid()
}
/// `qbz/<version>` software-version string for the device-info payload.
pub fn resolve_qconnect_software_version() -> String {
std::env::var("QBZ_QCONNECT_SOFTWARE_VERSION")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| {
format!(
"{DEFAULT_QCONNECT_SOFTWARE_PREFIX}/{}",
env!("CARGO_PKG_VERSION")
)
})
}
pub fn resolve_qconnect_device_brand() -> String {
std::env::var("QBZ_QCONNECT_DEVICE_BRAND")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_QCONNECT_DEVICE_BRAND.to_string())
}
pub fn resolve_qconnect_device_model() -> String {
std::env::var("QBZ_QCONNECT_DEVICE_MODEL")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_QCONNECT_DEVICE_MODEL.to_string())
}
pub fn resolve_qconnect_device_type() -> i32 {
std::env::var("QBZ_QCONNECT_DEVICE_TYPE")
.ok()
.and_then(|value| value.trim().parse::<i32>().ok())
.unwrap_or(DEFAULT_QCONNECT_DEVICE_TYPE)
}
pub fn resolve_system_hostname() -> String {
if let Ok(h) = std::env::var("HOSTNAME") {
if !h.trim().is_empty() {
return h.trim().to_string();
}
}
if let Ok(h) = std::fs::read_to_string("/etc/hostname") {
let trimmed = h.trim().to_string();
if !trimmed.is_empty() {
return trimmed;
}
}
"Desktop".to_string()
}
/// Returns "Qbz - {hostname}" as the default device name.
pub fn resolve_default_qconnect_device_name() -> String {
format!("Qbz - {}", resolve_system_hostname())
}
/// Effective friendly name: custom override -> env -> "Qbz - {hostname}".
pub fn resolve_qconnect_friendly_name(custom_name: Option<&str>) -> String {
custom_name
.filter(|s| !s.trim().is_empty())
.map(|s| s.to_string())
.or_else(|| {
std::env::var("QBZ_QCONNECT_DEVICE_NAME")
.ok()
.filter(|value| !value.trim().is_empty())
})
.unwrap_or_else(resolve_default_qconnect_device_name)
}
fn qconnect_settings_db_path() -> Option<std::path::PathBuf> {
qbz_app::qconnect_identity::qconnect_settings_db_path()
}
/// Load the persisted custom device name (fail-open: None on any error).
pub fn load_persisted_device_name() -> Option<String> {
let db_path = qconnect_settings_db_path()?;
let conn = rusqlite::Connection::open(&db_path).ok()?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.ok()?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)",
)
.ok()?;
conn.query_row(
"SELECT value FROM settings WHERE key = 'device_name'",
[],
|row| row.get::<_, String>(0),
)
.ok()
.filter(|v| !v.trim().is_empty())
}
/// Persist the custom device name (None clears it).
pub fn persist_device_name(name: Option<&str>) {
let Some(db_path) = qconnect_settings_db_path() else {
return;
};
let Ok(conn) = rusqlite::Connection::open(&db_path) else {
return;
};
let _ = conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;");
let _ = conn.execute_batch(
"CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)",
);
match name {
Some(n) => {
let _ = conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('device_name', ?1)",
rusqlite::params![n],
);
}
None => {
let _ = conn.execute("DELETE FROM settings WHERE key = 'device_name'", []);
}
}
}
/// Build the WS transport config: env overrides first, then auto-discovery via
/// `qws/createToken`. Mirrors the Tauri `resolve_transport_config` (the
/// per-option knobs are deferred — the Slint connect path uses defaults +
/// env). `require_jwt = true` (hard) and `reconnect_idle_retry_ms = 60s` match
/// the hardened Tauri defaults.
pub async fn resolve_transport_config(runtime: &Runtime) -> Result<WsTransportConfig, String> {
let mut endpoint_url = normalize_opt_string(std::env::var("QBZ_QCONNECT_WS_ENDPOINT").ok());
let mut jwt_qws = normalize_opt_string(std::env::var("QBZ_QCONNECT_JWT_QWS").ok())
.or_else(|| normalize_opt_string(std::env::var("QBZ_QCONNECT_JWT").ok()));
if endpoint_url.is_none() || jwt_qws.is_none() {
match fetch_qconnect_transport_credentials(runtime).await {
Ok((discovered_endpoint, discovered_jwt_qws)) => {
endpoint_url = endpoint_url.or(discovered_endpoint);
jwt_qws = jwt_qws.or(discovered_jwt_qws);
}
Err(err) if endpoint_url.is_some() => {
log::warn!(
"[QConnect] qws/createToken auto-discovery failed, using provided endpoint: {err}"
);
}
Err(err) => {
return Err(format!(
"QConnect endpoint_url is required (or QBZ_QCONNECT_WS_ENDPOINT). Auto-discovery via qws/createToken failed: {err}"
));
}
}
}
let endpoint_url = endpoint_url.ok_or_else(|| {
"QConnect endpoint_url is required (or QBZ_QCONNECT_WS_ENDPOINT)".to_string()
})?;
let subscribe_channels = if let Ok(raw) = std::env::var("QBZ_QCONNECT_SUBSCRIBE_CHANNELS_HEX") {
let channels: Vec<String> = raw
.split(',')
.map(str::trim)
.filter(|item| !item.is_empty())
.map(ToString::to_string)
.collect();
parse_subscribe_channels(channels)?
} else {
// Default QConnect channels: connectionId(0x01), backend(0x02), controllers(0x03)
vec![vec![0x01], vec![0x02], vec![0x03]]
};
let mut config = WsTransportConfig::default();
config.endpoint_url = endpoint_url;
config.jwt_qws = jwt_qws;
config.require_jwt = true;
config.reconnect_idle_retry_ms = 60_000;
config.subscribe_channels = subscribe_channels;
Ok(config)
}
async fn fetch_qconnect_transport_credentials(
runtime: &Runtime,
) -> Result<(Option<String>, Option<String>), String> {
// Slint difference: the client is Option-wrapped (None before API init).
let client = runtime
.core()
.client()
.read()
.await
.clone()
.ok_or_else(|| "qws/createToken requires an initialized API client".to_string())?;
let app_id = client
.app_id()
.await
.map_err(|err| format!("qws/createToken requires initialized API client: {err}"))?;
let user_auth_token = client
.auth_token()
.await
.map_err(|err| format!("qws/createToken requires authenticated user: {err}"))?;
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
"X-App-Id",
reqwest::header::HeaderValue::from_str(&app_id)
.map_err(|_| "invalid X-App-Id header value".to_string())?,
);
headers.insert(
"X-User-Auth-Token",
reqwest::header::HeaderValue::from_str(&user_auth_token)
.map_err(|_| "invalid X-User-Auth-Token header value".to_string())?,
);
let url = qbz_qobuz::endpoints::build_url(QCONNECT_QWS_CREATE_TOKEN_PATH);
let response = client
.get_http()
.post(&url)
.headers(headers)
.form(&[
("jwt", QCONNECT_QWS_TOKEN_KIND),
("user_auth_token_needed", "true"),
("strong_auth_needed", "true"),
])
.send()
.await
.map_err(|err| format!("qws/createToken HTTP request failed: {err}"))?;
let status = response.status();
let payload: Value = response
.json()
.await
.map_err(|err| format!("qws/createToken response decode failed: {err}"))?;
if !status.is_success() {
let preview = serde_json::to_string(&payload)
.unwrap_or_else(|_| "<unserializable>".to_string())
.chars()
.take(300)
.collect::<String>();
return Err(format!("qws/createToken status {status}: {preview}"));
}
let jwt_qws_payload = payload
.get("jwt_qws")
.ok_or_else(|| "qws/createToken response missing jwt_qws payload".to_string())?;
let endpoint_url = normalize_opt_string(
jwt_qws_payload
.get("endpoint")
.and_then(Value::as_str)
.map(ToString::to_string),
);
let jwt_qws = normalize_opt_string(
jwt_qws_payload
.get("jwt")
.and_then(Value::as_str)
.map(ToString::to_string),
);
if endpoint_url.is_none() {
return Err("qws/createToken response missing jwt_qws.endpoint".to_string());
}
Ok((endpoint_url, jwt_qws))
}
fn normalize_opt_string(value: Option<String>) -> Option<String> {
value.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
pub fn parse_subscribe_channels(items: Vec<String>) -> Result<Vec<Vec<u8>>, String> {
items.into_iter().map(|item| decode_hex_channel(&item)).collect()
}
pub fn decode_hex_channel(raw: &str) -> Result<Vec<u8>, String> {
let normalized = raw.trim().trim_start_matches("0x").trim_start_matches("0X");
if normalized.is_empty() {
return Err("empty subscribe channel hex value".to_string());
}
let needs_padding = normalized.len() % 2 != 0;
let value = if needs_padding {
format!("0{normalized}")
} else {
normalized.to_string()
};
let mut bytes = Vec::with_capacity(value.len() / 2);
let chars: Vec<char> = value.chars().collect();
for idx in (0..chars.len()).step_by(2) {
let pair = [chars[idx], chars[idx + 1]];
let hex = pair.iter().collect::<String>();
let byte = u8::from_str_radix(&hex, 16)
.map_err(|_| format!("invalid subscribe channel hex byte '{hex}' in '{raw}'"))?;
bytes.push(byte);
}
Ok(bytes)
}