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:
vicrodh committed 2026-05-18 12:48:13 -06:00
1 parent 7535869e61
commit 03b9a1c528
6 files changed
+299 -84

No files matched your search

+8 -1
View File
@@ -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"
+33
View File
@@ -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");
}
}
+151
View File
@@ -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);
}
}
+96 -32
View File
@@ -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(())
}
+4 -51
View File
@@ -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.
}
}