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:
5 files changed
+583
-10
No files matched your search
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user