From df7eb3588e82bb86e1bba340e37a20d66e710d1d Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:09:31 -0600 Subject: [PATCH] =?UTF-8?q?feat(qbz-qt):=20phase=201=20=E2=80=94=20login?= =?UTF-8?q?=20screen=20with=20full=20offline=20guardrails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/Cargo.lock | 277 ++++++++++++++- crates/qbz-qt/Cargo.toml | 15 + crates/qbz-qt/build.rs | 11 +- crates/qbz-qt/qml/LoginScreen.qml | 369 ++++++++++++++++++++ crates/qbz-qt/qml/Main.qml | 48 ++- crates/qbz-qt/qml/QbzTheme.qml | 38 +++ crates/qbz-qt/qml/ShellPlaceholder.qml | 115 +++++++ crates/qbz-qt/qml/assets/qbz-logo.png | Bin 0 -> 80834 bytes crates/qbz-qt/src/auth_qt.rs | 448 +++++++++++++++++++++++++ crates/qbz-qt/src/bridge.rs | 161 ++++++++- crates/qbz-qt/src/main.rs | 216 +++++++++++- crates/qbz-qt/src/offline_fwd.rs | 201 +++++++++++ 12 files changed, 1865 insertions(+), 34 deletions(-) create mode 100644 crates/qbz-qt/qml/LoginScreen.qml create mode 100644 crates/qbz-qt/qml/QbzTheme.qml create mode 100644 crates/qbz-qt/qml/ShellPlaceholder.qml create mode 100644 crates/qbz-qt/qml/assets/qbz-logo.png create mode 100644 crates/qbz-qt/src/auth_qt.rs create mode 100644 crates/qbz-qt/src/offline_fwd.rs diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 64aa5fa61..702798d70 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -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" diff --git a/crates/qbz-qt/Cargo.toml b/crates/qbz-qt/Cargo.toml index 8cfa87d2f..fcb0fa8f7 100644 --- a/crates/qbz-qt/Cargo.toml +++ b/crates/qbz-qt/Cargo.toml @@ -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" diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index f3bc4e6ac..937620219 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -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(); diff --git a/crates/qbz-qt/qml/LoginScreen.qml b/crates/qbz-qt/qml/LoginScreen.qml new file mode 100644 index 000000000..7de31c2ca --- /dev/null +++ b/crates/qbz-qt/qml/LoginScreen.qml @@ -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 + } + } + } +} diff --git a/crates/qbz-qt/qml/Main.qml b/crates/qbz-qt/qml/Main.qml index b499bec15..3909bbe7b 100644 --- a/crates/qbz-qt/qml/Main.qml +++ b/crates/qbz-qt/qml/Main.qml @@ -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 + } } } } diff --git a/crates/qbz-qt/qml/QbzTheme.qml b/crates/qbz-qt/qml/QbzTheme.qml new file mode 100644 index 000000000..c79f17185 --- /dev/null +++ b/crates/qbz-qt/qml/QbzTheme.qml @@ -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 +} diff --git a/crates/qbz-qt/qml/ShellPlaceholder.qml b/crates/qbz-qt/qml/ShellPlaceholder.qml new file mode 100644 index 000000000..069d213ff --- /dev/null +++ b/crates/qbz-qt/qml/ShellPlaceholder.qml @@ -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() + } + } + } +} diff --git a/crates/qbz-qt/qml/assets/qbz-logo.png b/crates/qbz-qt/qml/assets/qbz-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..859d9a4cc385a97c8de24099837363b6cc7ca57b GIT binary patch literal 80834 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
AraY?nR70tl6}* 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 literal 0 HcmV?d00001 diff --git a/crates/qbz-qt/src/auth_qt.rs b/crates/qbz-qt/src/auth_qt.rs new file mode 100644 index 000000000..51d44bdaa --- /dev/null +++ b/crates/qbz-qt/src/auth_qt.rs @@ -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( + runtime: &Arc>, + on_phase: F, +) -> Result +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(core: &qbz_core::QbzCore) -> 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( + runtime: &Arc>, +) -> Result, 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(runtime: &Arc>) -> Result +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(runtime: &Arc>) -> 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 { + 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() { + "\ +

Login successful

You can close this tab and return to QBZ.

" + } else { + "\ +

Waiting for Qobuz...

" + }; + 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 /?...`) — port of qbzd's `parse_callback`. +/// `code_autorisation` wins over `code`, matching the desktop. +fn parse_callback(request_line: &str, expected_nonce: &str) -> Option { + 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 { + 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())); + } +} diff --git a/crates/qbz-qt/src/bridge.rs b/crates/qbz-qt/src/bridge.rs index 61d54b3f0..770669917 100644 --- a/crates/qbz-qt/src/bridge.rs +++ b/crates/qbz-qt/src/bridge.rs @@ -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())) + } } diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index a6861aa79..ce7162dab 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -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 = OnceLock::new(); + +/// The UI-agnostic composition root (core + runtime state + session). +static APP: OnceLock>> = 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> = 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>> = Mutex::new(None); + +pub(crate) fn register_qt_thread(thread: CxxQtThread) { + if QT_THREAD.set(thread).is_err() { + log::warn!("[qbz-qt] Qt thread handle already registered"); + } +} + +pub(crate) fn app() -> Arc> { + 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 + 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(); diff --git a/crates/qbz-qt/src/offline_fwd.rs b/crates/qbz-qt/src/offline_fwd.rs new file mode 100644 index 000000000..35902ba41 --- /dev/null +++ b/crates/qbz-qt/src/offline_fwd.rs @@ -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> = + LazyLock::new(|| Arc::new(OfflineModeEngine::new())); + +/// The connectivity actor, spawned once per process by [`start`]. +static CONNECTIVITY: OnceLock = 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> = Mutex::new(None); + +pub fn engine() -> Arc { + 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); +} + +/// `/qbz/users//` — 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 { + 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}"), + } +}