feat(qconnect): the controller half of the official LAN wire

qconnect-lan gains what QBZ calls on another receiver: a
_qobuz-connect._tcp browser (own device_uuid filtered, Electron's SDK
floor), the get-display-info / get-connect-info probe (IPv4 first, 10 s,
Connection: close) and the connect-to-qconnect handoff body — the
controller's session uuid, the delegated API token and the delegated QWS
token under its LAN name jwt_qconnect, become_active. qbz-qobuz mints the
delegated pair with POST /qws/delegateAuth (q_app_id), the step every
official controller takes before the handoff. The receiver DTOs learn to
deserialize (unknown enum values collapse to Unknown). Pure helpers tested.

cc
This commit is contained in:
vicrodh committed 2026-09-13 13:29:35 -06:00
1 parent 9eedb278f6
commit c60cdec064
5 files changed
+583 -10

No files matched your search

+54
View File
@@ -13,6 +13,9 @@ use crate::endpoints::{self, paths};
use crate::error::{ApiError, Result};
const QCONNECT_TOKEN_FORM: [(&str, &str); 1] = [("jwt", "jwt_qws")];
/// `POST /qws/delegateAuth` carries exactly one form field: the renderer's
/// own application id (Android `oe9`, Electron's delegation step).
const QCONNECT_DELEGATE_FORM_FIELD: &str = "q_app_id";
/// A QWS credential returned by Qobuz.
///
@@ -48,6 +51,21 @@ struct QwsAuthResponse {
jwt_qws: Option<QwsAuthToken>,
}
/// The pair `/qws/delegateAuth` mints for a LAN renderer on this account's
/// behalf: `jwt_api` for its REST calls and `jwt_qws` — `jwt_qconnect` on
/// the LAN wire — for its QWS session. No `Debug`: both are secrets.
#[derive(Clone)]
pub struct DelegatedQwsAuth {
pub jwt_api: QwsAuthToken,
pub jwt_qws: QwsAuthToken,
}
#[derive(Deserialize)]
struct DelegatedQwsAuthResponse {
jwt_api: Option<QwsAuthToken>,
jwt_qws: Option<QwsAuthToken>,
}
impl QobuzClient {
/// Mint the owner credential used by the native QConnect WebSocket
/// transport. The response body is never included in an error because a
@@ -75,6 +93,37 @@ impl QobuzClient {
ApiError::ApiResponse("qws/createToken response missing jwt_qws".to_string())
})
}
/// Mint delegated credentials for a LAN renderer (`q_app_id` = the app id
/// its `get-connect-info` reports), the step every official controller
/// takes before `POST connect-to-qconnect`. The body is never included in
/// an error because a successful body contains both JWTs.
pub async fn delegate_qconnect_auth(&self, q_app_id: &str) -> Result<DelegatedQwsAuth> {
let headers = self.authenticated_headers().await?;
let response = self
.http()?
.post(endpoints::build_url(paths::QWS_DELEGATE_AUTH))
.headers(headers)
.form(&[(QCONNECT_DELEGATE_FORM_FIELD, q_app_id)])
.send()
.await?;
let status = response.status();
let body = response.text().await?;
if !status.is_success() {
return Err(ApiError::ApiResponse(format!(
"qws/delegateAuth failed with HTTP {status}"
)));
}
let payload: DelegatedQwsAuthResponse = serde_json::from_str(&body)?;
match (payload.jwt_api, payload.jwt_qws) {
(Some(jwt_api), Some(jwt_qws)) => Ok(DelegatedQwsAuth { jwt_api, jwt_qws }),
_ => Err(ApiError::ApiResponse(
"qws/delegateAuth response missing jwt_api or jwt_qws".to_string(),
)),
}
}
}
#[cfg(test)]
@@ -85,4 +134,9 @@ mod tests {
fn create_token_form_matches_official_wire() {
assert_eq!(QCONNECT_TOKEN_FORM, [("jwt", "jwt_qws")]);
}
#[test]
fn delegate_auth_form_field_matches_official_wire() {
assert_eq!(super::QCONNECT_DELEGATE_FORM_FIELD, "q_app_id");
}
}