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:
12 files changed
+1865
-34
No files matched your search
Generated
+268
-9
@@ -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"
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 |
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user