feat(qt): kiosk profile — resolution, boot and the live toggle

Port of contract section 8. Three Slint blocks, faithfully:

- main.rs:7855-7876 kiosk_profile_active() / resolved_shell_screen()
- main.rs:8586-8618 kiosk entry (forced reduce-motion; fullscreen ONLY via
  QBZ_KIOSK_FULLSCREEN, windowed otherwise -- incident 2026-07-11: a user who
  toggles kiosk on the desktop and restarts would be trapped, since the kiosk
  shell has no titlebar control and neither Esc nor F11 leave fullscreen)
- main.rs:11767-11780 the live toggle, in the reference's order: persist,
  flip the flag, then swap the screen. The flag first means the shell that
  mounts next reads a correct kioskProfile on its FIRST frame.

"kiosk" is a SIBLING screen of "shell", not a mode inside it, which is how
app.slint:38 models it (AppScreen { splash, login, shell, kiosk }, sibling
mounts at :145 and :205). Both shells read the same bridges, so the toggle
tears nothing down and playback keeps running.

The toggle reads the CURRENT profile rather than re-resolving: resolving
would let a QBZ_PROFILE env override pin the reading and a user on a kiosk
image could never toggle out (the reference says so at :11764-11765).

ViewModeMenu's delegate called QbzImmersive.openFromMenu() unconditionally,
so flipping any row's `live` would have made it open Immersive. Each row now
carries its own action. Miniplayer stays the one inert row.

The small bar's view button becomes the toggle itself in kiosk
(PlayerBarSmall.slint:746-750): every other flyout row is gated
!kiosk-profile there, so the menu would carry a single row.

reduce_motion's kiosk half is now live -- shell_bridge.rs had the seam
pre-built with a `false` literal and a note saying one line, on their side.

Also: the gear icon. NavRail tile 7 uses settings.svg; this port only had
settings-2.svg, which is two sliders, a different glyph.

288 tests (4 new), 9 warnings = baseline, both QML audits OK.

cc
This commit is contained in:
vicrodh committed 2026-08-03 22:32:10 -06:00
1 parent 87c20fce8f
commit 221f8704d5
10 files changed
+314 -22

No files matched your search

+9 -1
View File
@@ -449,9 +449,17 @@ ApplicationWindow {
id: screenLoader
anchors.fill: parent
active: QbzSession.screen !== "splash"
// "kiosk" is a SIBLING of "shell", not a mode inside it — the same
// shape as the reference (app.slint:38's AppScreen enum, with the two
// shells as sibling mounts at :145 and :205). Both read the same
// bridges, so the live toggle swaps them with nothing torn down.
// An unknown id falls through to "" and the Loader goes blank, which
// is the documented failure mode for a missing route (nav_qt.rs:14-16)
// and is what a missing KioskShell.qml would look like.
source: QbzSession.screen === "login"
? "LoginScreen.qml"
: (QbzSession.screen === "shell" ? "shell/AppShell.qml" : "")
: (QbzSession.screen === "shell" ? "shell/AppShell.qml"
: (QbzSession.screen === "kiosk" ? "shell/KioskShell.qml" : ""))
// Hand the host window down for drag/maximize/resize (custom chrome).
onLoaded: if (screenLoader.item) screenLoader.item.hostWindow = window
}
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" />
<circle cx="12" cy="12" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 562 B

