From c9285a33e9cd9324c76edf5b767a9641f0319e56 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:48:30 -0600 Subject: [PATCH] refactor(qconnect): extract device identity and transport config to transport.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move 14 items that own QConnect transport setup, device identity, and credential discovery into a dedicated transport submodule: Device identity (pub(super)): - default_qconnect_device_info / default_qconnect_device_info_with_name - resolve_qconnect_device_uuid (with QCONNECT_DEVICE_UUID OnceLock) - resolve_system_hostname, resolve_default_qconnect_device_name Custom-device-name persistence (pub(super)): - load_persisted_device_name, persist_device_name (qconnect_settings_db_path stays private to the submodule) Transport config + credentials (pub(super) + private): - resolve_transport_config (handles env-var fallbacks, qws/createToken auto-discovery, subscribe-channel parsing) - fetch_qconnect_transport_credentials (private) - normalize_opt_string, parse_subscribe_channels, decode_hex_channel (private) Diagnostics (pub(super)): - hex_preview (used by transport-event debug logs in mod.rs) Constants moved to transport.rs (private to the submodule): - DEFAULT_QCONNECT_DEVICE_BRAND/MODEL/TYPE/SOFTWARE_PREFIX - VOLUME_REMOTE_CONTROL_ALLOWED - QCONNECT_QWS_TOKEN_KIND, QCONNECT_QWS_CREATE_TOKEN_PATH Constants kept in mod.rs but promoted to pub(super) so transport.rs can borrow them: AUDIO_QUALITY_MP3, AUDIO_QUALITY_HIRES_LEVEL2. mod.rs imports OnceLock and Uuid removed (now confined to transport). Pure relocation. mod.rs: 4022 → 3673 lines. transport.rs: 386 lines. Total qconnect/ submodules now: 8 (types, session, queue_resolution, corebridge, track_loading, event_sink, transport, plus residual mod.rs). cc --- src-tauri/src/qconnect/mod.rs | 366 +------------------------- src-tauri/src/qconnect/transport.rs | 386 ++++++++++++++++++++++++++++ 2 files changed, 395 insertions(+), 357 deletions(-) create mode 100644 src-tauri/src/qconnect/transport.rs diff --git a/src-tauri/src/qconnect/mod.rs b/src-tauri/src/qconnect/mod.rs index e1da2d62e..3009a9a1d 100644 --- a/src-tauri/src/qconnect/mod.rs +++ b/src-tauri/src/qconnect/mod.rs @@ -3,11 +3,17 @@ mod event_sink; mod queue_resolution; mod session; mod track_loading; +mod transport; mod types; pub use session::{QconnectRendererInfo, QconnectSessionState}; pub use types::*; use corebridge::align_corebridge_queue_cursor; use event_sink::TauriQconnectEventSink; +use transport::{ + default_qconnect_device_info, default_qconnect_device_info_with_name, hex_preview, + load_persisted_device_name, persist_device_name, resolve_default_qconnect_device_name, + resolve_qconnect_device_uuid, resolve_system_hostname, resolve_transport_config, +}; use queue_resolution::{ find_cursor_index_by_track_id, resolve_controller_queue_item_from_snapshots, resolve_queue_item_ids_from_queue_state, QconnectRemoteSkipDirection, @@ -21,7 +27,7 @@ use session::{ use std::{ collections::HashMap, - sync::{Arc, OnceLock}, + sync::Arc, time::{Duration, SystemTime, UNIX_EPOCH}, }; @@ -48,28 +54,19 @@ pub(super) const PLAYING_STATE_STOPPED: i32 = 1; pub(super) const PLAYING_STATE_PLAYING: i32 = 2; pub(super) const PLAYING_STATE_PAUSED: i32 = 3; const BUFFER_STATE_OK: i32 = 2; -const QCONNECT_QWS_TOKEN_KIND: &str = "jwt_qws"; -const QCONNECT_QWS_CREATE_TOKEN_PATH: &str = "/qws/createToken"; -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_REMOTE_QUEUE_SOURCE: &str = "qobuz_connect_remote"; // AudioQuality enum: 0=unknown, 1=mp3, 2=cd, 3=hires_l1, 4=hires_l2(192k), 5=hires_l3(384k) const AUDIO_QUALITY_UNKNOWN: i32 = 0; -const AUDIO_QUALITY_MP3: i32 = 1; +pub(super) const AUDIO_QUALITY_MP3: i32 = 1; const AUDIO_QUALITY_CD: i32 = 2; const AUDIO_QUALITY_HIRES_LEVEL1: i32 = 3; -const AUDIO_QUALITY_HIRES_LEVEL2: i32 = 4; +pub(super) const AUDIO_QUALITY_HIRES_LEVEL2: i32 = 4; const AUDIO_QUALITY_HIRES_LEVEL3: i32 = 5; const DEFAULT_QCONNECT_CHANNEL_COUNT: i32 = 2; -// VolumeRemoteControl enum: 0=unknown, 1=not_allowed, 2=allowed -const VOLUME_REMOTE_CONTROL_ALLOWED: i32 = 2; // JoinSessionReason: 0=unknown, 1=controller_request, 2=reconnection const JOIN_SESSION_REASON_CONTROLLER_REQUEST: i32 = 1; const QCONNECT_PLAY_TRACK_HANDOFF_WAIT_MS: u64 = 1_500; const QCONNECT_PLAY_TRACK_HANDOFF_POLL_MS: u64 = 50; -static QCONNECT_DEVICE_UUID: OnceLock = OnceLock::new(); struct QconnectRuntime { @@ -1875,71 +1872,6 @@ async fn clear_pending_if_matches( } } -pub(super) fn default_qconnect_device_info() -> QconnectDeviceInfoPayload { - default_qconnect_device_info_with_name(None) -} - -fn default_qconnect_device_info_with_name(custom_name: Option<&str>) -> QconnectDeviceInfoPayload { - let friendly_name = 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); - let brand = std::env::var("QBZ_QCONNECT_DEVICE_BRAND") - .ok() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| DEFAULT_QCONNECT_DEVICE_BRAND.to_string()); - let model = std::env::var("QBZ_QCONNECT_DEVICE_MODEL") - .ok() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| DEFAULT_QCONNECT_DEVICE_MODEL.to_string()); - let software_version = 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") - ) - }); - let device_type = std::env::var("QBZ_QCONNECT_DEVICE_TYPE") - .ok() - .and_then(|value| value.trim().parse::().ok()) - .unwrap_or(DEFAULT_QCONNECT_DEVICE_TYPE); - - QconnectDeviceInfoPayload { - device_uuid: Some(resolve_qconnect_device_uuid()), - friendly_name: Some(friendly_name), - brand: Some(brand), - model: Some(model), - serial_number: None, - device_type: Some(device_type), - capabilities: Some(QconnectDeviceCapabilitiesPayload { - min_audio_quality: Some(AUDIO_QUALITY_MP3), - max_audio_quality: Some(AUDIO_QUALITY_HIRES_LEVEL2), - volume_remote_control: Some(VOLUME_REMOTE_CONTROL_ALLOWED), - }), - software_version: Some(software_version), - } -} - -pub(super) fn resolve_qconnect_device_uuid() -> String { - if let Some(explicit) = std::env::var("QBZ_QCONNECT_DEVICE_UUID") - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - { - return explicit; - } - - QCONNECT_DEVICE_UUID - .get_or_init(|| Uuid::new_v4().to_string()) - .clone() -} #[tauri::command] pub async fn v2_qconnect_connect( @@ -2700,286 +2632,6 @@ pub fn v2_get_hostname() -> Result { Ok(resolve_system_hostname()) } -fn resolve_system_hostname() -> String { - // Try HOSTNAME env var first - if let Ok(h) = std::env::var("HOSTNAME") { - if !h.trim().is_empty() { - return h.trim().to_string(); - } - } - // Try /etc/hostname - if let Ok(h) = std::fs::read_to_string("/etc/hostname") { - let trimmed = h.trim().to_string(); - if !trimmed.is_empty() { - return trimmed; - } - } - // Fallback - "Desktop".to_string() -} - -/// Returns "Qbz - {hostname}" as the default device name. -fn resolve_default_qconnect_device_name() -> String { - let hostname = resolve_system_hostname(); - format!("Qbz - {hostname}") -} - -/// Path to the QConnect settings database (global, not per-user). -fn qconnect_settings_db_path() -> Option { - let data_dir = dirs::data_dir()?.join("qbz"); - std::fs::create_dir_all(&data_dir).ok()?; - Some(data_dir.join("qconnect_settings.db")) -} - -/// Load the persisted custom device name from disk. -/// Returns None if not set or on any error (fail-open). -fn load_persisted_device_name() -> Option { - 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 to disk. None clears it. -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'", []); - } - } -} - -async fn resolve_transport_config( - options: QconnectConnectOptions, - app_state: &AppState, -) -> Result { - let mut endpoint_url = normalize_opt_string(options.endpoint_url) - .or_else(|| normalize_opt_string(std::env::var("QBZ_QCONNECT_WS_ENDPOINT").ok())); - - let mut jwt_qws = normalize_opt_string(options.jwt_qws) - .or_else(|| 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(app_state).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 (arg 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 (arg or QBZ_QCONNECT_WS_ENDPOINT)".to_string() - })?; - - let subscribe_channels = if let Some(channels) = options.subscribe_channels_hex { - parse_subscribe_channels(channels)? - } else if let Ok(raw) = std::env::var("QBZ_QCONNECT_SUBSCRIBE_CHANNELS_HEX") { - let channels: Vec = 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.reconnect_backoff_ms = options - .reconnect_backoff_ms - .unwrap_or(config.reconnect_backoff_ms); - config.reconnect_backoff_max_ms = options - .reconnect_backoff_max_ms - .unwrap_or(config.reconnect_backoff_max_ms); - config.connect_timeout_ms = options - .connect_timeout_ms - .unwrap_or(config.connect_timeout_ms); - config.keepalive_interval_ms = options - .keepalive_interval_ms - .unwrap_or(config.keepalive_interval_ms); - config.qcloud_proto = options.qcloud_proto.unwrap_or(config.qcloud_proto); - config.subscribe_channels = subscribe_channels; - - Ok(config) -} - -async fn fetch_qconnect_transport_credentials( - app_state: &AppState, -) -> Result<(Option, Option), String> { - let client = app_state.client.read().await.clone(); - 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 = crate::api::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(|_| "".to_string()) - .chars() - .take(300) - .collect::(); - 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 hex_preview(data: &[u8], max_bytes: usize) -> String { - let take = data.len().min(max_bytes); - let hex: String = data[..take] - .iter() - .map(|b| format!("{b:02x}")) - .collect::>() - .join(""); - if data.len() > max_bytes { - format!("{hex}...({}B total)", data.len()) - } else { - hex - } -} - -fn normalize_opt_string(value: Option) -> Option { - value.and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }) -} - -fn parse_subscribe_channels(items: Vec) -> Result>, String> { - items - .into_iter() - .map(|item| decode_hex_channel(&item)) - .collect() -} - -fn decode_hex_channel(raw: &str) -> Result, 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 = value.chars().collect(); - - for idx in (0..chars.len()).step_by(2) { - let pair = [chars[idx], chars[idx + 1]]; - let hex = pair.iter().collect::(); - let byte = u8::from_str_radix(&hex, 16) - .map_err(|_| format!("invalid subscribe channel hex byte '{hex}' in '{raw}'"))?; - bytes.push(byte); - } - - Ok(bytes) -} #[cfg(test)] mod tests { diff --git a/src-tauri/src/qconnect/transport.rs b/src-tauri/src/qconnect/transport.rs new file mode 100644 index 000000000..85e790322 --- /dev/null +++ b/src-tauri/src/qconnect/transport.rs @@ -0,0 +1,386 @@ +//! Transport setup, device identity, and credential discovery for the +//! QConnect WebSocket. Owns hostname resolution, the persisted custom +//! device name (in `qconnect_settings.db`), the qws/createToken +//! handshake, hex helpers used for inbound payload diagnostics, and the +//! once-initialized device UUID. + +use std::sync::OnceLock; + +use qconnect_transport_ws::WsTransportConfig; +use serde_json::Value; +use uuid::Uuid; + +use crate::AppState; + +use super::{ + QconnectConnectOptions, QconnectDeviceCapabilitiesPayload, QconnectDeviceInfoPayload, +}; + +use super::{AUDIO_QUALITY_HIRES_LEVEL2, AUDIO_QUALITY_MP3}; + +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 VOLUME_REMOTE_CONTROL_ALLOWED: i32 = 2; +const QCONNECT_QWS_TOKEN_KIND: &str = "jwt_qws"; +const QCONNECT_QWS_CREATE_TOKEN_PATH: &str = "/qws/createToken"; + +static QCONNECT_DEVICE_UUID: OnceLock = OnceLock::new(); + +pub(super) fn default_qconnect_device_info() -> QconnectDeviceInfoPayload { + default_qconnect_device_info_with_name(None) +} + +pub(super) fn default_qconnect_device_info_with_name( + custom_name: Option<&str>, +) -> QconnectDeviceInfoPayload { + let friendly_name = 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); + let brand = std::env::var("QBZ_QCONNECT_DEVICE_BRAND") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_QCONNECT_DEVICE_BRAND.to_string()); + let model = std::env::var("QBZ_QCONNECT_DEVICE_MODEL") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_QCONNECT_DEVICE_MODEL.to_string()); + let software_version = 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") + ) + }); + let device_type = std::env::var("QBZ_QCONNECT_DEVICE_TYPE") + .ok() + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(DEFAULT_QCONNECT_DEVICE_TYPE); + + QconnectDeviceInfoPayload { + device_uuid: Some(resolve_qconnect_device_uuid()), + friendly_name: Some(friendly_name), + brand: Some(brand), + model: Some(model), + serial_number: None, + device_type: Some(device_type), + capabilities: Some(QconnectDeviceCapabilitiesPayload { + min_audio_quality: Some(AUDIO_QUALITY_MP3), + max_audio_quality: Some(AUDIO_QUALITY_HIRES_LEVEL2), + volume_remote_control: Some(VOLUME_REMOTE_CONTROL_ALLOWED), + }), + software_version: Some(software_version), + } +} + +pub(super) fn resolve_qconnect_device_uuid() -> String { + if let Some(explicit) = std::env::var("QBZ_QCONNECT_DEVICE_UUID") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + return explicit; + } + + QCONNECT_DEVICE_UUID + .get_or_init(|| Uuid::new_v4().to_string()) + .clone() +} + +pub(super) fn resolve_system_hostname() -> String { + // Try HOSTNAME env var first + if let Ok(h) = std::env::var("HOSTNAME") { + if !h.trim().is_empty() { + return h.trim().to_string(); + } + } + // Try /etc/hostname + if let Ok(h) = std::fs::read_to_string("/etc/hostname") { + let trimmed = h.trim().to_string(); + if !trimmed.is_empty() { + return trimmed; + } + } + // Fallback + "Desktop".to_string() +} + +/// Returns "Qbz - {hostname}" as the default device name. +pub(super) fn resolve_default_qconnect_device_name() -> String { + let hostname = resolve_system_hostname(); + format!("Qbz - {hostname}") +} + +/// Path to the QConnect settings database (global, not per-user). +fn qconnect_settings_db_path() -> Option { + let data_dir = dirs::data_dir()?.join("qbz"); + std::fs::create_dir_all(&data_dir).ok()?; + Some(data_dir.join("qconnect_settings.db")) +} + +/// Load the persisted custom device name from disk. +/// Returns None if not set or on any error (fail-open). +pub(super) fn load_persisted_device_name() -> Option { + 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 to disk. None clears it. +pub(super) 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'", []); + } + } +} + +pub(super) async fn resolve_transport_config( + options: QconnectConnectOptions, + app_state: &AppState, +) -> Result { + let mut endpoint_url = normalize_opt_string(options.endpoint_url) + .or_else(|| normalize_opt_string(std::env::var("QBZ_QCONNECT_WS_ENDPOINT").ok())); + + let mut jwt_qws = normalize_opt_string(options.jwt_qws) + .or_else(|| 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(app_state).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 (arg 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 (arg or QBZ_QCONNECT_WS_ENDPOINT)".to_string() + })?; + + let subscribe_channels = if let Some(channels) = options.subscribe_channels_hex { + parse_subscribe_channels(channels)? + } else if let Ok(raw) = std::env::var("QBZ_QCONNECT_SUBSCRIBE_CHANNELS_HEX") { + let channels: Vec = 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.reconnect_backoff_ms = options + .reconnect_backoff_ms + .unwrap_or(config.reconnect_backoff_ms); + config.reconnect_backoff_max_ms = options + .reconnect_backoff_max_ms + .unwrap_or(config.reconnect_backoff_max_ms); + config.connect_timeout_ms = options + .connect_timeout_ms + .unwrap_or(config.connect_timeout_ms); + config.keepalive_interval_ms = options + .keepalive_interval_ms + .unwrap_or(config.keepalive_interval_ms); + config.qcloud_proto = options.qcloud_proto.unwrap_or(config.qcloud_proto); + config.subscribe_channels = subscribe_channels; + + Ok(config) +} + +async fn fetch_qconnect_transport_credentials( + app_state: &AppState, +) -> Result<(Option, Option), String> { + let client = app_state.client.read().await.clone(); + 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 = crate::api::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(|_| "".to_string()) + .chars() + .take(300) + .collect::(); + 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)) +} + +pub(super) fn hex_preview(data: &[u8], max_bytes: usize) -> String { + let take = data.len().min(max_bytes); + let hex: String = data[..take] + .iter() + .map(|b| format!("{b:02x}")) + .collect::>() + .join(""); + if data.len() > max_bytes { + format!("{hex}...({}B total)", data.len()) + } else { + hex + } +} + +fn normalize_opt_string(value: Option) -> Option { + value.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +fn parse_subscribe_channels(items: Vec) -> Result>, String> { + items + .into_iter() + .map(|item| decode_hex_channel(&item)) + .collect() +} + +fn decode_hex_channel(raw: &str) -> Result, 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 = value.chars().collect(); + + for idx in (0..chars.len()).step_by(2) { + let pair = [chars[idx], chars[idx + 1]]; + let hex = pair.iter().collect::(); + let byte = u8::from_str_radix(&hex, 16) + .map_err(|_| format!("invalid subscribe channel hex byte '{hex}' in '{raw}'"))?; + bytes.push(byte); + } + + Ok(bytes) +}