feat(qbz-slint): functional system-browser OAuth login
Wires real authentication. "Sign in via Browser" now runs the actual Qobuz OAuth flow with no Tauri and no WebView: opens the system browser, captures the authorization code on a one-shot localhost listener, exchanges it via QbzCore's Qobuz client, and activates a real per-user session through AppRuntime. - adapter.rs: SlintAdapter implements FrontendAdapter. - auth.rs: system-browser OAuth, mirrors v2_start_system_browser_oauth without Tauri. Includes a query-param parse test. - main.rs: tokio runtime; sign-in / offline callbacks run real async work and navigate to the shell on success. - Sidebar: placeholder playlist fixtures removed. qbz-slint now depends on qbz-app, qbz-core, and qbz-models. cc
This commit is contained in:
6 files changed
+299
-84
No files matched your search
@@ -4,7 +4,7 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Slint functional POC shell for QBZ (private branch only)"
|
||||
description = "Slint functional MVP shell for QBZ (private branch only)"
|
||||
|
||||
[[bin]]
|
||||
name = "qbz-slint"
|
||||
@@ -14,6 +14,13 @@ path = "src/main.rs"
|
||||
slint = "1"
|
||||
log = { workspace = true }
|
||||
env_logger = "0.11"
|
||||
tokio = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
urlencoding = "2"
|
||||
open = "5"
|
||||
qbz-app = { path = "../qbz-app" }
|
||||
qbz-core = { path = "../qbz-core" }
|
||||
qbz-models = { path = "../qbz-models" }
|
||||
|
||||
[build-dependencies]
|
||||
slint-build = "1"
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Slint frontend adapter.
|
||||
//!
|
||||
//! `SlintAdapter` implements `FrontendAdapter` so `QbzCore` can emit events
|
||||
//! toward the Slint UI. It holds a weak handle to the window to post updates
|
||||
//! onto the Slint event loop. The auth milestone only logs events; later
|
||||
//! milestones route them into UI models.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use qbz_core::{CoreEvent, FrontendAdapter};
|
||||
|
||||
use crate::AppWindow;
|
||||
|
||||
pub struct SlintAdapter {
|
||||
#[allow(dead_code)] // used by later milestones to push UI model updates
|
||||
window: slint::Weak<AppWindow>,
|
||||
}
|
||||
|
||||
impl SlintAdapter {
|
||||
pub fn new(window: slint::Weak<AppWindow>) -> Self {
|
||||
Self { window }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FrontendAdapter for SlintAdapter {
|
||||
async fn on_event(&self, event: CoreEvent) {
|
||||
log::debug!("[qbz-slint] core event: {:?}", event);
|
||||
}
|
||||
|
||||
async fn on_ready(&self) {
|
||||
log::info!("[qbz-slint] core ready");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! System-browser OAuth login for the Slint MVP.
|
||||
//!
|
||||
//! Mirrors the QBZ Tauri `v2_start_system_browser_oauth` flow without any
|
||||
//! Tauri or WebView dependency: it opens the user's default browser to the
|
||||
//! Qobuz OAuth page with a localhost redirect, captures the authorization
|
||||
//! code on a one-shot local HTTP listener, exchanges it through the core
|
||||
//! Qobuz client, and activates the per-user session via `AppRuntime`.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_core::FrontendAdapter;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
const OAUTH_TIMEOUT: Duration = Duration::from_secs(180);
|
||||
|
||||
/// Run the full system-browser OAuth login. Returns the authenticated
|
||||
/// user id on success.
|
||||
pub async fn login_via_system_browser<A>(runtime: &Arc<AppRuntime<A>>) -> Result<u64, String>
|
||||
where
|
||||
A: FrontendAdapter + Send + Sync + 'static,
|
||||
{
|
||||
let core = runtime.core();
|
||||
|
||||
// Ensure the Qobuz client is initialized (bundle tokens).
|
||||
if !core.is_api_initialized().await {
|
||||
core.try_init_api().await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let app_id = {
|
||||
let client_lock = core.client();
|
||||
let guard = client_lock.read().await;
|
||||
let client = guard.as_ref().ok_or("Qobuz client not initialized")?;
|
||||
client.app_id().await.map_err(|e| e.to_string())?
|
||||
};
|
||||
|
||||
// One-shot local listener for the OAuth redirect.
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to bind OAuth listener: {e}"))?;
|
||||
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
|
||||
|
||||
let oauth_url = format!(
|
||||
"https://www.qobuz.com/signin/oauth?ext_app_id={}&redirect_url={}",
|
||||
app_id,
|
||||
urlencoding::encode(&format!("http://localhost:{port}")),
|
||||
);
|
||||
|
||||
log::info!("[qbz-slint] opening system browser for OAuth (port {port})");
|
||||
open::that(&oauth_url).map_err(|e| format!("Failed to open browser: {e}"))?;
|
||||
|
||||
let code = tokio::time::timeout(OAUTH_TIMEOUT, capture_oauth_code(listener))
|
||||
.await
|
||||
.map_err(|_| "OAuth login timed out".to_string())?
|
||||
.ok_or_else(|| "OAuth login cancelled or no code received".to_string())?;
|
||||
|
||||
log::info!("[qbz-slint] OAuth code captured, exchanging for session");
|
||||
|
||||
let session = {
|
||||
let client_lock = core.client();
|
||||
let guard = client_lock.read().await;
|
||||
let client = guard.as_ref().ok_or("Qobuz client not initialized")?;
|
||||
client
|
||||
.login_with_oauth_code(&code)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
};
|
||||
let user_id = session.user_id;
|
||||
|
||||
// Emit LoggedIn through the core (idempotent set_session).
|
||||
core.set_session(session).await.map_err(|e| e.to_string())?;
|
||||
|
||||
// Activate the per-user session (creates dirs, opens the session store).
|
||||
runtime.activate(user_id).await?;
|
||||
|
||||
log::info!("[qbz-slint] login complete for user {user_id}");
|
||||
Ok(user_id)
|
||||
}
|
||||
|
||||
/// Accept connections until one carries the OAuth code, replying with a
|
||||
/// minimal success page. Browser noise (favicon requests, etc.) is answered
|
||||
/// and skipped.
|
||||
async fn capture_oauth_code(listener: TcpListener) -> Option<String> {
|
||||
loop {
|
||||
let (mut stream, _) = listener.accept().await.ok()?;
|
||||
let mut buf = [0u8; 8192];
|
||||
let n = stream.read(&mut buf).await.ok()?;
|
||||
let request = String::from_utf8_lossy(&buf[..n]);
|
||||
let target = request
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|line| line.split_whitespace().nth(1))
|
||||
.unwrap_or("");
|
||||
let code = query_param(target, "code_autorisation")
|
||||
.or_else(|| query_param(target, "code"));
|
||||
|
||||
let body = if code.is_some() {
|
||||
"<html><body style=\"font-family:system-ui;text-align:center;padding:64px;background:#0f0f0f;color:#fff\">\
|
||||
<h2>Login successful</h2><p>You can close this tab and return to QBZ.</p></body></html>"
|
||||
} else {
|
||||
"<html><body style=\"font-family:system-ui;text-align:center;padding:64px;background:#0f0f0f;color:#fff\">\
|
||||
<h2>Waiting for Qobuz...</h2></body></html>"
|
||||
};
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body,
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.flush().await;
|
||||
|
||||
if code.is_some() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract and percent-decode a query parameter from an HTTP request target.
|
||||
fn query_param(target: &str, key: &str) -> Option<String> {
|
||||
let query = target.split_once('?')?.1;
|
||||
for pair in query.split('&') {
|
||||
let Some((k, v)) = pair.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
if k == key {
|
||||
return urlencoding::decode(v).ok().map(|s| s.into_owned());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::query_param;
|
||||
|
||||
#[test]
|
||||
fn query_param_extracts_and_decodes() {
|
||||
assert_eq!(
|
||||
query_param("/?code_autorisation=abc123", "code_autorisation"),
|
||||
Some("abc123".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
query_param("/?a=1&code=x%2Fy", "code"),
|
||||
Some("x/y".to_string())
|
||||
);
|
||||
assert_eq!(query_param("/favicon.ico", "code"), None);
|
||||
assert_eq!(query_param("/?other=1", "code"), None);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +1,120 @@
|
||||
//! QBZ Slint POC binary.
|
||||
//! QBZ Slint MVP binary.
|
||||
//!
|
||||
//! Validates whether Slint can become QBZ's future UI foundation on top of
|
||||
//! the framework-agnostic `qbz-app` / `qbz-core` stack. See the POC ADR
|
||||
//! A native Slint front end for QBZ built on the framework-agnostic
|
||||
//! `qbz-app` / `qbz-core` stack — no Tauri, no WebView. See the MVP ADR
|
||||
//! (`qbz-nix-docs/qbz-adr/qbz_slint_functional_poc_adr.md`).
|
||||
//!
|
||||
//! Lives only on the private `slint-poc` branch (ADR-007). The Slint UI tree
|
||||
//! is compiled from `ui/app.slint` by `build.rs`; `include_modules!` pulls in
|
||||
//! the generated Rust bindings.
|
||||
//! Lives only on the private `slint-mvp` branch (ADR-007). The Slint UI
|
||||
//! tree is compiled from `ui/app.slint` by `build.rs`; `include_modules!`
|
||||
//! pulls in the generated Rust bindings.
|
||||
//!
|
||||
//! M1 status: foundation tokens, login screen, and the app shell frame
|
||||
//! (sidebar / header / content / player bar). UI callbacks reach the typed-
|
||||
//! command layer; M2 wires that layer to `AppRuntime` and real auth.
|
||||
//! Status: foundation tokens, login screen, app shell, and functional
|
||||
//! system-browser OAuth — sign-in runs the real Qobuz OAuth flow and
|
||||
//! activates a real per-user session.
|
||||
|
||||
slint::include_modules!();
|
||||
|
||||
mod adapter;
|
||||
mod auth;
|
||||
mod commands;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use adapter::SlintAdapter;
|
||||
use commands::AppCommand;
|
||||
use qbz_app::shell::AppRuntime;
|
||||
|
||||
/// Login Terms-of-Service link target.
|
||||
const QOBUZ_TOS_URL: &str = "https://www.qobuz.com/us-en/legal/terms";
|
||||
|
||||
/// Central command sink. M2 replaces the body with real `AppRuntime`
|
||||
/// dispatch; today it records that the typed-command path works.
|
||||
fn dispatch(command: AppCommand) {
|
||||
log::info!("[qbz-slint] AppCommand::{} dispatched", command.id());
|
||||
}
|
||||
|
||||
fn main() -> Result<(), slint::PlatformError> {
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
|
||||
let tokio_rt = tokio::runtime::Runtime::new()?;
|
||||
|
||||
let window = AppWindow::new()?;
|
||||
let app_runtime = Arc::new(AppRuntime::new(SlintAdapter::new(window.as_weak())));
|
||||
|
||||
// Sign-in and offline both reveal the app shell. During M1 this happens
|
||||
// without real authentication; M2 gates the transition on a real session.
|
||||
let weak = window.as_weak();
|
||||
window.on_sign_in_via_browser(move || {
|
||||
dispatch(AppCommand::SignInViaBrowser);
|
||||
if let Some(win) = weak.upgrade() {
|
||||
win.set_screen(AppScreen::Shell);
|
||||
// Extract Qobuz bundle tokens in the background so OAuth is ready
|
||||
// by the time the user signs in.
|
||||
{
|
||||
let runtime = app_runtime.clone();
|
||||
tokio_rt.spawn(async move {
|
||||
if let Err(e) = runtime.init().await {
|
||||
log::error!("[qbz-slint] core init failed: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sign in via the system browser → real OAuth → app shell.
|
||||
// "Sign in via Browser" and "Use your system browser instead" are the
|
||||
// same flow in the MVP (the in-app webview path is intentionally absent).
|
||||
let on_browser_login = {
|
||||
let runtime = app_runtime.clone();
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
move || {
|
||||
let runtime = runtime.clone();
|
||||
let weak = weak.clone();
|
||||
handle.spawn(async move {
|
||||
match auth::login_via_system_browser(&runtime).await {
|
||||
Ok(user_id) => {
|
||||
log::info!("[qbz-slint] authenticated as user {user_id}");
|
||||
let _ = weak.upgrade_in_event_loop(|w| w.set_screen(AppScreen::Shell));
|
||||
}
|
||||
Err(e) => log::error!("[qbz-slint] sign-in failed: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let login = on_browser_login.clone();
|
||||
window.on_sign_in_via_browser(move || {
|
||||
dispatch(AppCommand::SignInViaBrowser);
|
||||
login();
|
||||
});
|
||||
}
|
||||
{
|
||||
let login = on_browser_login.clone();
|
||||
window.on_use_system_browser(move || {
|
||||
dispatch(AppCommand::UseSystemBrowser);
|
||||
login();
|
||||
});
|
||||
}
|
||||
|
||||
// Offline: activate an offline-only session, then show the shell.
|
||||
{
|
||||
let runtime = app_runtime.clone();
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
window.on_start_offline(move || {
|
||||
dispatch(AppCommand::StartOffline);
|
||||
let runtime = runtime.clone();
|
||||
let weak = weak.clone();
|
||||
handle.spawn(async move {
|
||||
match runtime.activate_offline().await {
|
||||
Ok(()) => {
|
||||
let _ = weak.upgrade_in_event_loop(|w| w.set_screen(AppScreen::Shell));
|
||||
}
|
||||
Err(e) => log::error!("[qbz-slint] offline start failed: {e}"),
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.on_open_tos(|| {
|
||||
dispatch(AppCommand::OpenTermsOfService);
|
||||
if let Err(e) = open::that(QOBUZ_TOS_URL) {
|
||||
log::error!("[qbz-slint] failed to open Terms of Service: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
window.on_use_system_browser(|| dispatch(AppCommand::UseSystemBrowser));
|
||||
|
||||
let weak = window.as_weak();
|
||||
window.on_start_offline(move || {
|
||||
dispatch(AppCommand::StartOffline);
|
||||
if let Some(win) = weak.upgrade() {
|
||||
win.set_screen(AppScreen::Shell);
|
||||
}
|
||||
});
|
||||
|
||||
window.on_open_tos(|| dispatch(AppCommand::OpenTermsOfService));
|
||||
|
||||
log::info!("[qbz-slint] window ready — login screen");
|
||||
window.run()
|
||||
window.run()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Left navigation sidebar — playlists list plus the user card.
|
||||
//
|
||||
// Shell-frame increment: the playlist rows are placeholder names for
|
||||
// visual reference. Real playlist data is wired in a later milestone.
|
||||
// The playlist list is empty until real playlist data is wired (the
|
||||
// sidebar is a later milestone). No placeholder/fixture rows.
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Spacing } from "../foundation/spacing.slint";
|
||||
@@ -10,46 +10,6 @@ import { Layout } from "../foundation/layout.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
|
||||
component PlaylistRow inherits Rectangle {
|
||||
in property <string> name;
|
||||
height: 34px;
|
||||
border-radius: Radius.sm;
|
||||
background: ta.has-hover ? Theme.surface-hover : transparent;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Spacing.sm;
|
||||
padding-right: Spacing.sm;
|
||||
spacing: Spacing.sm;
|
||||
|
||||
Rectangle {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
border-radius: 3px;
|
||||
background: Theme.accent;
|
||||
opacity: 0.55;
|
||||
}
|
||||
Text {
|
||||
text: root.name;
|
||||
color: Theme.text-secondary;
|
||||
font-size: Typography.body;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
Text {
|
||||
text: "›";
|
||||
color: Theme.text-muted;
|
||||
font-size: 16px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
export component Sidebar inherits Rectangle {
|
||||
width: Layout.sidebar-width;
|
||||
background: Theme.surface-main;
|
||||
@@ -79,15 +39,8 @@ export component Sidebar inherits Rectangle {
|
||||
vertical-stretch: 1;
|
||||
VerticalLayout {
|
||||
spacing: 2px;
|
||||
for name in [
|
||||
"N.W.O.B.H.M.", "Beatles", "Progressive Metal",
|
||||
"Progressive Rock", "Rock en español", "Español",
|
||||
"Metal of the world", "Curated Metal", "Soundtracks",
|
||||
"Rock Mix", "Anime", "Classic Punk", "grls",
|
||||
"One Hit Wonder",
|
||||
] : PlaylistRow {
|
||||
name: name;
|
||||
}
|
||||
// Real playlist rows are populated when the sidebar
|
||||
// milestone wires playlist data. No fixtures.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user