+12 -1
View File
@@ -477,7 +477,18 @@ Rectangle {
name: "layout-grid"
active: smallViewMenu.opened
anchors.verticalCenter: parent.verticalCenter
onClicked: smallViewMenu.openBelowRight(smallViewBtn)
// In kiosk the button IS the toggle, skipping the menu
// (`shell/PlayerBarSmall.slint:746-750`). Every other
// row of that flyout is gated `!kiosk-profile` in the
// reference, so in kiosk it would carry a single row —
// which the fixed-height popup floats high above the
// button (the reason given at `:741-745`).
onClicked: {
if (QbzShell.kioskProfile)
QbzSession.toggleProfile()
else
smallViewMenu.openBelowRight(smallViewBtn)
}
}
QbzIconButton {
name: "mic-vocal"
+23 -8
View File
@@ -96,14 +96,24 @@ QbzContextMenu {
}
Repeater {
// Window-mode rows. `live` flips per-row inside the shared delegate
// (2026-08-02 immersive-port §7): only the Immersive row is wired in
// B2 — it loses the 0.45 dim and gains the openFromMenu click.
// Miniplayer + Kiosk stay inert (kiosk goes live in the
// 2026-08-02-kiosk-port contract §8 — do not touch it).
// (2026-08-02 immersive-port §7); Immersive went live in that
// contract's B2, Kiosk in the 2026-08-02-kiosk-port §8.1. Miniplayer
// remains the one inert row (sanctioned, kiosk contract §9.2).
//
// Each row carries its own `action` because the delegate's MouseArea
// used to call QbzImmersive.openFromMenu() unconditionally: flipping
// any other row's `live` would have made it open Immersive.
//
// The Kiosk label mirrors the profile, 1:1 with the reference
// (`shell/PlayerBar.slint:797`): in kiosk it reads "Desktop mode",
// because the row is the way back out.
model: [
{ "label": QbzSession.tr("Miniplayer", QbzSession.trRev), "icon": "picture-in-picture-2", "live": false },
{ "label": QbzSession.tr("Immersive", QbzSession.trRev), "icon": "maximize-2", "live": true },
{ "label": QbzSession.tr("Kiosk mode", QbzSession.trRev), "icon": "hard-drive", "live": false },
{ "label": QbzSession.tr("Miniplayer", QbzSession.trRev), "icon": "picture-in-picture-2", "live": false, "action": "miniplayer" },
{ "label": QbzSession.tr("Immersive", QbzSession.trRev), "icon": "maximize-2", "live": true, "action": "immersive" },
{ "label": QbzShell.kioskProfile
? QbzSession.tr("Desktop mode", QbzSession.trRev)
: QbzSession.tr("Kiosk mode", QbzSession.trRev),
"icon": "hard-drive", "live": true, "action": "kiosk" },
]
delegate: Rectangle {
required property var modelData
@@ -134,7 +144,12 @@ QbzContextMenu {
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
root.close()
QbzImmersive.openFromMenu()
if (modelData.action === "immersive")
QbzImmersive.openFromMenu()
else if (modelData.action === "kiosk")
QbzSession.toggleProfile()
// "miniplayer" has live: false, so `enabled` above keeps
// this handler unreachable for it.
}
}
}
+3
View File
@@ -245,6 +245,9 @@ const MASTERS: &[(&str, &str)] = &[
("rotate-ccw", include_str!("../qml/assets/icons/primary/rotate-ccw.svg")),
("rows-3", include_str!("../qml/assets/icons/primary/rows-3.svg")),
("search", include_str!("../qml/assets/icons/primary/search.svg")),
// The GEAR. Distinct from settings-2 (two sliders) — the kiosk NavRail's
// seventh tile uses this one (`shell/NavRail.slint:185`).
("settings", include_str!("../qml/assets/icons/primary/settings.svg")),
("settings-2", include_str!("../qml/assets/icons/primary/settings-2.svg")),
("shopping-bag", include_str!("../qml/assets/icons/primary/shopping-bag.svg")),
("shuffle", include_str!("../qml/assets/icons/primary/shuffle.svg")),
+199
View File
@@ -0,0 +1,199 @@
//! Kiosk profile — resolution, the live toggle, and the boot decisions.
//!
//! Port of three Slint blocks (2026-08-02 kiosk-port contract §8):
//!
//! * `qbz-nix/crates/qbz/src/main.rs:7855-7876` — `kiosk_profile_active()`
//! and `resolved_shell_screen()`.
//! * `:8586-8618` — kiosk entry: forced reduce-motion, and fullscreen ONLY
//! via `QBZ_KIOSK_FULLSCREEN`.
//! * `:11767-11780` — the live Kiosk <-> Desktop toggle.
//!
//! The `profile` key is shared with the shipping Slint app
//! (`qbz-nix/crates/qbz/src/ui_prefs.rs:634-635`, default `"desktop"`), so it
//! is read and written through `settings_qt`'s additive single-key patch and
//! never by rewriting the file.
use std::sync::atomic::{AtomicBool, Ordering};
use cxx_qt_lib::QString;
use crate::{session_bridge, settings_qt, shell_bridge};
/// The persisted key, shared with Slint.
const PREF_KEY: &str = "profile";
const KIOSK: &str = "kiosk";
const DESKTOP: &str = "desktop";
/// Rust-side mirror of the live profile.
///
/// Two readers need it and neither can reach a Qt property: the boot path
/// decides a screen string before any QObject exists, and [`toggle`] runs off
/// the Qt thread (`nav_qt.rs:63-66` documents that constraint and why an
/// accessor exists rather than a QML round trip). Same shape as
/// `shell_bridge.rs:579`'s `QUEUE_OPEN`.
static KIOSK_ACTIVE: AtomicBool = AtomicBool::new(false);
/// Is the kiosk profile on right now?
pub fn active() -> bool {
KIOSK_ACTIVE.load(Ordering::SeqCst)
}
/// Resolve the profile from `QBZ_PROFILE` (env wins) else the persisted key.
///
/// 1:1 with `main.rs:7855-7867`, including the two details that make the env
/// override usable: it is trimmed and compared case-insensitively, and an
/// EMPTY value falls through to the pref rather than reading as "not kiosk" —
/// so `QBZ_PROFILE=` behaves like an unset variable, and `QBZ_PROFILE=desktop`
/// can force the desktop shell back on a kiosk image.
pub fn resolve() -> bool {
if let Ok(v) = std::env::var("QBZ_PROFILE") {
let v = v.trim();
if !v.is_empty() {
return v.eq_ignore_ascii_case(KIOSK);
}
}
settings_qt::pref_str(PREF_KEY, DESKTOP)
.trim()
.eq_ignore_ascii_case(KIOSK)
}
/// Called once at boot, before the first screen is published: resolve the
/// profile, latch it, and report the two things it decides.
///
/// Returns whether the kiosk profile is on.
pub fn init_at_boot() -> bool {
let kiosk = resolve();
KIOSK_ACTIVE.store(kiosk, Ordering::SeqCst);
if kiosk {
// `main.rs:8607-8617`. Fullscreen at boot is OPT-IN and stays that
// way: a user who toggles kiosk on the desktop and restarts would be
// TRAPPED, because the kiosk shell has no titlebar control and
// neither Esc nor F11 leave fullscreen (incident 2026-07-11).
if fullscreen_at_boot() {
log::info!(
"[qbz-qt] kiosk profile active -> fullscreen-at-boot \
(QBZ_KIOSK_FULLSCREEN) + forced reduce-motion"
);
} else {
log::info!(
"[qbz-qt] kiosk profile active (reduce-motion on); windowed \
— set QBZ_KIOSK_FULLSCREEN=1 for appliance boot"
);
}
}
kiosk
}
/// `QBZ_KIOSK_FULLSCREEN` is presence-checked, not parsed — `main.rs:8608`
/// uses `env::var_os(..).is_some()`, so any value including an empty one
/// arms it.
pub fn fullscreen_at_boot() -> bool {
std::env::var_os("QBZ_KIOSK_FULLSCREEN").is_some()
}
/// The screen id the post-login mount should use — the Qt spelling of
/// `resolved_shell_screen()` (`main.rs:7869-7876`).
///
/// Every `set_screen("shell")` site goes through this, so a kiosk-profile
/// session lands in the kiosk shell whether it arrived by login, by silent
/// session restore, or by the offline path.
pub fn shell_screen() -> &'static str {
if active() {
KIOSK
} else {
"shell"
}
}
/// The live toggle (`main.rs:11767-11780`).
///
/// The order is load-bearing and matches the reference: persist, then flip the
/// flag, then swap the screen. The flag before the screen means the shell that
/// mounts next reads a correct `QbzShell.kioskProfile` on its FIRST frame
/// rather than one turn later.
///
/// The decision reads the CURRENT state, not [`resolve`] — the reference's
/// comment at `:11764-11765` is explicit about why: resolving would let a
/// `QBZ_PROFILE` env override pin the reading, and a user on a kiosk image
/// could then never toggle out.
pub fn toggle() {
let to_kiosk = !active();
KIOSK_ACTIVE.store(to_kiosk, Ordering::SeqCst);
let value = if to_kiosk { KIOSK } else { DESKTOP };
settings_qt::save_pref(PREF_KEY, serde_json::json!(value));
log::info!("[qbz-qt] profile -> {value}");
// Forced reduce-motion follows the profile (`main.rs:8596-8598`): the
// reference computes `kiosk_profile || !use_gpu_renderer`, and only the
// kiosk half has an honest source in this port (shell_bridge.rs:551-566).
shell_bridge::ui(move |mut b| {
b.as_mut().set_kiosk_profile(to_kiosk);
b.as_mut().set_reduce_motion(to_kiosk);
});
session_bridge::ui(move |mut b| {
b.as_mut()
.set_screen(QString::from(if to_kiosk { KIOSK } else { "shell" }));
});
}
#[cfg(test)]
mod tests {
use super::*;
/// `resolve()` reads a process-global env var, so these run serially
/// behind one lock rather than racing each other.
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_profile_env<R>(value: Option<&str>, f: impl FnOnce() -> R) -> R {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let previous = std::env::var_os("QBZ_PROFILE");
match value {
Some(v) => std::env::set_var("QBZ_PROFILE", v),
None => std::env::remove_var("QBZ_PROFILE"),
}
let out = f();
match previous {
Some(v) => std::env::set_var("QBZ_PROFILE", v),
None => std::env::remove_var("QBZ_PROFILE"),
}
out
}
#[test]
fn env_wins_and_is_case_insensitive() {
assert!(with_profile_env(Some("kiosk"), resolve));
assert!(with_profile_env(Some("KIOSK"), resolve));
assert!(with_profile_env(Some(" Kiosk "), resolve));
}
#[test]
fn env_can_force_desktop_back_on() {
assert!(!with_profile_env(Some("desktop"), resolve));
assert!(
!with_profile_env(Some("anything-else"), resolve),
"anything but kiosk is the desktop profile"
);
}
#[test]
fn an_empty_env_value_falls_through_to_the_pref() {
// Both spellings behave as "unset": whatever they resolve to, they
// resolve to the SAME thing, which is the pref-backed answer.
let empty = with_profile_env(Some(""), resolve);
let blank = with_profile_env(Some(" "), resolve);
let unset = with_profile_env(None, resolve);
assert_eq!(empty, unset);
assert_eq!(blank, unset);
}
#[test]
fn the_screen_id_follows_the_latch() {
let restore = active();
KIOSK_ACTIVE.store(true, Ordering::SeqCst);
assert_eq!(shell_screen(), "kiosk");
KIOSK_ACTIVE.store(false, Ordering::SeqCst);
assert_eq!(shell_screen(), "shell");
KIOSK_ACTIVE.store(restore, Ordering::SeqCst);
}
}
+18 -2
View File
@@ -81,6 +81,9 @@ mod qconnect_bridge;
// no boot() — nothing in Rust ever publishes into it (see its header).
mod kiosk_nav_bridge;
mod kiosk_nav_qt;
// The kiosk profile itself (the same contract, §8): env/pref resolution, the
// live Kiosk <-> Desktop toggle, and the boot decisions that follow from it.
mod kiosk_profile_qt;
mod artwork_qt;
mod atmosphere_qt;
mod bridge;
@@ -482,7 +485,11 @@ fn enter_shell(session: auth_qt::SessionInfo) {
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"));
// Kiosk profile (2026-08-02 kiosk-port contract §8.2): the post-login
// screen is the kiosk touch shell when the profile is active. The Qt
// spelling of `resolved_shell_screen()` (Slint main.rs:7869-7876).
b.as_mut()
.set_screen(QString::from(kiosk_profile_qt::shell_screen()));
});
}
@@ -556,7 +563,9 @@ pub(crate) fn start_offline() {
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"));
// Same profile resolution as the login path (§8.2).
b.as_mut()
.set_screen(QString::from(kiosk_profile_qt::shell_screen()));
});
}
Err(e) => {
@@ -2274,6 +2283,13 @@ fn main() {
} else {
boot_lang.as_str()
});
// Kiosk profile (2026-08-02 kiosk-port contract §8.2/§8.3): QBZ_PROFILE
// wins, else the persisted ui_prefs.profile key. Resolved HERE, before the
// bridges are constructed, because QbzShell seeds both `kiosk_profile` and
// `reduce_motion` from it at construction (shell_bridge.rs) and the
// post-login screen writes read it (:489, :565).
kiosk_profile_qt::init_at_boot();
// rustls process-level CryptoProvider (aws-lc-rs) — required before any
// reqwest call, same as the Slint and daemon binaries.
qbz_app::ensure_crypto_provider();
+8 -1
View File
@@ -19,7 +19,14 @@
//! `artistreleases`, `settings`, `search`, `playlist`, `discoverbrowse`,
//! `playlistbrowse`, `recentalbums`, `mostplayedalbums`, `label`,
//! `labelreleases`, `mix`, `mixtapes`, `collections`, `mixtapedetail`,
//! `discobuilder`, `blacklist`, `playlistmanager`.
//! `discobuilder`, `blacklist`, `playlistmanager`, `nowplaying`.
//!
//! `nowplaying` is KIOSK-ONLY (2026-08-02 kiosk-port contract §3): it is the
//! NavRail's fifth tile and the route the kiosk's full-screen player lives on.
//! The desktop shell has no equivalent — its transport is the persistent bar —
//! so `ContentRouter.qml` mounts it only in kiosk mode. It needs no arm in
//! `crate::navigate_to`: the view reads the queue and player bridges, both of
//! which are already live.
use std::sync::Mutex;
+18 -1
View File
@@ -24,7 +24,14 @@ pub mod qbz_session {
#[qobject]
#[qml_element]
#[qml_singleton]
// "splash" | "login" | "shell"
// "splash" | "login" | "shell" | "kiosk"
//
// "kiosk" is the small-panel touch shell — a SIBLING of "shell", not a
// mode inside it, exactly as the reference models it
// (`qbz-ui/ui/app.slint:38`: `enum AppScreen { splash, login, shell,
// kiosk }`, with the two shells as sibling mounts at `:145` and
// `:205`). Every `*State` global survives the swap untouched, which is
// what lets the live toggle keep playback running.
#[qproperty(QString, screen)]
// Login narration: 0 idle / 1 waiting-for-browser / 2 authenticating.
#[qproperty(i32, login_phase)]
@@ -76,6 +83,12 @@ pub mod qbz_session {
/// Shell logout: back to the login screen.
#[qinvokable]
fn logout(self: Pin<&mut QbzSession>);
/// Kiosk <-> Desktop live toggle (2026-08-02 kiosk-port contract
/// §8.1). Persists the profile, flips `QbzShell.kioskProfile` and
/// swaps `screen` between "kiosk" and "shell" — the shells share every
/// bridge, so nothing is torn down and playback keeps running.
#[qinvokable]
fn toggle_profile(self: Pin<&mut QbzSession>);
/// i18n lookup against the shared gettext catalog (qbz-i18n), so the
/// QML texts reuse the EXISTING .po translations via the same msgids
@@ -203,6 +216,10 @@ impl qbz_session::QbzSession {
crate::do_logout();
}
pub fn toggle_profile(self: Pin<&mut Self>) {
crate::kiosk_profile_qt::toggle();
}
pub fn tr(&self, msgid: QString, rev: i32) -> QString {
let _ = rev; // binding dependency only (see the bridge declaration)
QString::from(&qbz_i18n::t(&msgid.to_string()))
+20 -8
View File
@@ -148,6 +148,18 @@ pub mod qbz_shell {
/// KIOSK port can consume it rather than having to invent it. See
/// `set_reduce_motion` for the two halves and why one is dormant.
#[qproperty(bool, reduce_motion)]
/// Is the kiosk touch shell the one currently mounted?
///
/// The Qt counterpart of `ShellState.kiosk-profile`
/// (`qbz-ui/ui/state.slint:4054`). Seeded from the resolved profile at
/// construction and rewritten by the live toggle
/// (`kiosk_profile_qt::toggle`), which is the ONLY writer.
///
/// QML reads it for the chrome that differs in kiosk: the view menu's
/// "Kiosk mode"/"Desktop mode" label, and the small bar's flyout,
/// which in kiosk collapses to the profile row alone
/// (`shell/PlayerBarSmall.slint:772,780,788,796,811,819,826,847,983`).
#[qproperty(bool, kiosk_profile)]
#[qproperty(i32, ambient_mode)]
// Album-art triad (ambient_qt.rs), pushed on track change.
#[qproperty(QString, ambient_primary)]
@@ -413,6 +425,7 @@ pub struct QbzShellRust {
window_min_width: f32,
window_min_height: f32,
reduce_motion: bool,
kiosk_profile: bool,
ambient_mode: i32,
ambient_primary: QString,
ambient_secondary: QString,
@@ -475,6 +488,7 @@ impl Default for QbzShellRust {
window_min_width: crate::settings_qt::WINDOW_MIN_WIDTH,
window_min_height: crate::settings_qt::WINDOW_MIN_HEIGHT,
reduce_motion: reduce_motion_at_boot(),
kiosk_profile: crate::kiosk_profile_qt::active(),
ambient_mode: crate::settings_qt::app_background_mode(),
// The Slint ImmersiveState default triad (pre-artwork colors).
ambient_primary: QString::from("#00dcc8"),
@@ -541,13 +555,6 @@ static QT_THREAD: OnceLock<CxxQtThread<QbzShell>> = OnceLock::new();
/// Queue a shell-bridge mutation onto the Qt event loop (no-op before
/// boot registers the thread).
/// Kiosk profile — NOT PORTED YET. The kiosk contract
/// (`qbz-nix-docs/qt-frontend/2026-08-02-kiosk-port/00-CONTRACT.md:244`)
/// mandates "FORCED reduce-motion while in kiosk", so when that lands it
/// replaces this literal with the real profile flag and the seam is already
/// tested. One line, on their side.
const KIOSK_PROFILE_ACTIVE: bool = false;
/// The boot value of [`reduce_motion`].
///
/// The reference is `kiosk_profile || !use_gpu_renderer`. Only the FIRST half
@@ -561,8 +568,13 @@ const KIOSK_PROFILE_ACTIVE: bool = false;
/// exactly where it means nothing and stay off everywhere it would matter.
/// If a genuine software-tier session ever appears (a remote desktop, a
/// llvmpipe box), that is when the second half earns its probe.
///
/// The kiosk half is now live (2026-08-02 kiosk-port contract §8.3):
/// `kiosk_profile_qt::active()` is latched by `init_at_boot()` before the
/// bridge is constructed, and the live toggle republishes both this and
/// `kiosk_profile` together.
fn reduce_motion_at_boot() -> bool {
KIOSK_PROFILE_ACTIVE
crate::kiosk_profile_qt::active()
}
pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzShell>) + Send + 'static) {