feat(qbz-qt): phase 1 — login screen with full offline guardrails

QbzBridge QML singleton (12 properties, 8 invokables) driven by tokio
tasks via CxxQtThread. Port of auth.rs (browser OAuth loopback listener
with qbzd-style nonce, 180s timeout, cancel via JoinHandle abort,
scoped offline-gate lift for cold bundle init, D1 token-keep on network
failure / clear on explicit rejection, D4 subscription grace marks).
OfflineModeEngine + ConnectivityActor started at boot with live watch
forwarding (connectivity/captive portal/recovery banner/has-previous-
session). Boot: splash -> silent token restore -> shell | login.
Start-offline enters the guest profile. LoginScreen.qml is a faithful
port with exact msgids routed through qbz-i18n. 5 unit tests green.

km
This commit is contained in:
vicrodh committed 2026-07-27 12:09:31 -06:00
1 parent 469f83781d
commit df7eb3588e
12 files changed
+1865 -34

No files matched your search

+268 -9
View File
@@ -267,7 +267,7 @@ checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1"
dependencies = [
"anstyle",
"memchr",
"unicode-width",
"unicode-width 0.2.2",
]
[[package]]
@@ -1121,6 +1121,15 @@ dependencies = [
"inout",
]
[[package]]
name = "clang-format"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "696283b40e1a39d208ee614b92e5f6521d16962edeb47c48372585ec92419943"
dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "clang-sys"
version = "1.8.1"
@@ -1238,6 +1247,16 @@ dependencies = [
"objc",
]
[[package]]
name = "codespan-reporting"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e"
dependencies = [
"termcolor",
"unicode-width 0.1.14",
]
[[package]]
name = "codespan-reporting"
version = "0.12.0"
@@ -1246,7 +1265,18 @@ checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81"
dependencies = [
"serde",
"termcolor",
"unicode-width",
"unicode-width 0.2.2",
]
[[package]]
name = "codespan-reporting"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
dependencies = [
"serde",
"termcolor",
"unicode-width 0.2.2",
]
[[package]]
@@ -1321,6 +1351,15 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "convert_case"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "convert_case"
version = "0.10.0"
@@ -1660,6 +1699,150 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "cxx"
version = "1.0.198"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fe442a792c7c736eea18b32a7f8a3b63cf8aafabda6760042dc2fdeda456291"
dependencies = [
"cc",
"cxx-build",
"cxxbridge-cmd",
"cxxbridge-flags",
"cxxbridge-macro",
"foldhash 0.2.0",
"link-cplusplus",
]
[[package]]
name = "cxx-build"
version = "1.0.198"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3184a94384c663718698311a78a51ac00c484c10b4eeac06fb0a068c5f64fa2"
dependencies = [
"cc",
"codespan-reporting 0.13.1",
"indexmap",
"proc-macro2",
"quote",
"scratch",
"syn 3.0.3",
]
[[package]]
name = "cxx-gen"
version = "0.7.198"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb0f24fd8cafa20f043e24372ca5d8273ab1fdce055ee977b989f82c1bcd89b5"
dependencies = [
"codespan-reporting 0.13.1",
"indexmap",
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "cxx-qt"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb4ce9106b3ee7ef85f77d5f69ec30ec3037ea1edacd3e29a61b26ff47ecc637"
dependencies = [
"cxx",
"cxx-qt-build",
"cxx-qt-macro",
"qt-build-utils",
"static_assertions",
"thiserror 1.0.69",
]
[[package]]
name = "cxx-qt-build"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23a7884708e645dc34a2c475bd8c505a2ffeb7c9cb0843f5c580c002c939ea30"
dependencies = [
"cc",
"codespan-reporting 0.11.1",
"cxx-gen",
"cxx-qt-gen",
"proc-macro2",
"qt-build-utils",
"quote",
"serde",
"serde_json",
"version_check",
]
[[package]]
name = "cxx-qt-gen"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6b431dc58e4a9b7a55b675be0941331cc2cab0a494e4742ebf7bb393c3fc39"
dependencies = [
"clang-format",
"convert_case 0.6.0",
"indoc",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "cxx-qt-lib"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527b46c28be6bf3dd02f1567706641fe247a8798defab8268c84602955741217"
dependencies = [
"cxx",
"cxx-qt",
"cxx-qt-build",
"qt-build-utils",
]
[[package]]
name = "cxx-qt-macro"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "971d8811fd1c8dd06c17284edcfc8e9663dac30d7e3d52159405f836a81923f1"
dependencies = [
"cxx-qt-gen",
"proc-macro2",
"syn 2.0.117",
]
[[package]]
name = "cxxbridge-cmd"
version = "1.0.198"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0148d8fd1199329ddf1d157a5e134e51ceff37c6a7ddd38615c399d81cb05d8d"
dependencies = [
"clap",
"codespan-reporting 0.13.1",
"indexmap",
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "cxxbridge-flags"
version = "1.0.198"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52850339faed2eaadd24e286dc1d8268cc6f8a7bd9524d713adc9099566b4c89"
[[package]]
name = "cxxbridge-macro"
version = "1.0.198"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c77c856545d886c9bd5215409ebb63b925e262135248b50c79e5a5f194ee47c"
dependencies = [
"indexmap",
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "darling"
version = "0.23.0"
@@ -1789,7 +1972,7 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"convert_case",
"convert_case 0.10.0",
"proc-macro2",
"quote",
"rustc_version",
@@ -2605,7 +2788,7 @@ version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width",
"unicode-width 0.2.2",
]
[[package]]
@@ -4104,6 +4287,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
[[package]]
name = "link-cplusplus"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82"
dependencies = [
"cc",
]
[[package]]
name = "linked-hash-map"
version = "0.5.6"
@@ -4474,7 +4666,7 @@ dependencies = [
"bitflags 2.13.0",
"cfg-if",
"cfg_aliases",
"codespan-reporting",
"codespan-reporting 0.12.0",
"half",
"hashbrown 0.16.1",
"hexf-parse",
@@ -6310,6 +6502,29 @@ dependencies = [
"tokio",
]
[[package]]
name = "qbz-qt"
version = "2.0.2"
dependencies = [
"cxx",
"cxx-qt",
"cxx-qt-build",
"cxx-qt-lib",
"dirs 5.0.1",
"log",
"open",
"qbz-app",
"qbz-core",
"qbz-credentials",
"qbz-i18n",
"qbz-log",
"qbz-models",
"qbz-qobuz",
"rand 0.10.1",
"tokio",
"urlencoding",
]
[[package]]
name = "qbz-radio"
version = "2.0.2"
@@ -6491,6 +6706,17 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "qt-build-utils"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63c7e24653d0b3084180066306b26e532b152470b13a050c2d2960284d4b9f53"
dependencies = [
"cc",
"thiserror 1.0.69",
"versions",
]
[[package]]
name = "quick-error"
version = "2.0.1"
@@ -6664,7 +6890,7 @@ dependencies = [
"thiserror 2.0.18",
"unicode-segmentation",
"unicode-truncate",
"unicode-width",
"unicode-width 0.2.2",
]
[[package]]
@@ -6727,7 +6953,7 @@ dependencies = [
"strum",
"time",
"unicode-segmentation",
"unicode-width",
"unicode-width 0.2.2",
]
[[package]]
@@ -7084,7 +7310,7 @@ dependencies = [
"natord",
"snafu",
"unicode-linebreak",
"unicode-width",
"unicode-width 0.2.2",
]
[[package]]
@@ -7319,6 +7545,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scratch"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2"
[[package]]
name = "sctk-adwaita"
version = "0.10.1"
@@ -8202,6 +8434,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
@@ -8943,7 +9186,7 @@ checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
dependencies = [
"itertools 0.14.0",
"unicode-segmentation",
"unicode-width",
"unicode-width 0.2.2",
]
[[package]]
@@ -8952,6 +9195,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.2"
@@ -9096,6 +9345,16 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "versions"
version = "6.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f25d498b63d1fdb376b4250f39ab3a5ee8d103957346abacd911e2d8b612c139"
dependencies = [
"itertools 0.13.0",
"nom 7.1.3",
]
[[package]]
name = "vtable"
version = "0.4.0"
+15
View File
@@ -14,5 +14,20 @@ cxx = "1"
cxx-qt = "0.7"
cxx-qt-lib = { version = "0.7", features = ["qt_full"] }
tokio = { workspace = true }
log = { workspace = true }
rand = { workspace = true }
urlencoding = "2"
open = "5"
dirs = "5"
qbz-core = { path = "../qbz-core" }
qbz-app = { path = "../qbz-app" }
qbz-qobuz = { path = "../qbz-qobuz" }
qbz-models = { path = "../qbz-models" }
qbz-credentials = { path = "../qbz-credentials" }
qbz-i18n = { path = "../qbz-i18n" }
qbz-log = { path = "../qbz-log" }
[build-dependencies]
cxx-qt-build = "0.7"
+10 -1
View File
@@ -9,7 +9,16 @@ fn main() {
.qml_module(QmlModule {
uri: "com.blitzfc.qbz",
rust_files: &["src/bridge.rs"],
qml_files: &["qml/Main.qml"],
qml_files: &[
"qml/Main.qml",
"qml/LoginScreen.qml",
"qml/ShellPlaceholder.qml",
"qml/QbzTheme.qml",
],
// Non-QML resources. The path below lands at
// qrc:/qt/qml/com/blitzfc/qbz/qml/assets/qbz-logo.png — QML files
// in qml/ reference it relatively as "assets/qbz-logo.png".
qrc_files: &["qml/assets/qbz-logo.png"],
..Default::default()
})
.build();
+369
View File
@@ -0,0 +1,369 @@
// QBZ login screen — QML port of crates/qbz-ui/ui/login/LoginScreen.slint.
//
// Centered 720px dark card: logo, wordmark, ToS checkbox gating the
// primary button, system-browser sign-in (NO webview, NO email/password),
// phase narration, sign-in error box, offline-connectivity callout (with
// captive-portal line), session-restore error box, "Start offline" link,
// legal disclaimer.
//
// All user-visible strings go through QbzBridge.tr() with the EXACT msgids
// of the Slint @tr() calls so the existing .po translations apply.
// State comes from the QbzBridge singleton (Slint's LoginState +
// OfflineState globals); actions call the bridge invokables.
import QtQuick
import com.blitzfc.qbz
Rectangle {
id: root
color: theme.surfaceMain
// Slint: in-out property tos-accepted: true.
property bool tosAccepted: true
QbzTheme { id: theme }
// Faked drop shadow (blur 32, offset-y 8, #00000066): a translucent
// black rounded rect behind the card. Qt5Compat DropShadow is not
// assumed to be installed on the target.
Rectangle {
anchors.horizontalCenter: card.horizontalCenter
y: card.y + 8
width: card.width
height: card.height
radius: theme.radiusLg
color: theme.cardShadow
opacity: 0.5
}
Rectangle {
id: card
anchors.centerIn: parent
width: 720
height: cardColumn.implicitHeight + 2 * theme.cardPadding
color: theme.surfaceCard
radius: theme.radiusLg
Column {
id: cardColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: theme.cardPadding
spacing: 0
// --- Brand ------------------------------------------------
Image {
anchors.horizontalCenter: parent.horizontalCenter
source: "assets/qbz-logo.png"
width: 140
height: 140
fillMode: Image.PreserveAspectFit
}
Item { width: 1; height: theme.spacingSm }
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzBridge.tr("QBZ")
color: theme.textPrimary
font.pixelSize: theme.fontWordmark
font.weight: theme.weightSemibold
font.letterSpacing: 8
}
Item { width: 1; height: 2 }
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzBridge.tr("QOBUZ™ PLAYER")
color: theme.textMuted
font.pixelSize: theme.fontSubtitle
font.letterSpacing: 4
}
Item { width: 1; height: theme.spacingXl }
// --- Terms of Service row --------------------------------
Row {
spacing: theme.spacingSm
// Minimal dark checkbox (Slint QbzCheckbox equivalent).
Rectangle {
id: tosCheckbox
width: 18
height: 18
anchors.verticalCenter: parent.verticalCenter
radius: 4
color: root.tosAccepted ? theme.accent : "transparent"
border.color: root.tosAccepted ? theme.accent : theme.textMuted
border.width: 1
Text {
anchors.centerIn: parent
visible: root.tosAccepted
text: "✓"
color: theme.textPrimary
font.pixelSize: 13
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.tosAccepted = !root.tosAccepted
}
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzBridge.tr("I have read the")
color: theme.textSecondary
font.pixelSize: theme.fontBody
}
Text {
id: tosLink
anchors.verticalCenter: parent.verticalCenter
text: QbzBridge.tr("Qobuz™ Terms of Service")
color: theme.accent
font.pixelSize: theme.fontBody
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.openTos()
}
}
}
Item { width: 1; height: theme.spacingLg }
// --- Sign in ---------------------------------------------
// Opens the user's default web browser (no embedded webview).
Rectangle {
id: signInButton
width: parent.width
height: 44
radius: theme.radiusSm
property bool canSignIn: root.tosAccepted && QbzBridge.loginPhase === 0
color: canSignIn ? (signInArea.containsMouse ? "#5a95f5" : theme.accent)
: theme.surfaceElevated
Text {
anchors.centerIn: parent
text: QbzBridge.tr("Sign in with your browser")
color: signInButton.canSignIn ? theme.textPrimary : theme.textMuted
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
}
MouseArea {
id: signInArea
anchors.fill: parent
enabled: signInButton.canSignIn
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.signInViaBrowser()
}
}
Item { width: 1; height: theme.spacingMd }
// --- Browser-flow status ---------------------------------
// The browser may open in the background without stealing
// focus, and the code exchange takes a few seconds — the
// screen must narrate instead of sitting inert.
Column {
visible: QbzBridge.loginPhase === 1
width: parent.width
spacing: 4
Text {
width: parent.width
text: QbzBridge.tr("Continue in your web browser — QBZ is waiting for you to finish signing in.")
color: theme.textSecondary
font.pixelSize: theme.fontBody
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
Text {
width: parent.width
text: QbzBridge.tr("The browser may open in the background without taking focus — check your other windows.")
color: theme.textMuted
font.pixelSize: theme.fontLegal
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
Item { width: 1; height: 4 }
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzBridge.tr("Cancel")
color: theme.accent
font.pixelSize: theme.fontBody
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.cancelLogin()
}
}
}
Text {
visible: QbzBridge.loginPhase === 2
width: parent.width
text: QbzBridge.tr("Signing you in…")
color: theme.textSecondary
font.pixelSize: theme.fontBody
horizontalAlignment: Text.AlignHCenter
}
// Sign-in failure box (phase 0 only).
Rectangle {
visible: QbzBridge.loginPhase === 0 && QbzBridge.loginError !== ""
width: parent.width
height: signInErrorColumn.implicitHeight + 24
color: theme.surfaceElevated
radius: theme.radiusSm
border.width: 1
border.color: theme.borderSubtle
Column {
id: signInErrorColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 12
spacing: 4
Text {
width: parent.width
text: QbzBridge.tr("Sign-in failed")
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
horizontalAlignment: Text.AlignHCenter
}
Text {
width: parent.width
text: QbzBridge.loginError
color: theme.textMuted
font.pixelSize: theme.fontLegal
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
}
}
Item { width: 1; height: 10 }
// --- Offline-boot callout (spec §4.1) --------------------
// No connectivity: point the user at the Start-offline link
// right below. Gated on has-previous-session too.
Column {
visible: QbzBridge.hasPreviousSession && QbzBridge.connectivity === 2
width: parent.width
spacing: 2
Rectangle {
width: parent.width
height: offlineColumn.implicitHeight + 24
color: theme.surfaceElevated
radius: theme.radiusSm
border.width: 1
border.color: theme.borderSubtle
Column {
id: offlineColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 12
spacing: 4
Text {
width: parent.width
text: QbzBridge.tr("No internet connection — you can start in offline mode with your local library and downloads")
color: theme.textSecondary
font.pixelSize: theme.fontBody
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
Text {
visible: QbzBridge.captivePortal
width: parent.width
text: QbzBridge.tr("A network sign-in page may be blocking the connection")
color: theme.textMuted
font.pixelSize: theme.fontLegal
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
}
}
// Caret pointing at the Start-offline link below.
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "▼"
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
}
// Init-error variant of the same box (session restore failed
// for a non-connectivity reason). Hidden while the
// connectivity box shows — never two boxes at once.
Column {
visible: QbzBridge.restoreError !== "" && QbzBridge.connectivity !== 2
width: parent.width
spacing: 0
Rectangle {
width: parent.width
height: restoreColumn.implicitHeight + 24
color: theme.surfaceElevated
radius: theme.radiusSm
border.width: 1
border.color: theme.borderSubtle
Column {
id: restoreColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 12
spacing: 4
Text {
width: parent.width
text: QbzBridge.tr("Could not restore your session")
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
horizontalAlignment: Text.AlignHCenter
}
Text {
width: parent.width
text: QbzBridge.restoreError
color: theme.textMuted
font.pixelSize: theme.fontLegal
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
}
}
Item { width: 1; height: theme.spacingSm }
}
// Always visible (#553): without a previous session this
// opens the GUEST profile (user 0) — Local Library only,
// adopted by the account on the first real login.
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzBridge.tr("Start offline (no access to Qobuz™ services)")
color: theme.accent
font.pixelSize: theme.fontBody
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.startOffline()
}
}
Item { width: 1; height: theme.spacingXl }
// --- Legal disclaimer ------------------------------------
Text {
width: parent.width
text: QbzBridge.tr("QBZ requires an active Qobuz™ subscription. Your credentials are sent directly to Qobuz™.")
+ "\n"
+ QbzBridge.tr("QBZ can be used as an offline player without a Qobuz™ account (no access to the Qobuz™ library).")
+ "\n"
+ QbzBridge.tr("This application uses the Qobuz API but is not certified by Qobuz.")
+ " "
+ QbzBridge.tr("Qobuz™ is a trademark of Qobuz. QBZ is an open-source application licensed under the MIT License and is not affiliated with, endorsed by, or certified by Qobuz.")
color: theme.textMuted
font.pixelSize: theme.fontLegal
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
}
}
}
+40 -8
View File
@@ -1,21 +1,53 @@
// Root window: dark background + a Loader keyed on the bridge's `screen`
// property ("splash" | "login" | "shell"). Kicks off the Rust boot
// sequence once the QML tree is complete (the bridge registers its Qt
// thread handle in that first invokable, so every async UI hop lands).
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
ApplicationWindow {
id: window
width: 1280
height: 800
minimumWidth: 800
minimumHeight: 600
visible: true
width: 960
height: 600
title: "qbz-qt — toolchain gate"
title: "QBZ"
color: "#0f0f0f"
Component.onCompleted: QbzBridge.boot()
Loader {
id: screenLoader
anchors.fill: parent
active: QbzBridge.screen !== "splash"
source: QbzBridge.screen === "login"
? "LoginScreen.qml"
: (QbzBridge.screen === "shell" ? "ShellPlaceholder.qml" : "")
}
// Splash: shown until the silent session restore resolves.
Rectangle {
anchors.fill: parent
color: "#191922"
color: "#0f0f0f"
visible: QbzBridge.screen === "splash"
Text {
Column {
anchors.centerIn: parent
text: "qbz-qt phase 0 gate"
color: "#e8e8ef"
font.pixelSize: 20
spacing: 16
Image {
anchors.horizontalCenter: parent.horizontalCenter
source: "assets/qbz-logo.png"
width: 120
height: 120
fillMode: Image.PreserveAspectFit
}
BusyIndicator {
anchors.horizontalCenter: parent.horizontalCenter
running: true
}
}
}
}
+38
View File
@@ -0,0 +1,38 @@
// Shared dark-theme constants for the POC — the values the Slint app
// pushes into its ThemeColors struct for the dark theme
// (crates/qbz-ui/ui/foundation/theme.slint, dark block), plus the spacing /
// radius / typography tokens the login screen uses.
//
// Kept as a plain instantiable QtObject (NOT a pragma Singleton — the
// cxx-qt-build generated qmldir cannot mark singletons).
import QtQuick
QtObject {
readonly property color surfaceMain: "#0f0f0f"
readonly property color surfaceCard: "#1a1a1a"
readonly property color surfaceElevated: "#2a2a2a"
readonly property color textPrimary: "#ffffff"
readonly property color textSecondary: "#cccccc"
readonly property color textMuted: "#888888"
readonly property color accent: "#4285f4"
// #ffffff14 / #00000066 in the Slint ARGB notation.
readonly property color borderSubtle: "#14ffffff"
readonly property color cardShadow: "#66000000"
readonly property int spacingSm: 8
readonly property int spacingMd: 16
readonly property int spacingLg: 20
readonly property int spacingXl: 32
readonly property int cardPadding: 52
readonly property int radiusSm: 8
readonly property int radiusLg: 16
readonly property int fontLegal: 13
readonly property int fontBody: 15
readonly property int fontSubtitle: 15
readonly property int fontWordmark: 29
readonly property int weightMedium: Font.Medium
readonly property int weightSemibold: Font.DemiBold
}
+115
View File
@@ -0,0 +1,115 @@
// Shell placeholder for phase 1 — proves the post-login state (session
// header, D2 recovery banner, offline-session badge, logout) without
// porting the real shell (phase 2).
import QtQuick
import com.blitzfc.qbz
Rectangle {
id: root
color: theme.surfaceMain
QbzTheme { id: theme }
Column {
anchors.centerIn: parent
spacing: theme.spacingMd
width: Math.min(parent.width - 64, 560)
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "QBZ Qt POC — shell (phase 2 pending)"
color: theme.textPrimary
font.pixelSize: 22
font.weight: theme.weightSemibold
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzBridge.sessionUserName
+ (QbzBridge.sessionSubscription !== ""
? " — " + QbzBridge.sessionSubscription : "")
color: theme.textSecondary
font.pixelSize: theme.fontBody
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
visible: QbzBridge.offlineSession
text: "Offline session — Qobuz™ services unavailable"
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
// D2 recovery banner: a saved session exists but the app is
// running unauthenticated while connectivity is UP.
Rectangle {
visible: QbzBridge.showRecoveryBanner
width: parent.width
height: recoveryColumn.implicitHeight + 24
color: theme.surfaceElevated
radius: theme.radiusSm
border.width: 1
border.color: theme.borderSubtle
Column {
id: recoveryColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 12
spacing: 8
Text {
width: parent.width
text: "Your session could not be restored — sign in again to regain access to Qobuz™."
color: theme.textSecondary
font.pixelSize: theme.fontBody
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
width: recoveryButtonText.implicitWidth + 32
height: 36
radius: theme.radiusSm
color: recoveryArea.containsMouse ? "#5a95f5" : theme.accent
Text {
id: recoveryButtonText
anchors.centerIn: parent
text: "Sign in again"
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
}
MouseArea {
id: recoveryArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.recoveryLogin()
}
}
}
}
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
width: logoutText.implicitWidth + 32
height: 36
radius: theme.radiusSm
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Text {
id: logoutText
anchors.centerIn: parent
text: "Log out"
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.logout()
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

+448
View File
@@ -0,0 +1,448 @@
//! System-browser OAuth login for the Qt POC — a port of
//! `crates/qbz/src/auth.rs` onto cxx-qt, PLUS the redirect-listener
//! hardening from `crates/qbzd/src/login.rs` (CSPRNG nonce bound into the
//! redirect PATH; a mismatched/absent path nonce is dropped) that the
//! desktop auth.rs lacks.
//!
//! POC-NOTE (skipped vs the Slint glue, for the effort-measurement report):
//! the per-user store fan-out after a successful login/restore is NOT
//! ported. Omitted: `crate::offline::activate`, `offline_cache::
//! load_cached_ids`, `fav_cache`, `reco_dismiss`, `reco` (+train_async),
//! `external_reco`, `qbz_reco::ArtistVectorStore`, `discover_prefs`,
//! `artist_blacklist`, `pinned`, `local_favorites`, `search_service`,
//! `session_persist`, `lyrics`. Reason: the POC shell has no views that
//! read those stores; each is a mechanical `init_for_user(&dir)` call to
//! re-add when its view lands. Also omitted: the subscription purge
//! consumer (`spawn_subscription_purge_check`) — it needs the offline
//! cache, which this phase does not bring up; TODO when the offline cache
//! is wired.
//!
//! KEPT (the offline guardrails this phase is about):
//! `ensure_api_initialized` (scoped gate lift), `login_with_oauth_code`,
//! `core.set_session`, `runtime.activate`, offline-mode `init_for_user` +
//! `SubscriptionStateStore` bind + mark_valid/invalid (D4),
//! `engine().set_offline_session(false)` on success,
//! `qbz_credentials::save_oauth_token`, `qbz_log::register_secret`, and the
//! restore semantics (explicit auth rejection clears the token,
//! network-class failure keeps it).
use std::sync::Arc;
use std::time::Duration;
use qbz_app::shell::AppRuntime;
use qbz_app::user_data::UserDataPaths;
use qbz_core::FrontendAdapter;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
const OAUTH_TIMEOUT: Duration = Duration::from_secs(180);
/// The authenticated user, as the shell needs it.
pub struct SessionInfo {
// Carried for future shell phases (per-user views); the phase-1 shell
// placeholder only renders name + subscription.
#[allow(dead_code)]
pub user_id: u64,
pub display_name: String,
pub subscription: String,
}
/// Progress milestones of the browser OAuth, reported to the login UI so
/// the screen can narrate the flow.
#[derive(Clone, Copy, Debug)]
pub enum LoginPhase {
/// The browser was opened; waiting for the redirect on the listener.
WaitingForBrowser,
/// The authorization code was captured; exchanging it for a session.
Authenticating,
}
/// Run the full system-browser OAuth login. Returns the authenticated
/// session info on success. `on_phase` fires at each milestone; it is called
/// from this async context, so it must hop to the UI thread itself (the
/// caller wraps it in a `CxxQtThread::queue`).
pub async fn login_via_system_browser<A, F>(
runtime: &Arc<AppRuntime<A>>,
on_phase: F,
) -> Result<SessionInfo, String>
where
A: FrontendAdapter + Send + Sync + 'static,
F: Fn(LoginPhase) + Send + Sync,
{
let core = runtime.core();
// No offline_session pre-clear (see the desktop note): the sign-in
// endpoints are gate-exempt and the flag drops on SUCCESS only. The one
// still-gated dependency — a cold bundle-token init — is scoped inside
// ensure_api_initialized.
ensure_api_initialized(core).await?;
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 (loopback, ephemeral
// port) — desktop behavior, unchanged.
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();
// qbzd hardening: a CSPRNG nonce rides the redirect PATH and the
// callback's request path must match it.
let nonce = gen_nonce();
let oauth_url = format!(
"https://www.qobuz.com/signin/oauth?ext_app_id={}&redirect_url={}",
app_id,
urlencoding::encode(&format!("http://localhost:{port}/{nonce}")),
);
log::info!("[qbz-qt] opening system browser for OAuth (port {port})");
open::that(&oauth_url).map_err(|e| format!("Failed to open browser: {e}"))?;
on_phase(LoginPhase::WaitingForBrowser);
let code = tokio::time::timeout(OAUTH_TIMEOUT, capture_oauth_code(listener, &nonce))
.await
.map_err(|_| "OAuth login timed out".to_string())?
.ok_or_else(|| "OAuth login cancelled or no code received".to_string())?;
log::info!("[qbz-qt] OAuth code captured, exchanging for session");
on_phase(LoginPhase::Authenticating);
let session = {
let client_lock = core.client();
let guard = client_lock.read().await;
let client = guard.as_ref().ok_or("Qobuz client not initialized")?;
match client.login_with_oauth_code(&code).await {
Ok(session) => session,
Err(e) => {
// D4 producer: ONLY an explicit ineligible-account verdict
// starts the grace clock. Generic 401/network errors never do.
if matches!(e, qbz_qobuz::ApiError::IneligibleUser) {
crate::offline_fwd::subscription_mark_invalid();
}
return Err(e.to_string());
}
}
};
let user_id = session.user_id;
let display_name = session.display_name.clone();
let subscription = session.subscription_label.clone();
let token = session.user_auth_token.clone();
// Redaction (defense in depth): register the live auth token so any log
// line that embeds it bare is scrubbed.
qbz_log::register_secret(token.clone());
// 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?;
// POC-NOTE: per-user store fan-out skipped here (see module docs).
// Offline-MODE per-user binding, then the D4 valid verdict and the D2
// recovery: a successful login ends any unauthenticated offline session.
if let Some(dir) = crate::offline_fwd::user_data_dir(user_id) {
crate::offline_fwd::init_for_user(&dir);
}
crate::offline_fwd::subscription_mark_valid();
crate::offline_fwd::engine().set_offline_session(false);
// Persist the token so the next launch restores the session silently.
if let Err(e) = qbz_credentials::save_oauth_token(&token) {
log::warn!("[qbz-qt] failed to persist OAuth token: {e}");
}
log::info!("[qbz-qt] login complete for user {user_id}");
Ok(SessionInfo {
user_id,
display_name,
subscription,
})
}
/// Make sure the Qobuz client holds bundle tokens before a sign-in call.
/// Scoped gate lift around the gated cold bundle init — verbatim port of
/// the desktop pattern (crates/qbz/src/auth.rs `ensure_api_initialized`).
async fn ensure_api_initialized<A>(core: &qbz_core::QbzCore<A>) -> Result<(), String>
where
A: FrontendAdapter + Send + Sync + 'static,
{
if core.is_api_initialized().await {
return Ok(());
}
let offline_engine = crate::offline_fwd::engine();
let lifted = offline_engine.status().offline_session;
if lifted {
log::info!(
"[qbz-qt] cold bundle init with an offline session active — lifting the flag for the init only"
);
offline_engine.set_offline_session(false);
}
let result = core.try_init_api().await.map_err(|e| e.to_string());
if lifted {
offline_engine.set_offline_session(true);
}
result
}
/// True only for an EXPLICIT auth rejection from Qobuz: a 401 on the token
/// login or an ineligible-account verdict. Network-class failures return
/// false — on those the saved token must be KEPT (spec §4.1 D1).
fn is_auth_rejection(error: &qbz_core::CoreError) -> bool {
matches!(
error,
qbz_core::CoreError::Api(
qbz_qobuz::ApiError::AuthenticationError(_) | qbz_qobuz::ApiError::IneligibleUser
)
)
}
/// Restore a previously saved session from the encrypted token store.
///
/// Returns `Ok(Some(SessionInfo))` when a saved token is valid and the
/// session is activated, `Ok(None)` when there is no token. A token that
/// exists but is explicitly rejected by Qobuz is cleared and treated as
/// `None`; on network-class failures the token is kept so the session can
/// still be restored later (offline boot, D2 recovery).
pub async fn restore_saved_session<A>(
runtime: &Arc<AppRuntime<A>>,
) -> Result<Option<SessionInfo>, String>
where
A: FrontendAdapter + Send + Sync + 'static,
{
let token = match qbz_credentials::load_oauth_token() {
Ok(Some(token)) => token,
Ok(None) => return Ok(None),
Err(e) => {
log::warn!("[qbz-qt] could not read saved token: {e}");
return Ok(None);
}
};
// Same redaction registration as the browser login path.
qbz_log::register_secret(token.clone());
let core = runtime.core();
ensure_api_initialized(core).await?;
match core.login_with_token(&token).await {
Ok(session) => {
let user_id = session.user_id;
let display_name = session.display_name.clone();
let subscription = session.subscription_label.clone();
core.set_session(session).await.map_err(|e| e.to_string())?;
runtime.activate(user_id).await?;
// POC-NOTE: per-user store fan-out skipped here (see module
// docs) — only the offline-mode binding + D4 verdict stay.
if let Some(dir) = crate::offline_fwd::user_data_dir(user_id) {
crate::offline_fwd::init_for_user(&dir);
}
crate::offline_fwd::subscription_mark_valid();
crate::offline_fwd::engine().set_offline_session(false);
log::info!("[qbz-qt] restored saved session for user {user_id}");
Ok(Some(SessionInfo {
user_id,
display_name,
subscription,
}))
}
Err(e) if is_auth_rejection(&e) => {
log::warn!("[qbz-qt] saved token rejected by Qobuz, clearing: {e}");
let _ = qbz_credentials::clear_oauth_token();
// D4 producer: only the explicit ineligible verdict starts the
// grace clock; a plain 401 does not.
if matches!(
&e,
qbz_core::CoreError::Api(qbz_qobuz::ApiError::IneligibleUser)
) {
crate::offline_fwd::subscription_mark_invalid();
}
Ok(None)
}
Err(e) => {
// Network-class failure (offline boot, timeout, 5xx, ...): KEEP
// the token. The login screen shows with the session intact so
// "Start offline" / the D2 recovery banner can use it later.
log::warn!("[qbz-qt] session restore failed, keeping saved token: {e}");
Ok(None)
}
}
}
/// "Start offline": activate an unauthenticated offline session at the last
/// known user (guest profile user 0 when none — `activate_offline` resolves
/// the same id internally), bind the offline-mode engine, and flag the
/// session-scoped offline state (D1). Returns the resolved user id.
///
/// POC-NOTE: the Slint `enter_shell_offline` also brings up the offline
/// cache, local library, per-user pref stores, tray and media controls —
/// all skipped here; the POC shell placeholder shows only the offline-mode
/// status. Re-add each `init_for_user(&dir)` as its view lands.
pub async fn start_offline_session<A>(runtime: &Arc<AppRuntime<A>>) -> Result<u64, String>
where
A: FrontendAdapter + Send + Sync + 'static,
{
let user_id = UserDataPaths::load_last_user_id().unwrap_or(0);
if user_id == 0 {
log::info!(
"[qbz-qt] offline shell: no previous session — entering the guest profile (user 0)"
);
}
// Session scaffolding at the last user (session store, runtime state).
runtime.activate_offline().await?;
// Offline-MODE engine: bind the per-user stores and flag the
// unauthenticated offline session.
if let Some(dir) = crate::offline_fwd::user_data_dir(user_id) {
crate::offline_fwd::init_for_user(&dir);
}
crate::offline_fwd::engine().set_offline_session(true);
Ok(user_id)
}
/// Log out: clear the saved token, deactivate the per-user session, drop
/// the Qobuz client session, and tear down the offline-mode per-user state
/// (which reopens the Qobuz gate so a logged-out user can sign back in).
///
/// POC-NOTE: the Slint logout also tears down the skipped per-user stores
/// (offline cache, fav_cache, reco, artist vectors, discover prefs,
/// blacklist, pinned, local favorites, search, lyrics) — nothing to tear
/// down here since this phase never opens them.
pub async fn logout<A>(runtime: &Arc<AppRuntime<A>>) -> Result<(), String>
where
A: FrontendAdapter + Send + Sync + 'static,
{
let _ = qbz_credentials::clear_oauth_token();
let _ = runtime.core().logout().await;
crate::offline_fwd::teardown();
runtime.deactivate().await?;
log::info!("[qbz-qt] logged out");
Ok(())
}
/// Accept connections until one carries a NONCE-VALID OAuth code, replying
/// with a minimal success page. Browser noise (favicon requests) and
/// nonce-mismatched requests are answered and skipped (qbzd hardening).
async fn capture_oauth_code(listener: TcpListener, expected_nonce: &str) -> 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 request_line = request.lines().next().unwrap_or("");
let code = parse_callback(request_line, expected_nonce);
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;
}
}
}
/// Returns the authorization code ONLY when the request PATH carries the
/// expected nonce (`GET /<nonce>?...`) — port of qbzd's `parse_callback`.
/// `code_autorisation` wins over `code`, matching the desktop.
fn parse_callback(request_line: &str, expected_nonce: &str) -> Option<String> {
let target = request_line.split_whitespace().nth(1)?;
let (path, query) = target.split_once('?')?;
if path.trim_matches('/') != expected_nonce {
return None; // wrong or absent path nonce → drop
}
query_param(query, "code_autorisation").or_else(|| query_param(query, "code"))
}
/// Extract and percent-decode a query parameter from a `&`-joined query
/// string (the part after `?`).
fn query_param(query: &str, key: &str) -> Option<String> {
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
}
/// A 48-hex-char (24-byte) CSPRNG nonce, bound into the redirect-URL path
/// (port of qbzd's `gen_nonce`).
fn gen_nonce() -> String {
use rand::RngExt;
let mut bytes = [0u8; 24];
rand::rng().fill(&mut bytes);
let mut s = String::with_capacity(48);
for b in bytes {
use std::fmt::Write as _;
let _ = write!(s, "{b:02x}");
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[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("other=1", "code"), None);
}
#[test]
fn parse_callback_accepts_matching_path_nonce() {
let line = "GET /abc123?code_autorisation=THECODE HTTP/1.1";
assert_eq!(parse_callback(line, "abc123"), Some("THECODE".to_string()));
}
#[test]
fn parse_callback_prefers_code_autorisation_over_code() {
let line = "GET /n?code=plain&code_autorisation=preferred HTTP/1.1";
assert_eq!(parse_callback(line, "n"), Some("preferred".to_string()));
}
#[test]
fn parse_callback_rejects_mismatched_or_absent_path_nonce() {
assert_eq!(
parse_callback("GET /WRONG?code_autorisation=THECODE HTTP/1.1", "abc123"),
None
);
assert_eq!(
parse_callback("GET /?code_autorisation=THECODE HTTP/1.1", "abc123"),
None
);
assert_eq!(parse_callback("GET /favicon.ico HTTP/1.1", "abc123"), None);
}
#[test]
fn gen_nonce_is_long_unique_and_hex() {
let a = gen_nonce();
let b = gen_nonce();
assert_ne!(a, b);
assert_eq!(a.len(), 48);
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
}
}
+147 -14
View File
@@ -1,25 +1,158 @@
//! Phase 0 toolchain-gate bridge.
//! The single Qt-side bridge object for the POC.
//!
//! A single trivial QObject proves the cxx-qt codegen <-> Qt 6.11 pipeline
//! (qmltyperegistrar, moc, CMake link) before any real binding work lands.
//! One `QbzBridge` QObject is registered as a QML SINGLETON (`QbzBridge.*`
//! in QML). All session/login/offline state the QML needs lives in its
//! properties; user actions come in as invokables. Invokable bodies NEVER
//! block the Qt thread — they enqueue work onto the process-global tokio
//! runtime (see `main.rs`) and the async results hop back here through
//! `CxxQtThread::queue` (the cxx-qt analogue of Slint's
//! `upgrade_in_event_loop`).
//!
//! `#[auto_cxx_name]` on the extern blocks keeps Rust names snake_case while
//! QML/C++ see camelCase (`login_phase` -> `loginPhase`), matching the
//! property names of the Slint `LoginState`/`OfflineState` globals.
#[cxx_qt::bridge]
mod qbz_bridge {
extern "RustQt" {
#[qobject]
#[qml_element]
#[qproperty(QString, greeting)]
type HelloBridge = super::HelloBridgeRust;
}
pub mod qbz_bridge {
extern "C++" {
include!("cxx-qt-lib/qstring.h");
type QString = cxx_qt_lib::QString;
}
#[auto_cxx_name]
extern "RustQt" {
#[qobject]
#[qml_element]
#[qml_singleton]
// "splash" | "login" | "shell"
#[qproperty(QString, screen)]
// Login narration: 0 idle / 1 waiting-for-browser / 2 authenticating.
#[qproperty(i32, login_phase)]
// Last SIGN-IN failure message ("" = none). Mirrors Slint
// `LoginState.error`.
#[qproperty(QString, login_error)]
// Session-RESTORE failure message ("" = none). Mirrors Slint
// `OfflineState.login-error`. (The Slint app has two separate fields;
// keeping both here so the two login-screen boxes stay distinct.)
#[qproperty(QString, restore_error)]
// Offline-MODE engine snapshot (Slint `OfflineState`):
#[qproperty(bool, offline)]
// 0 online / 1 real offline / 2 induced offline.
#[qproperty(i32, offline_mode)]
// 0 unknown / 1 up / 2 down.
#[qproperty(i32, connectivity)]
#[qproperty(bool, captive_portal)]
#[qproperty(bool, has_previous_session)]
#[qproperty(bool, show_recovery_banner)]
#[qproperty(bool, offline_session)]
// Shell session header:
#[qproperty(QString, session_user_name)]
#[qproperty(QString, session_subscription)]
type QbzBridge = super::QbzBridgeRust;
/// Called once from Main.qml's Component.onCompleted: registers the
/// Qt thread handle and kicks off the boot sequence (offline engine
/// start + silent session restore).
#[qinvokable]
fn boot(self: Pin<&mut QbzBridge>);
/// Login screen primary button: system-browser OAuth (no webview).
#[qinvokable]
fn sign_in_via_browser(self: Pin<&mut QbzBridge>);
/// Cancel link (phase 1): aborts the in-flight OAuth task.
#[qinvokable]
fn cancel_login(self: Pin<&mut QbzBridge>);
/// "Start offline" link: unauthenticated offline session.
#[qinvokable]
fn start_offline(self: Pin<&mut QbzBridge>);
/// Recovery banner "Sign in again": same browser flow.
#[qinvokable]
fn recovery_login(self: Pin<&mut QbzBridge>);
/// Terms-of-Service link: opens the system browser.
#[qinvokable]
fn open_tos(self: Pin<&mut QbzBridge>);
/// Shell logout: back to the login screen.
#[qinvokable]
fn logout(self: Pin<&mut QbzBridge>);
/// i18n lookup against the shared gettext catalog (qbz-i18n), so the
/// QML texts reuse the EXISTING .po translations via the same msgids
/// the Slint `@tr()` calls use.
#[qinvokable]
fn tr(self: &QbzBridge, msgid: QString) -> QString;
}
impl cxx_qt::Threading for QbzBridge {}
}
/// Rust side of the phase-0 object (constructed from QML if instantiated).
use core::pin::Pin;
use cxx_qt::Threading as _;
use cxx_qt_lib::QString;
/// Rust side of the bridge. All fields are driven exclusively through the
/// generated `set_*` methods on the Qt thread; the struct itself is plain
/// storage (as required by cxx-qt's Default-constructed qobjects).
#[derive(Default)]
pub struct HelloBridgeRust {
greeting: cxx_qt_lib::QString,
pub struct QbzBridgeRust {
screen: QString,
login_phase: i32,
login_error: QString,
restore_error: QString,
offline: bool,
offline_mode: i32,
connectivity: i32,
captive_portal: bool,
has_previous_session: bool,
show_recovery_banner: bool,
offline_session: bool,
session_user_name: QString,
session_subscription: QString,
}
impl qbz_bridge::QbzBridge {
pub fn boot(self: Pin<&mut Self>) {
// The very first invokable: hand the Qt-thread handle to the process
// global so tokio tasks can queue property updates back here.
crate::register_qt_thread(self.qt_thread());
crate::on_boot();
}
pub fn sign_in_via_browser(self: Pin<&mut Self>) {
crate::start_login();
}
pub fn cancel_login(self: Pin<&mut Self>) {
crate::cancel_login();
}
pub fn start_offline(self: Pin<&mut Self>) {
crate::start_offline();
}
pub fn recovery_login(self: Pin<&mut Self>) {
crate::start_login();
}
pub fn open_tos(self: Pin<&mut Self>) {
// Same URL the Slint shell opens (crates/qbz/src/main.rs
// QOBUZ_TOS_URL). `open` spawns xdg-open detached, so this is safe to
// run on the Qt thread.
if let Err(e) = open::that(crate::QOBUZ_TOS_URL) {
log::error!("[qbz-qt] failed to open Terms of Service: {e}");
}
}
pub fn logout(self: Pin<&mut Self>) {
crate::do_logout();
}
pub fn tr(&self, msgid: QString) -> QString {
QString::from(&qbz_i18n::t(&msgid.to_string()))
}
}
+214 -2
View File
@@ -1,12 +1,224 @@
//! qbz-qt — Qt/QML frontend POC entry point.
//!
//! Phase 0: boot a QML engine on the qrc module and show a gate window.
//! Phase 1: boot flow (splash -> silent session restore -> shell
//! placeholder | login screen) with all the offline guardrails of the
//! Slint app. `main` is NOT async: `QGuiApplication::exec()` owns the main
//! thread, so a process-global multi-thread tokio runtime carries all
//! async work; results hop back to Qt through the bridge's `CxxQtThread`.
mod auth_qt;
mod bridge;
mod offline_fwd;
use cxx_qt_lib::{QGuiApplication, QQmlApplicationEngine, QUrl};
use std::pin::Pin;
use std::sync::{Arc, Mutex, OnceLock};
use cxx_qt::CxxQtThread;
use cxx_qt_lib::{QGuiApplication, QQmlApplicationEngine, QString, QUrl};
use qbz_app::shell::AppRuntime;
use qbz_core::LoggingAdapter;
use tokio::runtime::Runtime;
use tokio::task::JoinHandle;
use bridge::qbz_bridge::QbzBridge;
/// Same URL the Slint shell opens for the Terms-of-Service link.
pub(crate) const QOBUZ_TOS_URL: &str = "https://www.qobuz.com/us-en/legal/terms";
/// The async runtime for everything non-Qt (OAuth, session restore,
/// connectivity monitoring). Lives for the whole process.
static TOKIO: OnceLock<Runtime> = OnceLock::new();
/// The UI-agnostic composition root (core + runtime state + session).
static APP: OnceLock<Arc<AppRuntime<LoggingAdapter>>> = OnceLock::new();
/// Qt-thread handle of the singleton bridge, registered by its first
/// invokable (`boot`). `CxxQtThread` is Send+Sync and safe to queue on from
/// any thread; before registration, `ui()` is a no-op.
static QT_THREAD: OnceLock<CxxQtThread<QbzBridge>> = OnceLock::new();
/// In-flight browser-OAuth task, so `cancelLogin()` can `abort()` it
/// (mirrors `login_task` in crates/qbz/src/main.rs).
static LOGIN_TASK: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
pub(crate) fn register_qt_thread(thread: CxxQtThread<QbzBridge>) {
if QT_THREAD.set(thread).is_err() {
log::warn!("[qbz-qt] Qt thread handle already registered");
}
}
pub(crate) fn app() -> Arc<AppRuntime<LoggingAdapter>> {
Arc::clone(APP.get().expect("AppRuntime not initialized"))
}
/// Spawn a future on the process-global tokio runtime.
pub(crate) fn spawn(future: impl std::future::Future<Output = ()> + Send + 'static) {
TOKIO
.get()
.expect("tokio runtime not initialized")
.spawn(future);
}
/// Queue a bridge mutation onto the Qt event loop — the cxx-qt analogue of
/// Slint's `upgrade_in_event_loop`. No-op before the bridge registers its
/// thread handle (boot's first step) or after it is destroyed.
pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzBridge>) + Send + 'static) {
if let Some(thread) = QT_THREAD.get() {
let _ = thread.queue(f);
}
}
/// Boot sequence, fired by the bridge's `boot` invokable (Main.qml
/// Component.onCompleted — the Qt-thread handle is registered first, so
/// every `ui()` hop below lands).
pub(crate) fn on_boot() {
// Offline guardrails FIRST (before the login screen can show): engine +
// connectivity actor, then the live status forwarder. Both need the
// tokio runtime context for their spawns.
spawn(async {
offline_fwd::start();
});
offline_fwd::start_ui_forwarder();
// Splash -> silent session restore -> shell | login.
let runtime = app();
spawn(async move {
// Best-effort, offline-tolerant (mirrors the Slint boot): a network
// failure here leaves the core usable for offline playback.
if let Err(e) = runtime.init().await {
log::warn!("[qbz-qt] core init failed (continuing): {e}");
}
match auth_qt::restore_saved_session(&runtime).await {
Ok(Some(session)) => enter_shell(session),
Ok(None) => ui(|mut b| b.as_mut().set_screen(QString::from("login"))),
Err(e) => ui(move |mut b| {
b.as_mut().set_restore_error(QString::from(e.as_str()));
b.as_mut().set_screen(QString::from("login"));
}),
}
});
}
/// Post-login UI state: session header, refresh has-previous-session,
/// clear login errors/phase, switch to the shell.
fn enter_shell(session: auth_qt::SessionInfo) {
ui(move |mut b| {
b.as_mut().set_session_user_name(QString::from(session.display_name.as_str()));
b.as_mut().set_session_subscription(QString::from(session.subscription.as_str()));
b.as_mut().set_has_previous_session(true);
b.as_mut().set_login_error(QString::from(""));
b.as_mut().set_restore_error(QString::from(""));
b.as_mut().set_login_phase(0);
b.as_mut().set_screen(QString::from("shell"));
});
}
/// Login screen primary button / recovery banner: the system-browser OAuth
/// flow on a background task; phases and results hop back to Qt.
pub(crate) fn start_login() {
{
let guard = LOGIN_TASK.lock().unwrap();
if guard.is_some() {
log::warn!("[qbz-qt] login already in progress, ignoring");
return;
}
}
ui(|mut b| {
b.as_mut().set_login_error(QString::from(""));
b.as_mut().set_login_phase(1);
});
let runtime = app();
let handle = TOKIO.get().unwrap().spawn(async move {
let result = auth_qt::login_via_system_browser(&runtime, |phase| {
let value = match phase {
auth_qt::LoginPhase::WaitingForBrowser => 1,
auth_qt::LoginPhase::Authenticating => 2,
};
ui(move |mut b| b.as_mut().set_login_phase(value));
})
.await;
LOGIN_TASK.lock().unwrap().take();
match result {
Ok(session) => enter_shell(session),
Err(e) => ui(move |mut b| {
b.as_mut().set_login_phase(0);
b.as_mut().set_login_error(QString::from(e.as_str()));
}),
}
});
*LOGIN_TASK.lock().unwrap() = Some(handle);
}
/// Cancel link (phase 1): abort the in-flight OAuth and return to idle.
pub(crate) fn cancel_login() {
if let Some(task) = LOGIN_TASK.lock().unwrap().take() {
task.abort();
}
ui(|mut b| b.as_mut().set_login_phase(0));
}
/// "Start offline": unauthenticated offline session -> shell placeholder.
pub(crate) fn start_offline() {
let runtime = app();
spawn(async move {
match auth_qt::start_offline_session(&runtime).await {
Ok(user_id) => {
let name = if user_id == 0 {
"Guest (offline)".to_string()
} else {
format!("Offline (user {user_id})")
};
ui(move |mut b| {
b.as_mut().set_session_user_name(QString::from(name.as_str()));
b.as_mut().set_session_subscription(QString::from(""));
b.as_mut().set_login_error(QString::from(""));
b.as_mut().set_restore_error(QString::from(""));
b.as_mut().set_login_phase(0);
b.as_mut().set_screen(QString::from("shell"));
});
}
Err(e) => {
log::error!("[qbz-qt] failed to enter offline mode: {e}");
ui(move |mut b| {
b.as_mut().set_login_error(QString::from(e.as_str()));
});
}
}
});
}
/// Shell logout: token + session teardown, then back to the login screen.
pub(crate) fn do_logout() {
let runtime = app();
spawn(async move {
if let Err(e) = auth_qt::logout(&runtime).await {
log::error!("[qbz-qt] logout failed: {e}");
}
ui(|mut b| {
b.as_mut().set_session_user_name(QString::from(""));
b.as_mut().set_session_subscription(QString::from(""));
b.as_mut().set_login_error(QString::from(""));
b.as_mut().set_restore_error(QString::from(""));
b.as_mut().set_login_phase(0);
b.as_mut().set_screen(QString::from("login"));
});
});
}
fn main() {
qbz_log::install("info");
qbz_i18n::set_language(qbz_i18n::resolve_auto());
// rustls process-level CryptoProvider (aws-lc-rs) — required before any
// reqwest call, same as the Slint and daemon binaries.
qbz_app::ensure_crypto_provider();
let tokio_runtime = Runtime::new().expect("failed to build the tokio runtime");
let _ = TOKIO.set(tokio_runtime);
let runtime = Arc::new(AppRuntime::new(LoggingAdapter::new("[qbz-qt]")));
let _ = APP.set(runtime);
let mut app = QGuiApplication::new();
let mut engine = QQmlApplicationEngine::new();
+201
View File
@@ -0,0 +1,201 @@
//! Qt-side glue for the shared offline-MODE engine — the cxx-qt port of
//! `crates/qbz/src/offline_mode.rs` MINUS the Slint parts.
//!
//! Offline MODE = the app operating without Qobuz — NOT the offline CACHE
//! (downloads). The engine, connectivity actor and persisted settings are
//! frontend-agnostic (`qbz_app::offline_mode`, ADR-006); this module owns
//! only the process globals, the per-user binding, and the status -> QML
//! forwarder (`engine().subscribe()` watch -> `CxxQtThread::queue`).
//!
//! POC-NOTE (skipped vs the Slint glue): `check_now` / `seed_settings` /
//! `request_recheck` (Settings > Offline UI — no settings views in the
//! POC), `offline_playback_allowed` (no playback yet), and the
//! subscription purge consumer (`spawn_subscription_purge_check` — needs
//! the offline cache, which this phase does not bring up; TODO when the
//! offline cache is wired).
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock, Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
use qbz_app::offline_mode::{
Connectivity, ConnectivityActor, OfflineMode, OfflineModeEngine, OfflineStatus,
};
use qbz_app::settings::subscription::SubscriptionStateStore;
use qbz_app::user_data::UserDataPaths;
/// Process-global engine. Exists from first use; per-user state binds via
/// [`init_for_user`], connectivity via [`start`].
static ENGINE: LazyLock<Arc<OfflineModeEngine>> =
LazyLock::new(|| Arc::new(OfflineModeEngine::new()));
/// The connectivity actor, spawned once per process by [`start`].
static CONNECTIVITY: OnceLock<ConnectivityActor> = OnceLock::new();
/// Per-user subscription state (D4). `None` until a session (online or
/// offline) is activated; consumers fail open in that window.
static SUBSCRIPTION: Mutex<Option<SubscriptionStateStore>> = Mutex::new(None);
pub fn engine() -> Arc<OfflineModeEngine> {
Arc::clone(&ENGINE)
}
/// Spawn the connectivity actor and attach it to the engine. Called once
/// during boot (from a tokio context — the spawns need the runtime); the
/// monitoring runs for the whole app lifetime, login screen included (the
/// restore flow and the D2 recovery banner read it).
pub fn start() {
if CONNECTIVITY.get().is_some() {
return;
}
let actor = ConnectivityActor::spawn();
engine().attach_connectivity(&actor);
if CONNECTIVITY.set(actor).is_err() {
log::warn!("[qbz-qt] offline mode: connectivity actor already started");
} else {
log::info!("[qbz-qt] offline mode: connectivity monitoring started");
}
}
/// Mirror every engine status change into the bridge properties (the login
/// affordances + the D2 recovery banner read them). Also seeds
/// `hasPreviousSession` once; `enter_shell` refreshes it after a successful
/// login. Spawned once from the boot sequence.
pub fn start_ui_forwarder() {
let has_previous = UserDataPaths::load_last_user_id().is_some();
crate::ui(move |mut b| b.as_mut().set_has_previous_session(has_previous));
crate::spawn(async move {
let mut rx = engine().subscribe();
loop {
let status = *rx.borrow_and_update();
crate::ui(move |b| apply_status(b, status));
if rx.changed().await.is_err() {
break;
}
}
});
}
/// Push one engine status snapshot into the bridge (queued on the Qt
/// thread via [`crate::ui`]).
fn apply_status(mut b: core::pin::Pin<&mut crate::bridge::qbz_bridge::QbzBridge>, status: OfflineStatus) {
b.as_mut().set_offline(status.is_offline());
b.as_mut().set_offline_mode(match status.mode {
OfflineMode::Online => 0,
OfflineMode::RealOffline => 1,
OfflineMode::InducedOffline => 2,
});
b.as_mut().set_connectivity(match status.connectivity {
Connectivity::Unknown => 0,
Connectivity::Up => 1,
Connectivity::Down => 2,
});
b.as_mut().set_captive_portal(status.captive_portal);
b.as_mut().set_show_recovery_banner(status.show_recovery_banner());
// The header badge's "Logged out" state needs the raw session flag —
// show_recovery_banner() is false while connectivity is down.
b.as_mut().set_offline_session(status.offline_session);
}
/// `<data_dir>/qbz/users/<user_id>/` — the per-user directory both the
/// engine store and the subscription store live in. Matches the Tauri and
/// Slint per-user path.
pub fn user_data_dir(user_id: u64) -> Option<PathBuf> {
Some(
dirs::data_dir()?
.join("qbz")
.join("users")
.join(user_id.to_string()),
)
}
/// Bind the engine + subscription store to the active user's data dir.
/// Called on every session activation (login, restore, offline entry).
/// Best-effort: failures are logged, never block entry.
///
/// POC-NOTE: the Slint version also spawns the subscription purge check
/// here — omitted (needs the offline cache; see module docs).
pub fn init_for_user(base_dir: &Path) {
if let Err(e) = engine().init_for_user(base_dir) {
log::error!("[qbz-qt] offline mode engine init failed: {e}");
}
match SubscriptionStateStore::new_at(base_dir) {
Ok(store) => {
if let Ok(mut guard) = SUBSCRIPTION.lock() {
*guard = Some(store);
}
}
// Fail-open: no store means no recorded invalidity, playback allowed.
Err(e) => log::error!("[qbz-qt] subscription state store open failed: {e}"),
}
}
/// Drop the per-user state on logout. The engine also ends the
/// session-scoped offline state (offline_session + cached induced flag) and
/// reopens the Qobuz gate when connectivity allows — a logged-out user must
/// always be able to sign back in.
pub fn teardown() {
engine().teardown();
if let Ok(mut guard) = SUBSCRIPTION.lock() {
*guard = None;
}
}
fn now_unix_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
/// D4 producer: a successful login verdict. Clears any running grace clock.
pub fn subscription_mark_valid() {
let now = now_unix_secs();
match SUBSCRIPTION.lock() {
Ok(guard) => match guard.as_ref() {
Some(store) => {
if let Err(e) = store.mark_valid(now) {
log::error!("[qbz-qt] subscription mark_valid failed: {e}");
}
}
None => log::warn!("[qbz-qt] subscription mark_valid: no store open"),
},
Err(e) => log::error!("[qbz-qt] subscription store lock poisoned: {e}"),
}
}
/// D4 producer: an EXPLICIT ineligible-account login verdict
/// (`ApiError::IneligibleUser`). Generic 401/network errors must never
/// reach this — the grace clock only starts on a real verdict.
///
/// An ineligible verdict can arrive before any session activation, so when
/// no store is open this falls back to transiently opening the LAST user's
/// store.
pub fn subscription_mark_invalid() {
let now = now_unix_secs();
if let Ok(guard) = SUBSCRIPTION.lock() {
if let Some(store) = guard.as_ref() {
if let Err(e) = store.mark_invalid(now) {
log::error!("[qbz-qt] subscription mark_invalid failed: {e}");
}
return;
}
}
let Some(user_id) = UserDataPaths::load_last_user_id() else {
log::warn!("[qbz-qt] subscription mark_invalid: no previous user, skipping");
return;
};
let Some(dir) = user_data_dir(user_id) else {
log::warn!("[qbz-qt] subscription mark_invalid: data dir unavailable");
return;
};
match SubscriptionStateStore::new_at(&dir) {
Ok(store) => {
if let Err(e) = store.mark_invalid(now) {
log::error!("[qbz-qt] subscription mark_invalid failed: {e}");
}
}
Err(e) => log::error!("[qbz-qt] subscription state store open failed: {e}"),
}
}