Merge pre-release: 1.2.13 (400+ stars)

cc
This commit is contained in:
vicrodh committed 2026-05-16 19:28:22 -06:00
commit 813892d605
219 files changed
+21347 -12936

No files matched your search

+14 -5
View File
@@ -18,8 +18,10 @@ jobs:
include:
- target: aarch64-apple-darwin
artifact: qbz-macos-aarch64
arch: aarch64
- target: x86_64-apple-darwin
artifact: qbz-macos-x86_64
arch: x86_64
runs-on: macos-latest
permissions:
contents: write
@@ -102,13 +104,20 @@ jobs:
run: npm run tauri build -- --target ${{ matrix.target }}
- name: Collect artifacts
env:
BUNDLE_DIR: src-tauri/target/${{ matrix.target }}/release/bundle
ARCH: ${{ matrix.arch }}
run: |
set -euo pipefail
mkdir -p dist
# DMG for manual distribution
cp -v src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg dist/ || true
# .app.tar.gz + .sig for auto-updater
cp -v src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.tar.gz dist/ || true
cp -v src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.tar.gz.sig dist/ || true
# DMG for manual distribution (filename already includes arch).
cp -v "$BUNDLE_DIR"/dmg/*.dmg dist/
# .app.tar.gz + .sig for auto-updater. Rename with arch suffix so the
# two matrix jobs don't overwrite each other on the GitHub Release,
# and so release-updater-manifest.yml's regex picks them up
# ("aarch64.app.tar.gz$" / "(x64|x86_64).app.tar.gz$").
cp -v "$BUNDLE_DIR/macos/QBZ.app.tar.gz" "dist/QBZ_${ARCH}.app.tar.gz"
cp -v "$BUNDLE_DIR/macos/QBZ.app.tar.gz.sig" "dist/QBZ_${ARCH}.app.tar.gz.sig"
ls -la dist
- name: Upload artifacts
+2
View File
@@ -424,8 +424,10 @@ Contributions welcome. Please read `CONTRIBUTING.md` before submitting issues or
- [@boxdot](https://github.com/boxdot)
- [@arminfelder](https://github.com/arminfelder)
- [@afonsojramos](https://github.com/afonsojramos) — macOS port
- [@Vudgekek](https://github.com/Vudgekek) — macOS audio
- [@GwendalBeaumont](https://github.com/GwendalBeaumont) — i18n
- [@AdamArstall](https://github.com/AdamArstall)
- [@DoubleGate](https://github.com/DoubleGate)
## License
+16
View File
@@ -2903,6 +2903,22 @@ version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
[[package]]
name = "qbz-app"
version = "0.1.0"
dependencies = [
"base64",
"chrono",
"dirs",
"log",
"md-5",
"rand 0.10.1",
"rusqlite",
"serde",
"serde_json",
"tokio",
]
[[package]]
name = "qbz-audio"
version = "0.1.0"
+1
View File
@@ -2,6 +2,7 @@
resolver = "2"
members = [
"qbz-app",
"qbz-models",
"qbz-audio",
"qbz-player",
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "qbz-app"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
log = { workspace = true }
rusqlite = { version = "0.31", features = ["bundled"] }
dirs = "6"
chrono = { workspace = true }
md-5 = { workspace = true }
base64 = { workspace = true }
rand = "0.10"
+413
View File
@@ -0,0 +1,413 @@
//! Framework-agnostic boot watchdog decision logic.
//!
//! The Tauri shell owns marker-file IO, settings persistence, and startup env
//! mutation. This module owns the pure crash-recovery state transition that can
//! be reused by any future native shell.
use serde::{Deserialize, Serialize};
/// Snapshot of the risky settings attempted in a boot. Compared against the
/// previous boot's snapshot to decide which setting "caused" a crash.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct BootAttempt {
pub hardware_acceleration: bool,
pub force_dmabuf: bool,
pub preferred_gpu: String,
pub force_x11: bool,
}
/// Sticky flags written when a setting was auto-reverted after a crash.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct CrashFlags {
/// Last boot crashed with hardware_acceleration ON; we reverted it.
pub hardware_acceleration_disabled: bool,
pub hardware_acceleration_consecutive_failures: u8,
/// Same for force_dmabuf.
pub force_dmabuf_disabled: bool,
pub force_dmabuf_consecutive_failures: u8,
/// Same for preferred_gpu (reset to "auto").
pub preferred_gpu_disabled: bool,
pub preferred_gpu_consecutive_failures: u8,
/// Lockout - set when consecutive_failures >= 2 for a given knob.
pub hardware_acceleration_locked: bool,
pub force_dmabuf_locked: bool,
pub preferred_gpu_locked: bool,
}
/// Resolved settings the host shell should actually use for this boot.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WatchdogResolution {
pub hardware_acceleration: bool,
pub force_dmabuf: bool,
pub preferred_gpu: String,
/// Reverted-in-this-boot messages so the UI knows which setting was
/// just rolled back. Persisted in CrashFlags as well.
pub recovery_messages: Vec<String>,
}
/// Pure output for a boot watchdog decision. The host adapter decides how to
/// persist marker files and settings writes from this value.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BootWatchdogDecision {
pub resolved_attempt: BootAttempt,
pub crash_flags: CrashFlags,
pub recovery_messages: Vec<String>,
pub should_persist_reverts: bool,
}
impl BootWatchdogDecision {
pub fn resolution(&self) -> WatchdogResolution {
WatchdogResolution {
hardware_acceleration: self.resolved_attempt.hardware_acceleration,
force_dmabuf: self.resolved_attempt.force_dmabuf,
preferred_gpu: self.resolved_attempt.preferred_gpu.clone(),
recovery_messages: self.recovery_messages.clone(),
}
}
}
pub fn resolve_boot_attempt(
intended: BootAttempt,
pending: Option<BootAttempt>,
mut flags: CrashFlags,
) -> BootWatchdogDecision {
let mut resolved = intended.clone();
let mut messages: Vec<String> = Vec::new();
let mut any_revert = false;
if let Some(prev) = pending {
if prev.hardware_acceleration && resolved.hardware_acceleration {
flags.hardware_acceleration_consecutive_failures = flags
.hardware_acceleration_consecutive_failures
.saturating_add(1);
resolved.hardware_acceleration = false;
flags.hardware_acceleration_disabled = true;
if flags.hardware_acceleration_consecutive_failures >= 2 {
flags.hardware_acceleration_locked = true;
}
messages.push(
"Hardware acceleration was disabled because the previous launch crashed during \
graphics init."
.to_string(),
);
any_revert = true;
}
if prev.force_dmabuf && resolved.force_dmabuf {
flags.force_dmabuf_consecutive_failures =
flags.force_dmabuf_consecutive_failures.saturating_add(1);
resolved.force_dmabuf = false;
flags.force_dmabuf_disabled = true;
if flags.force_dmabuf_consecutive_failures >= 2 {
flags.force_dmabuf_locked = true;
}
messages.push(
"DMA-BUF renderer was disabled because the previous launch crashed during \
graphics init."
.to_string(),
);
any_revert = true;
}
if prev.preferred_gpu == resolved.preferred_gpu && resolved.preferred_gpu != "auto" {
flags.preferred_gpu_consecutive_failures =
flags.preferred_gpu_consecutive_failures.saturating_add(1);
resolved.preferred_gpu = "auto".to_string();
flags.preferred_gpu_disabled = true;
if flags.preferred_gpu_consecutive_failures >= 2 {
flags.preferred_gpu_locked = true;
}
messages.push(format!(
"Rendering GPU preference was reset to Auto because pinning to {:?} crashed the \
previous launch.",
prev.preferred_gpu
));
any_revert = true;
}
if any_revert {
if resolved.hardware_acceleration {
resolved.hardware_acceleration = false;
flags.hardware_acceleration_disabled = true;
messages.push(
"Hardware acceleration was also disabled as a safety fallback to land in \
CPU mode."
.to_string(),
);
}
if resolved.force_dmabuf {
resolved.force_dmabuf = false;
flags.force_dmabuf_disabled = true;
}
if resolved.preferred_gpu != "auto" {
resolved.preferred_gpu = "auto".to_string();
flags.preferred_gpu_disabled = true;
}
}
} else {
if !flags.hardware_acceleration_locked {
flags.hardware_acceleration_disabled = false;
flags.hardware_acceleration_consecutive_failures = 0;
}
if !flags.force_dmabuf_locked {
flags.force_dmabuf_disabled = false;
flags.force_dmabuf_consecutive_failures = 0;
}
if !flags.preferred_gpu_locked {
flags.preferred_gpu_disabled = false;
flags.preferred_gpu_consecutive_failures = 0;
}
}
let should_persist_reverts = resolved != intended;
BootWatchdogDecision {
resolved_attempt: resolved,
crash_flags: flags,
recovery_messages: messages,
should_persist_reverts,
}
}
pub fn mark_boot_success_flags(attempt: &BootAttempt, mut flags: CrashFlags) -> CrashFlags {
if attempt.hardware_acceleration {
flags.hardware_acceleration_consecutive_failures = 0;
flags.hardware_acceleration_disabled = false;
flags.hardware_acceleration_locked = false;
}
if attempt.force_dmabuf {
flags.force_dmabuf_consecutive_failures = 0;
flags.force_dmabuf_disabled = false;
flags.force_dmabuf_locked = false;
}
if attempt.preferred_gpu != "auto" {
flags.preferred_gpu_consecutive_failures = 0;
flags.preferred_gpu_disabled = false;
flags.preferred_gpu_locked = false;
}
flags
}
pub fn clear_crash_flag_value(mut flags: CrashFlags, flag: &str) -> Option<CrashFlags> {
match flag {
"hardware_acceleration" => {
flags.hardware_acceleration_disabled = false;
flags.hardware_acceleration_consecutive_failures = 0;
flags.hardware_acceleration_locked = false;
}
"force_dmabuf" => {
flags.force_dmabuf_disabled = false;
flags.force_dmabuf_consecutive_failures = 0;
flags.force_dmabuf_locked = false;
}
"preferred_gpu" => {
flags.preferred_gpu_disabled = false;
flags.preferred_gpu_consecutive_failures = 0;
flags.preferred_gpu_locked = false;
}
_ => return None,
}
Some(flags)
}
#[cfg(test)]
mod tests {
use super::*;
fn attempt() -> BootAttempt {
BootAttempt {
hardware_acceleration: true,
force_dmabuf: true,
preferred_gpu: "auto".to_string(),
force_x11: false,
}
}
#[test]
fn no_pending_returns_intended_and_clears_non_locked_flags() {
let flags = CrashFlags {
hardware_acceleration_disabled: true,
hardware_acceleration_consecutive_failures: 1,
force_dmabuf_disabled: true,
force_dmabuf_consecutive_failures: 1,
preferred_gpu_disabled: true,
preferred_gpu_consecutive_failures: 1,
..CrashFlags::default()
};
let decision = resolve_boot_attempt(attempt(), None, flags);
assert_eq!(decision.resolved_attempt, attempt());
assert!(!decision.should_persist_reverts);
assert_eq!(decision.crash_flags, CrashFlags::default());
}
#[test]
fn no_pending_preserves_locked_flags() {
let flags = CrashFlags {
hardware_acceleration_disabled: true,
hardware_acceleration_consecutive_failures: 2,
hardware_acceleration_locked: true,
..CrashFlags::default()
};
let decision = resolve_boot_attempt(attempt(), None, flags);
assert!(decision.crash_flags.hardware_acceleration_disabled);
assert_eq!(
decision
.crash_flags
.hardware_acceleration_consecutive_failures,
2
);
assert!(decision.crash_flags.hardware_acceleration_locked);
}
#[test]
fn pending_hardware_acceleration_crash_disables_hardware_acceleration() {
let decision = resolve_boot_attempt(attempt(), Some(attempt()), CrashFlags::default());
assert!(!decision.resolved_attempt.hardware_acceleration);
assert!(!decision.resolved_attempt.force_dmabuf);
assert_eq!(decision.resolved_attempt.preferred_gpu, "auto");
assert!(decision.crash_flags.hardware_acceleration_disabled);
assert_eq!(
decision
.crash_flags
.hardware_acceleration_consecutive_failures,
1
);
assert!(decision.should_persist_reverts);
}
#[test]
fn pending_dmabuf_crash_disables_dmabuf() {
let mut intended = attempt();
intended.hardware_acceleration = false;
let pending = intended.clone();
let decision = resolve_boot_attempt(intended, Some(pending), CrashFlags::default());
assert!(!decision.resolved_attempt.force_dmabuf);
assert!(decision.crash_flags.force_dmabuf_disabled);
assert_eq!(decision.crash_flags.force_dmabuf_consecutive_failures, 1);
assert!(decision.should_persist_reverts);
}
#[test]
fn pending_same_preferred_gpu_resets_to_auto() {
let mut intended = attempt();
intended.hardware_acceleration = false;
intended.force_dmabuf = false;
intended.preferred_gpu = "0000:01:00.0".to_string();
let pending = intended.clone();
let decision = resolve_boot_attempt(intended, Some(pending), CrashFlags::default());
assert_eq!(decision.resolved_attempt.preferred_gpu, "auto");
assert!(decision.crash_flags.preferred_gpu_disabled);
assert_eq!(decision.crash_flags.preferred_gpu_consecutive_failures, 1);
assert!(decision.should_persist_reverts);
}
#[test]
fn pending_different_preferred_gpu_does_not_blame_gpu_choice() {
let mut intended = attempt();
intended.hardware_acceleration = false;
intended.force_dmabuf = false;
intended.preferred_gpu = "0000:02:00.0".to_string();
let mut pending = intended.clone();
pending.preferred_gpu = "0000:01:00.0".to_string();
let decision = resolve_boot_attempt(intended.clone(), Some(pending), CrashFlags::default());
assert_eq!(decision.resolved_attempt, intended);
assert!(!decision.crash_flags.preferred_gpu_disabled);
assert!(!decision.should_persist_reverts);
}
#[test]
fn second_failure_locks_setting() {
let flags = CrashFlags {
hardware_acceleration_consecutive_failures: 1,
..CrashFlags::default()
};
let decision = resolve_boot_attempt(attempt(), Some(attempt()), flags);
assert_eq!(
decision
.crash_flags
.hardware_acceleration_consecutive_failures,
2
);
assert!(decision.crash_flags.hardware_acceleration_locked);
}
#[test]
fn success_with_enabled_knobs_clears_matching_flags() {
let flags = CrashFlags {
hardware_acceleration_disabled: true,
hardware_acceleration_consecutive_failures: 2,
hardware_acceleration_locked: true,
force_dmabuf_disabled: true,
force_dmabuf_consecutive_failures: 2,
force_dmabuf_locked: true,
preferred_gpu_disabled: true,
preferred_gpu_consecutive_failures: 2,
preferred_gpu_locked: true,
};
let mut boot = attempt();
boot.preferred_gpu = "0000:01:00.0".to_string();
let updated = mark_boot_success_flags(&boot, flags);
assert_eq!(updated, CrashFlags::default());
}
#[test]
fn success_with_cpu_fallback_does_not_clear_disabled_flags() {
let flags = CrashFlags {
hardware_acceleration_disabled: true,
hardware_acceleration_consecutive_failures: 1,
force_dmabuf_disabled: true,
force_dmabuf_consecutive_failures: 1,
preferred_gpu_disabled: true,
preferred_gpu_consecutive_failures: 1,
..CrashFlags::default()
};
let boot = BootAttempt {
hardware_acceleration: false,
force_dmabuf: false,
preferred_gpu: "auto".to_string(),
force_x11: false,
};
let updated = mark_boot_success_flags(&boot, flags.clone());
assert_eq!(updated, flags);
}
#[test]
fn clear_known_flag_resets_disabled_counter_and_lock() {
let flags = CrashFlags {
force_dmabuf_disabled: true,
force_dmabuf_consecutive_failures: 2,
force_dmabuf_locked: true,
..CrashFlags::default()
};
let updated = clear_crash_flag_value(flags, "force_dmabuf").expect("known flag");
assert!(!updated.force_dmabuf_disabled);
assert_eq!(updated.force_dmabuf_consecutive_failures, 0);
assert!(!updated.force_dmabuf_locked);
}
#[test]
fn clear_unknown_flag_is_noop() {
assert_eq!(
clear_crash_flag_value(CrashFlags::default(), "force_x11"),
None
);
}
}
+435
View File
@@ -0,0 +1,435 @@
//! Framework-agnostic graphics auto-configuration recommendation logic.
//!
//! This module detects the host graphics environment and computes the settings
//! profile QBZ should recommend. Applying that profile is intentionally left to
//! the shell adapter because it writes to persisted settings and currently
//! targets the Tauri/WebKit startup path.
use serde::Serialize;
/// Detected environment information.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct Environment {
pub display_server: String,
pub gpu_nvidia: bool,
pub gpu_amd: bool,
pub gpu_intel: bool,
pub gpu_name: String,
pub desktop: String,
pub is_vm: bool,
}
/// Recommended configuration.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct Recommendation {
pub hardware_acceleration: bool,
pub force_x11: bool,
pub gsk_renderer: Option<String>,
pub disable_dmabuf: bool,
pub disable_blur_background: bool,
pub rationale: Vec<String>,
}
pub fn detect_environment() -> Environment {
let display_server = detect_display_server();
let gpu_nvidia = is_nvidia_gpu();
let gpu_amd = is_amd_gpu();
let gpu_intel = is_intel_gpu();
let gpu_name = detect_gpu_name(gpu_nvidia, gpu_amd, gpu_intel);
let desktop = detect_desktop();
let is_vm = is_virtual_machine();
Environment {
display_server,
gpu_nvidia,
gpu_amd,
gpu_intel,
gpu_name,
desktop,
is_vm,
}
}
fn detect_display_server() -> String {
let is_wayland = std::env::var_os("WAYLAND_DISPLAY").is_some()
|| std::env::var("XDG_SESSION_TYPE").as_deref() == Ok("wayland");
if is_wayland {
"Wayland".to_string()
} else {
"X11".to_string()
}
}
fn is_nvidia_gpu() -> bool {
std::path::Path::new("/proc/driver/nvidia/version").exists()
|| std::fs::read_to_string("/proc/modules")
.map(|m| m.lines().any(|l| l.starts_with("nvidia")))
.unwrap_or(false)
}
fn is_amd_gpu() -> bool {
std::path::Path::new("/sys/module/amdgpu").exists()
|| std::fs::read_to_string("/proc/modules")
.map(|m| m.lines().any(|l| l.starts_with("amdgpu")))
.unwrap_or(false)
}
fn is_intel_gpu() -> bool {
std::path::Path::new("/sys/module/i915").exists()
|| std::fs::read_to_string("/proc/modules")
.map(|m| m.lines().any(|l| l.starts_with("i915")))
.unwrap_or(false)
}
fn is_virtual_machine() -> bool {
if let Ok(product) = std::fs::read_to_string("/sys/class/dmi/id/product_name") {
let p = product.trim().to_lowercase();
if p.contains("virtualbox")
|| p.contains("vmware")
|| p.contains("qemu")
|| p.contains("bochs")
|| p.contains("hyper-v")
{
return true;
}
}
if let Ok(vendor) = std::fs::read_to_string("/sys/class/dmi/id/sys_vendor") {
let v = vendor.trim().to_lowercase();
if v.contains("innotek")
|| v.contains("vmware")
|| v.contains("qemu")
|| v.contains("xen")
|| v.contains("parallels")
{
return true;
}
}
if let Ok(h) = std::fs::read_to_string("/sys/hypervisor/type") {
if !h.trim().is_empty() {
return true;
}
}
false
}
pub fn detect_gpu_name(nvidia: bool, amd: bool, intel: bool) -> String {
// Hybrid laptops have more than one of these set; join the names so
// diagnostics surface the full picture instead of returning only the
// first vendor matched.
let mut parts: Vec<String> = Vec::new();
if nvidia {
parts.push(nvidia_name());
}
if amd {
parts.push(amd_name());
}
if intel {
parts.push(intel_name());
}
if parts.is_empty() {
"Unknown / None detected".to_string()
} else {
parts.join(" + ")
}
}
fn nvidia_name() -> String {
if let Ok(version) = std::fs::read_to_string("/proc/driver/nvidia/version") {
if let Some(line) = version.lines().next() {
return format!("NVIDIA ({})", line.trim());
}
}
"NVIDIA (driver loaded)".to_string()
}
fn amd_name() -> String {
if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("card") && !name.contains('-') {
let model_path = entry.path().join("device/product_name");
if let Ok(model) = std::fs::read_to_string(&model_path) {
let model = model.trim();
if !model.is_empty() {
return format!("AMD {}", model);
}
}
}
}
}
"AMD (amdgpu driver loaded)".to_string()
}
fn intel_name() -> String {
"Intel (i915/xe driver loaded)".to_string()
}
fn detect_desktop() -> String {
let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
let session = std::env::var("XDG_SESSION_DESKTOP").unwrap_or_default();
let de = std::env::var("DESKTOP_SESSION").unwrap_or_default();
if !desktop.is_empty() {
desktop
} else if !session.is_empty() {
session
} else if !de.is_empty() {
de
} else {
"Unknown".to_string()
}
}
pub fn compute_recommendation(env: &Environment) -> Recommendation {
let mut rationale = Vec::new();
// VM: software rendering, no blur
if env.is_vm {
rationale.push("Virtual machine detected: using software rendering".to_string());
return Recommendation {
hardware_acceleration: false,
force_x11: false,
gsk_renderer: Some("cairo".to_string()),
disable_dmabuf: true,
disable_blur_background: true,
rationale,
};
}
let is_wayland = env.display_server == "Wayland";
let has_hybrid_igpu = env.gpu_nvidia && (env.gpu_intel || env.gpu_amd);
// Hybrid laptops (NVIDIA dGPU + Intel/AMD iGPU). WebKit composes via
// the iGPU through EGL/GLX defaults - the NVIDIA card sits idle for
// PRIME render offload. Auto (None) lets GTK4 pick NGL/Vulkan as
// appropriate for the iGPU. DMA-BUF is safe here: the compositing
// path runs on the stable iGPU stack, not the NVIDIA driver, so the
// instability that affects NVIDIA-only systems does not apply. The
// user must still pick the iGPU explicitly in the rendering GPU
// selector - leaving it on Auto can resolve to the NVIDIA card.
if has_hybrid_igpu {
let igpu_label = if env.gpu_intel { "Intel" } else { "AMD" };
rationale.push(format!(
"NVIDIA + {} hybrid: iGPU handles WebKit compositing, leaving GSK at Auto",
igpu_label
));
rationale.push(format!(
"Select the {} integrated GPU explicitly in the rendering GPU selector instead of Auto",
igpu_label
));
return Recommendation {
hardware_acceleration: true,
force_x11: false,
gsk_renderer: None,
disable_dmabuf: false,
disable_blur_background: false,
rationale,
};
}
// NVIDIA-only (no iGPU to offload WebKit compositing to). WebKitGTK
// on the proprietary NVIDIA driver has long-standing compositing
// stutter that XWayland does not solve. Rather than recommend
// hardware acceleration that visibly stutters, fall back to CPU
// rendering: trimmed but smooth. DMA-BUF stays off - there is no
// stable GPU compositing path on this configuration.
if env.gpu_nvidia {
rationale.push("NVIDIA-only system: using CPU rendering for a smooth UI".to_string());
rationale
.push("WebKit GPU compositing on the NVIDIA driver has unresolved stutter".to_string());
return Recommendation {
hardware_acceleration: false,
force_x11: false,
gsk_renderer: Some("cairo".to_string()),
disable_dmabuf: true,
disable_blur_background: true,
rationale,
};
}
// AMD + Wayland
if env.gpu_amd && is_wayland {
rationale.push("AMD + Wayland: NGL renderer with DMA-BUF".to_string());
return Recommendation {
hardware_acceleration: true,
force_x11: false,
gsk_renderer: Some("ngl".to_string()),
disable_dmabuf: false,
disable_blur_background: false,
rationale,
};
}
// AMD + X11
if env.gpu_amd {
rationale.push("AMD + X11: full hardware acceleration".to_string());
return Recommendation {
hardware_acceleration: true,
force_x11: false,
gsk_renderer: None,
disable_dmabuf: false,
disable_blur_background: false,
rationale,
};
}
// Intel + Wayland
if env.gpu_intel && is_wayland {
rationale.push("Intel + Wayland: NGL renderer with DMA-BUF".to_string());
return Recommendation {
hardware_acceleration: true,
force_x11: false,
gsk_renderer: Some("ngl".to_string()),
disable_dmabuf: false,
disable_blur_background: false,
rationale,
};
}
// Intel + X11
if env.gpu_intel {
rationale.push("Intel + X11: full hardware acceleration".to_string());
return Recommendation {
hardware_acceleration: true,
force_x11: false,
gsk_renderer: None,
disable_dmabuf: false,
disable_blur_background: false,
rationale,
};
}
// Unknown GPU: safe defaults
rationale.push("No known GPU detected: using safe defaults".to_string());
Recommendation {
hardware_acceleration: true,
force_x11: false,
gsk_renderer: None,
disable_dmabuf: false,
disable_blur_background: false,
rationale,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn env(display_server: &str) -> Environment {
Environment {
display_server: display_server.to_string(),
gpu_nvidia: false,
gpu_amd: false,
gpu_intel: false,
gpu_name: "test".to_string(),
desktop: "Unknown".to_string(),
is_vm: false,
}
}
#[test]
fn vm_uses_cpu_fallback() {
let mut environment = env("Wayland");
environment.is_vm = true;
let recommendation = compute_recommendation(&environment);
assert!(!recommendation.hardware_acceleration);
assert_eq!(recommendation.gsk_renderer.as_deref(), Some("cairo"));
assert!(recommendation.disable_dmabuf);
assert!(recommendation.disable_blur_background);
}
#[test]
fn nvidia_hybrid_leaves_renderer_auto_and_allows_dmabuf() {
let mut environment = env("Wayland");
environment.gpu_nvidia = true;
environment.gpu_intel = true;
let recommendation = compute_recommendation(&environment);
assert!(recommendation.hardware_acceleration);
assert_eq!(recommendation.gsk_renderer, None);
// DMA-BUF is safe on hybrid: the iGPU does the compositing.
assert!(!recommendation.disable_dmabuf);
// The user is told to pick the iGPU explicitly, not leave Auto.
assert!(recommendation
.rationale
.iter()
.any(|line| line.contains("integrated GPU")));
}
#[test]
fn nvidia_only_wayland_uses_cpu_rendering() {
let mut environment = env("Wayland");
environment.gpu_nvidia = true;
let recommendation = compute_recommendation(&environment);
assert!(!recommendation.hardware_acceleration);
assert_eq!(recommendation.gsk_renderer.as_deref(), Some("cairo"));
assert!(recommendation.disable_dmabuf);
assert!(recommendation.disable_blur_background);
}
#[test]
fn nvidia_only_x11_uses_cpu_rendering() {
let mut environment = env("X11");
environment.gpu_nvidia = true;
let recommendation = compute_recommendation(&environment);
// X11/XWayland does not rescue NVIDIA-only: CPU rendering either way.
assert!(!recommendation.hardware_acceleration);
assert_eq!(recommendation.gsk_renderer.as_deref(), Some("cairo"));
assert!(recommendation.disable_dmabuf);
assert!(!recommendation.force_x11);
}
#[test]
fn amd_wayland_uses_ngl_and_allows_dmabuf() {
let mut environment = env("Wayland");
environment.gpu_amd = true;
let recommendation = compute_recommendation(&environment);
assert_eq!(recommendation.gsk_renderer.as_deref(), Some("ngl"));
assert!(!recommendation.disable_dmabuf);
}
#[test]
fn intel_wayland_uses_ngl_and_allows_dmabuf() {
let mut environment = env("Wayland");
environment.gpu_intel = true;
let recommendation = compute_recommendation(&environment);
assert_eq!(recommendation.gsk_renderer.as_deref(), Some("ngl"));
assert!(!recommendation.disable_dmabuf);
}
#[test]
fn unknown_gpu_keeps_safe_defaults() {
let environment = env("X11");
let recommendation = compute_recommendation(&environment);
assert!(recommendation.hardware_acceleration);
assert!(!recommendation.force_x11);
assert_eq!(recommendation.gsk_renderer, None);
assert!(!recommendation.disable_dmabuf);
assert!(!recommendation.disable_blur_background);
}
#[test]
fn gpu_name_combines_hybrid_vendors() {
let name = detect_gpu_name(true, false, true);
assert!(name.contains("NVIDIA"));
assert!(name.contains("Intel"));
assert!(name.contains(" + "));
}
}
+503
View File
@@ -0,0 +1,503 @@
//! Host GPU enumeration for the current desktop shell.
//!
//! Cross-references the PCI devices visible under `/sys/class/drm/` (real
//! physical GPUs) with the EGL vendor JSONs available under
//! `/usr/share/glvnd/egl_vendor.d/` (stacks the running process can
//! actually use). The intersection is what the host shell can expose to
//! the user.
//!
//! Inside Flatpak/Snap the `/sys` path is still mounted (read-only) so
//! PCI detection works; the `/usr/share/glvnd/...` view reflects the
//! sandbox's own vendor JSONs which is the correct thing to read.
//!
//! Companion to graphics auto-configuration logic, which decides "what
//! config should we recommend". This module answers "what hardware/stacks
//! do we actually have".
//!
//! Cross-platform contract: the public API (`enumerate_gpus`,
//! `env_vars_for_preferred_gpu`, `PreferredGpu`) compiles on every
//! platform. macOS/Windows fall through to empty enumerations and a
//! no-op env-var application; the Software entry still round-trips
//! through storage without breaking the build.
use serde::Serialize;
#[cfg(target_os = "linux")]
use std::fs;
#[cfg(target_os = "linux")]
use std::path::Path;
/// PCI vendor IDs we recognize.
const VENDOR_INTEL: &str = "0x8086";
const VENDOR_AMD: &str = "0x1002";
const VENDOR_NVIDIA: &str = "0x10de";
/// Coarse classification of a GPU's role on the system.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GpuKind {
Integrated,
Discrete,
/// Pure-software rendering (llvmpipe / cpu fallback). Not a real device.
Software,
}
/// One detected GPU with everything the picker needs to render and the
/// env-var application path needs to act on the user's choice.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct DetectedGpu {
/// Stable identifier across launches: the PCI slot string for real
/// devices (e.g. "0000:01:00.0") or a sentinel for software.
pub id: String,
pub vendor: String,
/// Human-readable model where available.
pub name: String,
/// PCI ID as `vendor:device` for diagnostics.
pub pci_id: Option<String>,
pub kind: GpuKind,
/// Absolute path of the EGL vendor JSON that exposes this GPU's stack to
/// the running process, if one is available.
pub egl_vendor_json: Option<String>,
/// True when the GPU is both physically present and reachable via an EGL
/// vendor JSON.
pub is_usable: bool,
}
/// Enumerate every GPU the user could pick. Always returns at least the
/// Software entry so the dropdown is never empty on systems with no detectable
/// hardware.
pub fn enumerate_gpus() -> Vec<DetectedGpu> {
let physical = detect_physical_gpus();
let egl = enumerate_egl_vendor_jsons();
let mut result: Vec<DetectedGpu> = physical
.into_iter()
.map(|p| {
let egl_path = match p.vendor.as_str() {
"NVIDIA" => egl.nvidia.clone(),
_ => egl.mesa.clone(),
};
DetectedGpu {
id: p.pci_slot.clone(),
vendor: p.vendor.clone(),
name: p.name.clone(),
pci_id: Some(format!("{}:{}", p.pci_vendor_hex, p.pci_device_hex)),
kind: p.kind,
is_usable: egl_path.is_some(),
egl_vendor_json: egl_path,
}
})
.collect();
result.push(DetectedGpu {
id: "software".to_string(),
vendor: "Software".to_string(),
name: "Software (CPU, llvmpipe)".to_string(),
pci_id: None,
kind: GpuKind::Software,
egl_vendor_json: egl.mesa.clone(),
is_usable: egl.mesa.is_some(),
});
result
}
#[derive(Debug, Clone)]
struct PhysicalGpu {
pci_slot: String,
pci_vendor_hex: String,
pci_device_hex: String,
vendor: String,
name: String,
kind: GpuKind,
}
#[cfg(not(target_os = "linux"))]
fn detect_physical_gpus() -> Vec<PhysicalGpu> {
Vec::new()
}
#[cfg(not(target_os = "linux"))]
fn enumerate_egl_vendor_jsons() -> EglVendors {
EglVendors::default()
}
#[cfg(target_os = "linux")]
fn detect_physical_gpus() -> Vec<PhysicalGpu> {
let drm_root = Path::new("/sys/class/drm");
let entries = match fs::read_dir(drm_root) {
Ok(it) => it,
Err(_) => return Vec::new(),
};
let mut gpus: Vec<PhysicalGpu> = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if !name.starts_with("card") || name.contains('-') {
continue;
}
let device_path = entry.path().join("device");
let canonical = match fs::canonicalize(&device_path) {
Ok(p) => p,
Err(_) => continue,
};
let pci_slot = pci_slot_from_path(&canonical).unwrap_or_else(|| name.clone());
let pci_vendor_hex = read_trimmed(&device_path.join("vendor")).unwrap_or_default();
let pci_device_hex = read_trimmed(&device_path.join("device")).unwrap_or_default();
let vendor = classify_vendor(&pci_vendor_hex);
if vendor == "Unknown" {
continue;
}
let product_name =
read_trimmed(&device_path.join("product_name")).filter(|s| !s.is_empty());
let name = build_human_name(&vendor, product_name.as_deref());
gpus.push(PhysicalGpu {
pci_slot,
pci_vendor_hex: strip_hex_prefix(&pci_vendor_hex),
pci_device_hex: strip_hex_prefix(&pci_device_hex),
vendor,
name,
kind: GpuKind::Discrete,
});
}
classify_kinds(&mut gpus);
gpus
}
#[cfg(target_os = "linux")]
fn pci_slot_from_path(path: &Path) -> Option<String> {
path.file_name().map(|s| s.to_string_lossy().to_string())
}
#[cfg(target_os = "linux")]
fn read_trimmed(p: &Path) -> Option<String> {
fs::read_to_string(p).ok().map(|s| s.trim().to_string())
}
fn strip_hex_prefix(s: &str) -> String {
s.strip_prefix("0x").unwrap_or(s).to_string()
}
fn classify_vendor(pci_vendor: &str) -> String {
match pci_vendor {
VENDOR_INTEL => "Intel".to_string(),
VENDOR_AMD => "AMD".to_string(),
VENDOR_NVIDIA => "NVIDIA".to_string(),
_ => "Unknown".to_string(),
}
}
fn build_human_name(vendor: &str, product: Option<&str>) -> String {
match (vendor, product) {
(_, Some(p)) if !p.is_empty() => p.to_string(),
("Intel", None) => "Intel Graphics".to_string(),
("AMD", None) => "AMD Graphics".to_string(),
("NVIDIA", None) => "NVIDIA Graphics".to_string(),
_ => "GPU".to_string(),
}
}
fn classify_kinds(gpus: &mut [PhysicalGpu]) {
let has_nvidia = gpus.iter().any(|g| g.vendor == "NVIDIA");
let has_amd = gpus.iter().any(|g| g.vendor == "AMD");
for g in gpus.iter_mut() {
g.kind = match g.vendor.as_str() {
"NVIDIA" => GpuKind::Discrete,
"Intel" => {
if has_nvidia || has_amd || slot_is_root_bus(&g.pci_slot) {
GpuKind::Integrated
} else {
GpuKind::Discrete
}
}
"AMD" => {
if has_nvidia || slot_is_root_bus(&g.pci_slot) {
GpuKind::Integrated
} else {
GpuKind::Discrete
}
}
_ => GpuKind::Discrete,
};
}
}
fn slot_is_root_bus(slot: &str) -> bool {
slot.split(':').nth(1).map(|bb| bb == "00").unwrap_or(false)
}
#[derive(Debug, Clone, Default)]
struct EglVendors {
mesa: Option<String>,
nvidia: Option<String>,
}
#[cfg(target_os = "linux")]
fn enumerate_egl_vendor_jsons() -> EglVendors {
let candidates: [&str; 2] = ["/usr/share/glvnd/egl_vendor.d", "/etc/glvnd/egl_vendor.d"];
let mut out = EglVendors::default();
for root in candidates {
let Ok(entries) = fs::read_dir(root) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let Some(file_name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
if !file_name.ends_with(".json") {
continue;
}
let lower = file_name.to_lowercase();
let abs = path.to_string_lossy().to_string();
if lower.contains("nvidia") {
if out.nvidia.is_none() {
out.nvidia = Some(abs);
}
} else if lower.contains("mesa") && out.mesa.is_none() {
out.mesa = Some(abs);
}
}
}
out
}
/// An action for the host shell to apply to the process environment.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnvAction {
Set(String, String),
Unset(String),
}
/// Decide what `PreferredGpu::Auto` should pin on this system.
pub fn auto_resolves_to(detected: &[DetectedGpu]) -> Option<&DetectedGpu> {
let integrated = detected
.iter()
.find(|g| g.kind == GpuKind::Integrated && g.is_usable);
let has_discrete = detected
.iter()
.any(|g| g.kind == GpuKind::Discrete && g.is_usable);
if integrated.is_some() && has_discrete {
integrated
} else {
None
}
}
/// Build the env actions that pin the running process to a specific GPU stack.
/// Returning an empty vec means "auto with no resolution".
pub fn env_vars_for_preferred_gpu(
preferred: &PreferredGpu,
detected: &[DetectedGpu],
) -> Vec<EnvAction> {
match preferred {
PreferredGpu::Auto => match auto_resolves_to(detected) {
Some(g) => env_vars_for_gpu(g),
None => Vec::new(),
},
PreferredGpu::Software => vec![
EnvAction::Set("LIBGL_ALWAYS_SOFTWARE".to_string(), "1".to_string()),
EnvAction::Set(
"WEBKIT_DISABLE_DMABUF_RENDERER".to_string(),
"1".to_string(),
),
EnvAction::Set("GSK_RENDERER".to_string(), "cairo".to_string()),
EnvAction::Unset("__NV_PRIME_RENDER_OFFLOAD".to_string()),
],
PreferredGpu::Kind(kind) => {
let target = detected.iter().find(|g| g.kind == *kind && g.is_usable);
let Some(g) = target else {
return Vec::new();
};
env_vars_for_gpu(g)
}
PreferredGpu::SpecificId(id) => {
let target = detected.iter().find(|g| &g.id == id);
let Some(g) = target else {
return Vec::new();
};
env_vars_for_gpu(g)
}
}
}
fn env_vars_for_gpu(g: &DetectedGpu) -> Vec<EnvAction> {
let mut env: Vec<EnvAction> = Vec::new();
if let Some(path) = &g.egl_vendor_json {
env.push(EnvAction::Set(
"__EGL_VENDOR_LIBRARY_FILENAMES".to_string(),
path.clone(),
));
}
match g.vendor.as_str() {
"Intel" | "AMD" => {
env.push(EnvAction::Set(
"__GLX_VENDOR_LIBRARY_NAME".to_string(),
"mesa".to_string(),
));
env.push(EnvAction::Set("DRI_PRIME".to_string(), "0".to_string()));
env.push(EnvAction::Unset("__NV_PRIME_RENDER_OFFLOAD".to_string()));
}
"NVIDIA" => {
env.push(EnvAction::Set(
"__NV_PRIME_RENDER_OFFLOAD".to_string(),
"1".to_string(),
));
env.push(EnvAction::Set(
"__GLX_VENDOR_LIBRARY_NAME".to_string(),
"nvidia".to_string(),
));
}
_ => {}
}
env
}
/// User-facing preference. Mirrors the column in graphics_settings.db.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PreferredGpu {
Auto,
Software,
Kind(GpuKind),
SpecificId(String),
}
impl PreferredGpu {
pub fn parse(raw: &str) -> Self {
match raw.trim().to_lowercase().as_str() {
"" | "auto" => Self::Auto,
"software" | "cpu" => Self::Software,
"integrated" | "igpu" => Self::Kind(GpuKind::Integrated),
"discrete" | "dgpu" => Self::Kind(GpuKind::Discrete),
other => Self::SpecificId(other.to_string()),
}
}
pub fn as_storage_string(&self) -> String {
match self {
Self::Auto => "auto".to_string(),
Self::Software => "software".to_string(),
Self::Kind(GpuKind::Integrated) => "integrated".to_string(),
Self::Kind(GpuKind::Discrete) => "discrete".to_string(),
Self::Kind(GpuKind::Software) => "software".to_string(),
Self::SpecificId(id) => id.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn detected_gpu(
id: &str,
vendor: &str,
kind: GpuKind,
is_usable: bool,
egl_vendor_json: Option<&str>,
) -> DetectedGpu {
DetectedGpu {
id: id.to_string(),
vendor: vendor.to_string(),
name: format!("{vendor} Graphics"),
pci_id: None,
kind,
egl_vendor_json: egl_vendor_json.map(str::to_string),
is_usable,
}
}
#[test]
fn preferred_gpu_round_trip() {
for raw in ["auto", "software", "integrated", "discrete", "0000:01:00.0"] {
let parsed = PreferredGpu::parse(raw);
assert_eq!(parsed.as_storage_string(), raw);
}
}
#[test]
fn slot_root_bus_classification() {
assert!(slot_is_root_bus("0000:00:02.0"));
assert!(slot_is_root_bus("0000:00:01.0"));
assert!(!slot_is_root_bus("0000:01:00.0"));
assert!(!slot_is_root_bus("0000:02:00.0"));
}
#[test]
fn vendor_classification() {
assert_eq!(classify_vendor(VENDOR_INTEL), "Intel");
assert_eq!(classify_vendor(VENDOR_AMD), "AMD");
assert_eq!(classify_vendor(VENDOR_NVIDIA), "NVIDIA");
assert_eq!(classify_vendor("0xdeadbeef"), "Unknown");
}
#[test]
fn auto_resolves_to_integrated_gpu_on_hybrid_system() {
let gpus = vec![
detected_gpu(
"0000:00:02.0",
"Intel",
GpuKind::Integrated,
true,
Some("/mesa.json"),
),
detected_gpu(
"0000:01:00.0",
"NVIDIA",
GpuKind::Discrete,
true,
Some("/nvidia.json"),
),
];
let resolved = auto_resolves_to(&gpus).expect("hybrid should resolve");
assert_eq!(resolved.id, "0000:00:02.0");
}
#[test]
fn auto_does_not_resolve_without_usable_discrete_gpu() {
let gpus = vec![
detected_gpu(
"0000:00:02.0",
"Intel",
GpuKind::Integrated,
true,
Some("/mesa.json"),
),
detected_gpu(
"0000:01:00.0",
"NVIDIA",
GpuKind::Discrete,
false,
Some("/nvidia.json"),
),
];
assert!(auto_resolves_to(&gpus).is_none());
}
#[test]
fn software_preference_returns_cpu_env_actions() {
let actions = env_vars_for_preferred_gpu(&PreferredGpu::Software, &[]);
assert!(actions.contains(&EnvAction::Set(
"LIBGL_ALWAYS_SOFTWARE".to_string(),
"1".to_string()
)));
assert!(actions.contains(&EnvAction::Set(
"WEBKIT_DISABLE_DMABUF_RENDERER".to_string(),
"1".to_string()
)));
assert!(actions.contains(&EnvAction::Set(
"GSK_RENDERER".to_string(),
"cairo".to_string()
)));
assert!(actions.contains(&EnvAction::Unset("__NV_PRIME_RENDER_OFFLOAD".to_string())));
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod boot_watchdog;
pub mod graphics_autoconfig;
pub mod graphics_detection;
pub mod runtime;
pub mod settings;
pub mod user_data;
+482
View File
@@ -0,0 +1,482 @@
//! Runtime Session Contract
//!
//! Implements the UI-agnostic runtime lifecycle as per ADR_RUNTIME_SESSION_CONTRACT.md
//!
//! Key concepts:
//! - Single bootstrap entrypoint
//! - Canonical state machine (Uninitialized -> Ready)
//! - Typed errors (no string matching)
//! - Command gating in backend
//! - Lifecycle events
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
/// Canonical runtime states
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", content = "data")]
pub enum RuntimeState {
/// Initial state - nothing initialized
Uninitialized,
/// Client initialized but no authentication
InitializedNoAuth,
/// Authenticated but per-user session not activated (transitional)
AuthenticatedNoUserSession { user_id: u64 },
/// Fully ready - all systems operational
Ready { user_id: u64 },
/// Degraded state - something is broken
Degraded { reason: DegradedReason },
}
impl Default for RuntimeState {
fn default() -> Self {
Self::Uninitialized
}
}
/// Reasons for degraded state
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "code", content = "message")]
pub enum DegradedReason {
/// Bundle token extraction failed
BundleExtractionFailed(String),
/// CoreBridge initialization failed
CoreBridgeInitFailed(String),
/// Network connectivity issues
NetworkError(String),
/// Database/storage issues
StorageError(String),
/// Session activation failed (per-user stores not initialized)
SessionActivationFailed(String),
}
/// Full runtime status returned by runtime_get_status and runtime_bootstrap
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeStatus {
/// Current state
pub state: RuntimeState,
/// User ID if authenticated (None if not logged in)
pub user_id: Option<u64>,
/// Whether the API client is initialized (bundle tokens extracted)
pub client_initialized: bool,
/// Whether legacy auth is active
pub legacy_auth: bool,
/// Whether CoreBridge/V2 auth is active
pub corebridge_auth: bool,
/// Whether per-user session is activated
pub session_activated: bool,
/// Degraded reason if state is Degraded
pub degraded_reason: Option<DegradedReason>,
}
impl Default for RuntimeStatus {
fn default() -> Self {
Self {
state: RuntimeState::Uninitialized,
user_id: None,
client_initialized: false,
legacy_auth: false,
corebridge_auth: false,
session_activated: false,
degraded_reason: None,
}
}
}
/// Typed runtime errors - no string matching in clients
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "code", content = "details")]
pub enum RuntimeError {
/// Runtime not initialized - call runtime_bootstrap first
RuntimeNotInitialized,
/// Authentication required for this operation
AuthRequired,
/// Per-user session not activated - call activate_user_session
UserSessionNotActivated,
/// CoreBridge/V2 auth missing - V2 commands won't work
CoreBridgeAuthMissing,
/// Runtime is in degraded state
RuntimeDegraded(DegradedReason),
/// Invalid user ID (e.g., 0)
InvalidUserId,
/// Bootstrap already in progress
BootstrapInProgress,
/// V2 CoreBridge authentication failed
V2AuthFailed(String),
/// V2 CoreBridge not initialized
V2NotInitialized,
/// Manual offline mode is on and the requested track is not available
/// in any local cache. Emitted instead of silently streaming from the
/// network (see issue #279).
TrackNotAvailableOffline,
/// Internal error
Internal(String),
}
impl std::fmt::Display for RuntimeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::RuntimeNotInitialized => write!(f, "Runtime not initialized"),
Self::AuthRequired => write!(f, "Authentication required"),
Self::UserSessionNotActivated => write!(f, "User session not activated"),
Self::CoreBridgeAuthMissing => write!(f, "CoreBridge authentication missing"),
Self::RuntimeDegraded(reason) => write!(f, "Runtime degraded: {:?}", reason),
Self::InvalidUserId => write!(f, "Invalid user ID"),
Self::BootstrapInProgress => write!(f, "Bootstrap already in progress"),
Self::V2AuthFailed(msg) => write!(f, "V2 authentication failed: {}", msg),
Self::V2NotInitialized => write!(f, "V2 CoreBridge not initialized"),
Self::TrackNotAvailableOffline => {
write!(f, "Track not available in offline cache while in offline mode")
}
Self::Internal(msg) => write!(f, "Internal error: {}", msg),
}
}
}
impl std::error::Error for RuntimeError {}
/// Command prerequisites - what each command requires
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandRequirement {
/// No requirements (public endpoints)
None,
/// Requires client to be initialized (bundle tokens)
RequiresClientInit,
/// Requires authentication (logged in)
RequiresAuth,
/// Requires per-user session to be activated
RequiresUserSession,
/// Requires CoreBridge/V2 auth (for V2 commands)
RequiresCoreBridgeAuth,
}
/// Runtime state manager - thread-safe, holds canonical state
pub struct RuntimeManager {
state: Arc<RwLock<RuntimeStatus>>,
bootstrap_in_progress: Arc<RwLock<bool>>,
/// Tracks which Mixtape/Collection the current queue was built from.
/// Set by v2_enqueue_collection (replace mode), cleared by any
/// non-Mixtape queue replacement (set_queue / clear_queue).
/// Append-style ops (add/add_next/bulk) preserve this value.
/// Persistence is in-memory only; session_queue_state table will
/// add the source_collection_id column when it lands.
queue_source_collection_id: RwLock<Option<String>>,
}
impl RuntimeManager {
pub fn new() -> Self {
Self {
state: Arc::new(RwLock::new(RuntimeStatus::default())),
bootstrap_in_progress: Arc::new(RwLock::new(false)),
queue_source_collection_id: RwLock::new(None),
}
}
/// Set (or clear) which collection the current queue was built from.
pub async fn set_queue_source_collection(&self, id: Option<String>) {
*self.queue_source_collection_id.write().await = id;
}
/// Return the collection ID that seeded the current queue, if any.
pub async fn get_queue_source_collection(&self) -> Option<String> {
self.queue_source_collection_id.read().await.clone()
}
/// Get current runtime status
pub async fn get_status(&self) -> RuntimeStatus {
self.state.read().await.clone()
}
/// Update runtime state
pub async fn set_state(&self, new_state: RuntimeState) {
let mut status = self.state.write().await;
status.state = new_state.clone();
// Update derived fields based on state
match &new_state {
RuntimeState::Uninitialized => {
status.client_initialized = false;
status.legacy_auth = false;
status.corebridge_auth = false;
status.session_activated = false;
status.user_id = None;
status.degraded_reason = None;
}
RuntimeState::InitializedNoAuth => {
status.client_initialized = true;
status.legacy_auth = false;
status.corebridge_auth = false;
status.session_activated = false;
status.user_id = None;
status.degraded_reason = None;
}
RuntimeState::AuthenticatedNoUserSession { user_id } => {
status.client_initialized = true;
status.legacy_auth = true;
status.session_activated = false;
status.user_id = Some(*user_id);
status.degraded_reason = None;
}
RuntimeState::Ready { user_id } => {
status.client_initialized = true;
status.legacy_auth = true;
status.corebridge_auth = true;
status.session_activated = true;
status.user_id = Some(*user_id);
status.degraded_reason = None;
}
RuntimeState::Degraded { reason } => {
status.degraded_reason = Some(reason.clone());
}
}
log::info!("[Runtime] State changed to: {:?}", new_state);
}
/// Mark client as initialized
pub async fn set_client_initialized(&self, initialized: bool) {
let mut status = self.state.write().await;
status.client_initialized = initialized;
if initialized && status.state == RuntimeState::Uninitialized {
status.state = RuntimeState::InitializedNoAuth;
}
}
/// Mark legacy auth status
pub async fn set_legacy_auth(&self, auth: bool, user_id: Option<u64>) {
let mut status = self.state.write().await;
status.legacy_auth = auth;
if auth {
if let Some(uid) = user_id {
status.user_id = Some(uid);
if !status.session_activated {
status.state = RuntimeState::AuthenticatedNoUserSession { user_id: uid };
}
}
} else {
status.user_id = None;
status.corebridge_auth = false;
status.session_activated = false;
status.state = RuntimeState::InitializedNoAuth;
}
}
/// Mark CoreBridge auth status
pub async fn set_corebridge_auth(&self, auth: bool) {
let mut status = self.state.write().await;
status.corebridge_auth = auth;
}
/// Mark session as activated
pub async fn set_session_activated(&self, activated: bool, user_id: u64) {
let mut status = self.state.write().await;
status.session_activated = activated;
if activated && status.legacy_auth && status.corebridge_auth {
status.state = RuntimeState::Ready { user_id };
status.user_id = Some(user_id);
}
}
/// Check if bootstrap is in progress
pub async fn is_bootstrap_in_progress(&self) -> bool {
*self.bootstrap_in_progress.read().await
}
/// Set bootstrap in progress flag
pub async fn set_bootstrap_in_progress(&self, in_progress: bool) {
*self.bootstrap_in_progress.write().await = in_progress;
}
/// Validate command requirements against current state
pub async fn check_requirements(&self, req: CommandRequirement) -> Result<(), RuntimeError> {
let status = self.state.read().await;
match req {
CommandRequirement::None => Ok(()),
CommandRequirement::RequiresClientInit => {
if !status.client_initialized {
Err(RuntimeError::RuntimeNotInitialized)
} else {
Ok(())
}
}
CommandRequirement::RequiresAuth => {
if !status.client_initialized {
Err(RuntimeError::RuntimeNotInitialized)
} else if !status.legacy_auth {
Err(RuntimeError::AuthRequired)
} else {
Ok(())
}
}
CommandRequirement::RequiresUserSession => {
// RequiresUserSession only checks session_activated, not legacy_auth.
// This allows both:
// - Online sessions: session_activated=true via activate_session()
// - Offline sessions: session_activated=true via activate_offline_session()
//
// Commands needing Qobuz API should use RequiresAuth or RequiresCoreBridgeAuth.
if !status.session_activated {
Err(RuntimeError::UserSessionNotActivated)
} else {
Ok(())
}
}
CommandRequirement::RequiresCoreBridgeAuth => {
if !status.client_initialized {
Err(RuntimeError::RuntimeNotInitialized)
} else if !status.legacy_auth {
Err(RuntimeError::AuthRequired)
} else if !status.session_activated {
Err(RuntimeError::UserSessionNotActivated)
} else if !status.corebridge_auth {
Err(RuntimeError::CoreBridgeAuthMissing)
} else {
Ok(())
}
}
}
}
/// Check if in degraded state
pub async fn is_degraded(&self) -> bool {
matches!(self.state.read().await.state, RuntimeState::Degraded { .. })
}
/// Set degraded state
pub async fn set_degraded(&self, reason: DegradedReason) {
self.set_state(RuntimeState::Degraded { reason }).await;
}
}
impl Default for RuntimeManager {
fn default() -> Self {
Self::new()
}
}
/// Events emitted during runtime lifecycle
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "event", content = "data")]
pub enum RuntimeEvent {
/// Runtime initialized (client ready)
RuntimeInitialized,
/// Authentication state changed
AuthChanged {
logged_in: bool,
user_id: Option<u64>,
},
/// Per-user session activated
UserSessionActivated { user_id: u64 },
/// Per-user session deactivated
UserSessionDeactivated,
/// CoreBridge auth failed
CoreBridgeAuthFailed { error: String },
/// Runtime entered degraded state
RuntimeDegraded { reason: DegradedReason },
/// Runtime fully ready
RuntimeReady { user_id: u64 },
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_mixtape_context_set_clear() {
let mgr = RuntimeManager::new();
// Initially None
assert_eq!(mgr.get_queue_source_collection().await, None);
// Set Some
mgr.set_queue_source_collection(Some("col-abc".to_string())).await;
assert_eq!(
mgr.get_queue_source_collection().await,
Some("col-abc".to_string())
);
// Clear to None
mgr.set_queue_source_collection(None).await;
assert_eq!(mgr.get_queue_source_collection().await, None);
}
#[tokio::test]
async fn default_state_is_uninitialized() {
let mgr = RuntimeManager::new();
let status = mgr.get_status().await;
assert_eq!(status.state, RuntimeState::Uninitialized);
assert!(!status.client_initialized);
assert!(!status.legacy_auth);
assert!(!status.corebridge_auth);
assert!(!status.session_activated);
assert_eq!(status.user_id, None);
}
#[tokio::test]
async fn client_initialized_transitions_to_initialized_no_auth() {
let mgr = RuntimeManager::new();
mgr.set_client_initialized(true).await;
let status = mgr.get_status().await;
assert_eq!(status.state, RuntimeState::InitializedNoAuth);
assert!(status.client_initialized);
assert!(!status.legacy_auth);
assert_eq!(status.user_id, None);
}
#[tokio::test]
async fn legacy_auth_transitions_to_authenticated_no_user_session() {
let mgr = RuntimeManager::new();
mgr.set_client_initialized(true).await;
mgr.set_legacy_auth(true, Some(42)).await;
let status = mgr.get_status().await;
assert_eq!(
status.state,
RuntimeState::AuthenticatedNoUserSession { user_id: 42 }
);
assert!(status.client_initialized);
assert!(status.legacy_auth);
assert!(!status.session_activated);
assert_eq!(status.user_id, Some(42));
}
#[tokio::test]
async fn corebridge_auth_and_session_activation_reaches_ready() {
let mgr = RuntimeManager::new();
mgr.set_client_initialized(true).await;
mgr.set_legacy_auth(true, Some(42)).await;
mgr.set_corebridge_auth(true).await;
mgr.set_session_activated(true, 42).await;
let status = mgr.get_status().await;
assert_eq!(status.state, RuntimeState::Ready { user_id: 42 });
assert!(status.client_initialized);
assert!(status.legacy_auth);
assert!(status.corebridge_auth);
assert!(status.session_activated);
assert_eq!(status.user_id, Some(42));
}
#[tokio::test]
async fn requires_user_session_accepts_offline_session_without_legacy_auth() {
let mgr = RuntimeManager::new();
mgr.set_session_activated(true, 0).await;
assert!(mgr
.check_requirements(CommandRequirement::RequiresUserSession)
.await
.is_ok());
assert!(matches!(
mgr.check_requirements(CommandRequirement::RequiresAuth).await,
Err(RuntimeError::RuntimeNotInitialized)
));
}
}
+213
View File
@@ -0,0 +1,213 @@
//! Developer settings persistence.
//!
//! This module stores portable developer-mode toggles only. Tauri command
//! wrappers and restart messaging stay outside `qbz-app`.
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeveloperSettings {
pub force_dmabuf: bool,
}
impl Default for DeveloperSettings {
fn default() -> Self {
Self {
force_dmabuf: false,
}
}
}
pub struct DeveloperSettingsStore {
conn: Connection,
}
impl DeveloperSettingsStore {
/// Lightweight read-only open for startup before host-managed state exists.
/// Opens existing DB without creating tables or running migrations.
pub fn new_readonly() -> Result<Self, String> {
let db_path = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz")
.join("developer_settings.db");
Self::new_readonly_at_path(&db_path)
}
pub fn new_readonly_at_path(db_path: &Path) -> Result<Self, String> {
if !db_path.exists() {
return Err("Developer settings DB does not exist yet".to_string());
}
let conn = Connection::open_with_flags(
db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|e| {
format!(
"Failed to open developer settings database (readonly): {}",
e
)
})?;
Ok(Self { conn })
}
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open developer settings database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| {
format!(
"Failed to enable WAL for developer settings database: {}",
e
)
})?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS developer_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
force_dmabuf INTEGER NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO developer_settings (id, force_dmabuf) VALUES (1, 0);",
)
.map_err(|e| format!("Failed to create developer settings table: {}", e))?;
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
Self::open_at(&data_dir, "developer_settings.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "developer_settings.db")
}
pub fn get_settings(&self) -> Result<DeveloperSettings, String> {
self.conn
.query_row(
"SELECT force_dmabuf FROM developer_settings WHERE id = 1",
[],
|row| {
Ok(DeveloperSettings {
force_dmabuf: row.get::<_, i64>(0)? != 0,
})
},
)
.map_err(|e| format!("Failed to get developer settings: {}", e))
}
pub fn set_force_dmabuf(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE developer_settings SET force_dmabuf = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set force_dmabuf: {}", e))?;
Ok(())
}
}
/// Thread-safe wrapper for host state management.
pub struct DeveloperSettingsState {
pub store: Arc<Mutex<Option<DeveloperSettingsStore>>>,
}
impl DeveloperSettingsState {
pub fn new() -> Result<Self, String> {
Ok(Self {
store: Arc::new(Mutex::new(Some(DeveloperSettingsStore::new()?))),
})
}
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
}
impl Default for DeveloperSettingsState {
fn default() -> Self {
Self::new_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn unique_test_dir(name: &str) -> std::path::PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
}
fn fresh_store(name: &str) -> (std::path::PathBuf, DeveloperSettingsStore) {
let dir = unique_test_dir(name);
let store = DeveloperSettingsStore::new_at(&dir).expect("open store in temp dir");
(dir, store)
}
#[test]
fn developer_settings_default_values_are_stable() {
let settings = DeveloperSettings::default();
assert!(!settings.force_dmabuf);
}
#[test]
fn developer_settings_store_returns_defaults() {
let (dir, store) = fresh_store("developer-default");
let settings = store.get_settings().expect("get settings");
assert!(!settings.force_dmabuf);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn developer_settings_persist_force_dmabuf() {
let dir = unique_test_dir("developer-force-dmabuf");
{
let store = DeveloperSettingsStore::new_at(&dir).expect("open store");
store.set_force_dmabuf(true).expect("set force dmabuf");
}
let reopened = DeveloperSettingsStore::new_at(&dir).expect("reopen store");
let settings = reopened.get_settings().expect("get settings");
assert!(settings.force_dmabuf);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn developer_settings_readonly_opens_existing_db() {
let dir = unique_test_dir("developer-readonly");
let db_path = dir.join("developer_settings.db");
{
let store = DeveloperSettingsStore::new_at(&dir).expect("open store");
store.set_force_dmabuf(true).expect("set force dmabuf");
}
let readonly = DeveloperSettingsStore::new_readonly_at_path(&db_path)
.expect("open existing store read-only");
let settings = readonly.get_settings().expect("get settings");
assert!(settings.force_dmabuf);
let _ = std::fs::remove_dir_all(dir);
}
}
+226
View File
@@ -0,0 +1,226 @@
//! Download settings persistence.
//!
//! This module stores portable download preferences only. Host filesystem
//! validation, permission probes, and download/cache behavior stay outside
//! `qbz-app`.
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadSettings {
pub download_root: String,
pub show_in_library: bool,
}
impl Default for DownloadSettings {
fn default() -> Self {
let default_root = dirs::cache_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("qbz")
.join("audio")
.to_string_lossy()
.to_string();
Self {
download_root: default_root,
// New installs surface offline-cached Qobuz tracks in the
// Local Library by default. Existing users with a persisted value
// are not affected because INSERT OR IGNORE only seeds first run.
show_in_library: true,
}
}
}
pub struct DownloadSettingsStore {
conn: Connection,
}
impl DownloadSettingsStore {
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open download settings database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to enable WAL for download settings database: {}", e))?;
let default_settings = DownloadSettings::default();
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS download_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
download_root TEXT NOT NULL,
show_in_library INTEGER NOT NULL DEFAULT 1
);",
)
.map_err(|e| format!("Failed to create download settings table: {}", e))?;
conn.execute(
"INSERT OR IGNORE INTO download_settings (id, download_root, show_in_library)
VALUES (1, ?1, ?2)",
params![
default_settings.download_root,
default_settings.show_in_library as i64,
],
)
.map_err(|e| format!("Failed to initialize download settings: {}", e))?;
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
Self::open_at(&data_dir, "download_settings.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "download_settings.db")
}
pub fn get_settings(&self) -> Result<DownloadSettings, String> {
self.conn
.query_row(
"SELECT download_root, show_in_library FROM download_settings WHERE id = 1",
[],
|row| {
Ok(DownloadSettings {
download_root: row.get(0)?,
show_in_library: row.get::<_, i64>(1)? != 0,
})
},
)
.map_err(|e| format!("Failed to get download settings: {}", e))
}
pub fn set_download_root(&self, path: &str) -> Result<(), String> {
self.conn
.execute(
"UPDATE download_settings SET download_root = ?1 WHERE id = 1",
params![path],
)
.map_err(|e| format!("Failed to set download root: {}", e))?;
Ok(())
}
pub fn set_show_in_library(&self, show: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE download_settings SET show_in_library = ?1 WHERE id = 1",
params![show as i64],
)
.map_err(|e| format!("Failed to set show_in_library: {}", e))?;
Ok(())
}
}
pub type DownloadSettingsState = Arc<Mutex<Option<DownloadSettingsStore>>>;
pub fn create_download_settings_state() -> Result<DownloadSettingsState, String> {
let store = DownloadSettingsStore::new()?;
Ok(Arc::new(Mutex::new(Some(store))))
}
pub fn create_empty_download_settings_state() -> DownloadSettingsState {
Arc::new(Mutex::new(None))
}
#[cfg(test)]
mod tests {
use super::*;
fn unique_test_dir(name: &str) -> std::path::PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
}
fn fresh_store(name: &str) -> (std::path::PathBuf, DownloadSettingsStore) {
let dir = unique_test_dir(name);
let store = DownloadSettingsStore::new_at(&dir).expect("open store in temp dir");
(dir, store)
}
#[test]
fn download_settings_default_values_are_stable() {
let settings = DownloadSettings::default();
assert!(settings.download_root.ends_with("qbz/audio"));
assert!(settings.show_in_library);
}
#[test]
fn download_settings_store_returns_defaults() {
let (dir, store) = fresh_store("download-default");
let settings = store.get_settings().expect("get settings");
assert!(settings.download_root.ends_with("qbz/audio"));
assert!(settings.show_in_library);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn download_settings_persist_download_root() {
let dir = unique_test_dir("download-root");
{
let store = DownloadSettingsStore::new_at(&dir).expect("open store");
store
.set_download_root("/tmp/qbz-downloads")
.expect("set download root");
}
let reopened = DownloadSettingsStore::new_at(&dir).expect("reopen store");
let settings = reopened.get_settings().expect("get settings");
assert_eq!(settings.download_root, "/tmp/qbz-downloads");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn download_settings_persist_show_in_library() {
let dir = unique_test_dir("download-library");
{
let store = DownloadSettingsStore::new_at(&dir).expect("open store");
store
.set_show_in_library(false)
.expect("set show in library");
}
let reopened = DownloadSettingsStore::new_at(&dir).expect("reopen store");
let settings = reopened.get_settings().expect("get settings");
assert!(!settings.show_in_library);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn download_settings_reopen_does_not_overwrite_existing_row() {
let dir = unique_test_dir("download-no-overwrite");
{
let store = DownloadSettingsStore::new_at(&dir).expect("open store");
store
.set_download_root("/tmp/custom-downloads")
.expect("set download root");
store
.set_show_in_library(false)
.expect("set show in library");
}
let reopened = DownloadSettingsStore::new_at(&dir).expect("reopen store");
let settings = reopened.get_settings().expect("get settings");
assert_eq!(settings.download_root, "/tmp/custom-downloads");
assert!(!settings.show_in_library);
let _ = std::fs::remove_dir_all(dir);
}
}
+402
View File
@@ -0,0 +1,402 @@
use chrono::Utc;
use md5::{Digest, Md5};
use rusqlite::{params, Connection, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FavoritesPreferences {
pub custom_icon_path: Option<String>,
pub custom_icon_preset: Option<String>,
pub icon_background: Option<String>,
pub tab_order: Vec<String>,
}
impl Default for FavoritesPreferences {
fn default() -> Self {
Self {
custom_icon_path: None,
custom_icon_preset: Some("heart".to_string()),
icon_background: None,
tab_order: vec![
"tracks".to_string(),
"albums".to_string(),
"artists".to_string(),
"playlists".to_string(),
],
}
}
}
pub struct FavoritesPreferencesStore {
conn: Connection,
}
impl FavoritesPreferencesStore {
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open favorites preferences database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to set WAL mode on favorites preferences: {}", e))?;
conn.execute(
"CREATE TABLE IF NOT EXISTS favorites_preferences (
id INTEGER PRIMARY KEY CHECK (id = 1),
custom_icon_path TEXT,
custom_icon_preset TEXT,
tab_order TEXT NOT NULL
)",
[],
)
.map_err(|e| format!("Failed to create favorites preferences table: {}", e))?;
// Migration: Add icon_background column if it doesn't exist
let has_icon_background = conn
.prepare("SELECT icon_background FROM favorites_preferences LIMIT 1")
.is_ok();
if !has_icon_background {
conn.execute(
"ALTER TABLE favorites_preferences ADD COLUMN icon_background TEXT",
[],
)
.map_err(|e| format!("Failed to add icon_background column: {}", e))?;
}
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
Self::open_at(&data_dir, "favorites_preferences.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "favorites_preferences.db")
}
fn favorites_icon_dir() -> Result<PathBuf, String> {
let cache_dir = dirs::cache_dir()
.ok_or("Could not determine cache directory")?
.join("qbz")
.join("favorites");
fs::create_dir_all(&cache_dir)
.map_err(|e| format!("Failed to create favorites icon directory: {}", e))?;
Ok(cache_dir)
}
fn normalize_custom_icon_path(&self, path: String) -> Result<String, String> {
let trimmed = path.trim();
if trimmed.is_empty() {
return Ok(String::new());
}
let source = Path::new(trimmed);
if !source.exists() {
return Err(format!("Source icon does not exist: {}", trimmed));
}
let icon_dir = Self::favorites_icon_dir()?;
if source.starts_with(&icon_dir) {
return Ok(trimmed.to_string());
}
let extension = source.extension().and_then(|e| e.to_str()).unwrap_or("png");
let mut hasher = Md5::new();
hasher.update(trimmed.as_bytes());
let hash = format!("{:x}", hasher.finalize());
let filename = format!(
"favorites_icon_{}_{}.{}",
hash,
Utc::now().timestamp(),
extension
);
let dest_path = icon_dir.join(filename);
fs::copy(source, &dest_path)
.map_err(|e| format!("Failed to copy favorites icon: {}", e))?;
Ok(dest_path.to_string_lossy().to_string())
}
pub fn get_preferences(&self) -> Result<FavoritesPreferences, String> {
let mut stmt = self.conn.prepare("SELECT custom_icon_path, custom_icon_preset, icon_background, tab_order FROM favorites_preferences WHERE id = 1")
.map_err(|e| format!("Failed to prepare select: {}", e))?;
let result = stmt.query_row([], |row| {
let custom_icon_path: Option<String> = row.get(0)?;
let custom_icon_preset: Option<String> = row.get(1)?;
let icon_background: Option<String> = row.get(2)?;
let tab_order_str: String = row.get(3)?;
let custom_icon_path = custom_icon_path.and_then(|value| {
if value.trim().is_empty() {
None
} else {
Some(value)
}
});
let tab_order: Vec<String> =
serde_json::from_str(&tab_order_str).unwrap_or_else(|_| {
vec![
"tracks".to_string(),
"albums".to_string(),
"artists".to_string(),
"playlists".to_string(),
]
});
Ok(FavoritesPreferences {
custom_icon_path,
custom_icon_preset,
icon_background,
tab_order,
})
});
match result {
Ok(mut prefs) => {
if let Some(path) = prefs.custom_icon_path.clone() {
match self.normalize_custom_icon_path(path) {
Ok(resolved) => {
let normalized = if resolved.trim().is_empty() {
None
} else {
Some(resolved)
};
if normalized != prefs.custom_icon_path {
prefs.custom_icon_path = normalized;
let _ = self.save_preferences(prefs.clone());
}
}
Err(_) => {
prefs.custom_icon_path = None;
let _ = self.save_preferences(prefs.clone());
}
}
}
Ok(prefs)
}
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(FavoritesPreferences::default()),
Err(e) => Err(format!("Failed to query preferences: {}", e)),
}
}
pub fn save_preferences(
&self,
mut prefs: FavoritesPreferences,
) -> Result<FavoritesPreferences, String> {
if let Some(path) = prefs.custom_icon_path.clone() {
let resolved = self.normalize_custom_icon_path(path)?;
if resolved.is_empty() {
prefs.custom_icon_path = None;
} else {
prefs.custom_icon_path = Some(resolved);
}
}
let tab_order_str = serde_json::to_string(&prefs.tab_order)
.map_err(|e| format!("Failed to serialize tab_order: {}", e))?;
self.conn.execute(
"INSERT OR REPLACE INTO favorites_preferences (id, custom_icon_path, custom_icon_preset, icon_background, tab_order)
VALUES (1, ?1, ?2, ?3, ?4)",
params![prefs.custom_icon_path, prefs.custom_icon_preset, prefs.icon_background, tab_order_str],
)
.map_err(|e| format!("Failed to save preferences: {}", e))?;
Ok(prefs)
}
}
pub struct FavoritesPreferencesState {
pub store: Arc<Mutex<Option<FavoritesPreferencesStore>>>,
}
impl FavoritesPreferencesState {
pub fn new() -> Result<Self, String> {
let store = FavoritesPreferencesStore::new()?;
Ok(Self {
store: Arc::new(Mutex::new(Some(store))),
})
}
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
let new_store = FavoritesPreferencesStore::new_at(base_dir)?;
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock favorites preferences store".to_string())?;
*guard = Some(new_store);
Ok(())
}
pub fn teardown(&self) -> Result<(), String> {
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock favorites preferences store".to_string())?;
*guard = None;
Ok(())
}
}
pub fn create_table(conn: &Connection) -> Result<()> {
conn.execute(
"CREATE TABLE IF NOT EXISTS favorites_preferences (
id INTEGER PRIMARY KEY CHECK (id = 1),
custom_icon_path TEXT,
custom_icon_preset TEXT,
tab_order TEXT NOT NULL
)",
[],
)?;
// Migration: Add icon_background column if it doesn't exist
let has_icon_background = conn
.prepare("SELECT icon_background FROM favorites_preferences LIMIT 1")
.is_ok();
if !has_icon_background {
conn.execute(
"ALTER TABLE favorites_preferences ADD COLUMN icon_background TEXT",
[],
)?;
}
Ok(())
}
pub fn load_preferences(conn: &Connection) -> Result<FavoritesPreferences> {
let mut stmt = conn.prepare("SELECT custom_icon_path, custom_icon_preset, icon_background, tab_order FROM favorites_preferences WHERE id = 1")?;
let result = stmt.query_row([], |row| {
let custom_icon_path: Option<String> = row.get(0)?;
let custom_icon_preset: Option<String> = row.get(1)?;
let icon_background: Option<String> = row.get(2)?;
let tab_order_str: String = row.get(3)?;
let custom_icon_path = custom_icon_path.and_then(|value| {
if value.trim().is_empty() {
None
} else {
Some(value)
}
});
let tab_order: Vec<String> = serde_json::from_str(&tab_order_str).unwrap_or_else(|_| {
vec![
"tracks".to_string(),
"albums".to_string(),
"artists".to_string(),
"playlists".to_string(),
]
});
Ok(FavoritesPreferences {
custom_icon_path,
custom_icon_preset,
icon_background,
tab_order,
})
});
match result {
Ok(prefs) => Ok(prefs),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(FavoritesPreferences::default()),
Err(e) => Err(e),
}
}
pub fn save_preferences(conn: &Connection, prefs: &FavoritesPreferences) -> Result<()> {
let tab_order_str = serde_json::to_string(&prefs.tab_order).unwrap();
conn.execute(
"INSERT OR REPLACE INTO favorites_preferences (id, custom_icon_path, custom_icon_preset, icon_background, tab_order)
VALUES (1, ?1, ?2, ?3, ?4)",
params![prefs.custom_icon_path, prefs.custom_icon_preset, prefs.icon_background, tab_order_str],
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn unique_test_dir(name: &str) -> PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
}
#[test]
fn favorites_preferences_default_tab_order_is_stable() {
let prefs = FavoritesPreferences::default();
assert_eq!(
prefs.tab_order,
vec![
"tracks".to_string(),
"albums".to_string(),
"artists".to_string(),
"playlists".to_string()
]
);
assert_eq!(prefs.custom_icon_preset, Some("heart".to_string()));
}
#[test]
fn favorites_preferences_store_returns_default_when_empty() {
let dir = unique_test_dir("favorites-default");
let store = FavoritesPreferencesStore::new_at(&dir).unwrap();
let prefs = store.get_preferences().unwrap();
assert_eq!(prefs.tab_order, FavoritesPreferences::default().tab_order);
assert_eq!(
prefs.custom_icon_preset,
FavoritesPreferences::default().custom_icon_preset
);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn favorites_preferences_persist_without_custom_icon() {
let dir = unique_test_dir("favorites-persist");
let store = FavoritesPreferencesStore::new_at(&dir).unwrap();
let prefs = FavoritesPreferences {
custom_icon_path: None,
custom_icon_preset: Some("star".to_string()),
icon_background: Some("#112233".to_string()),
tab_order: vec!["artists".to_string(), "tracks".to_string()],
};
let saved = store.save_preferences(prefs.clone()).unwrap();
let loaded = store.get_preferences().unwrap();
assert_eq!(saved.custom_icon_preset, prefs.custom_icon_preset);
assert_eq!(loaded.custom_icon_preset, prefs.custom_icon_preset);
assert_eq!(loaded.icon_background, prefs.icon_background);
assert_eq!(loaded.tab_order, prefs.tab_order);
let _ = std::fs::remove_dir_all(dir);
}
}
+338
View File
@@ -0,0 +1,338 @@
//! Graphics settings persistence.
//!
//! This module stores portable host rendering preferences only. Startup
//! detection, environment variable application, crash recovery, and command
//! transport stay outside `qbz-app`.
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphicsSettings {
/// GPU rendering toggle. Read at startup as default; env var QBZ_HARDWARE_ACCEL overrides.
pub hardware_acceleration: bool,
/// Force X11/XWayland backend on Wayland sessions (requires restart).
pub force_x11: bool,
/// GDK_SCALE override for XWayland (None = auto). Integer values: "1", "2".
pub gdk_scale: Option<String>,
/// GDK_DPI_SCALE override for XWayland (None = auto). Float values: "0.5", "1", "1.5".
pub gdk_dpi_scale: Option<String>,
/// GSK_RENDERER override (None = auto). Values: "gl", "ngl", "vulkan", "cairo".
pub gsk_renderer: Option<String>,
/// Rendering GPU selection.
///
/// Valid values are "auto", "integrated", "discrete", "software", or a
/// host-specific GPU id such as a PCI slot.
pub preferred_gpu: String,
/// Opt-in NVIDIA Wayland compatibility mode. The host applies the runtime
/// environment changes before graphics initialization.
pub nvidia_compat_mode: bool,
}
impl Default for GraphicsSettings {
fn default() -> Self {
Self {
hardware_acceleration: true,
force_x11: false,
gdk_scale: None,
gdk_dpi_scale: None,
gsk_renderer: None,
preferred_gpu: "auto".to_string(),
nvidia_compat_mode: false,
}
}
}
pub struct GraphicsSettingsStore {
conn: Connection,
}
impl GraphicsSettingsStore {
/// Lightweight read-only open for startup before host-managed state exists.
/// Opens existing DB without creating tables or running migrations.
pub fn new_readonly() -> Result<Self, String> {
let db_path = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz")
.join("graphics_settings.db");
Self::new_readonly_at_path(&db_path)
}
pub fn new_readonly_at_path(db_path: &Path) -> Result<Self, String> {
if !db_path.exists() {
return Err("Graphics settings DB does not exist yet".to_string());
}
let conn = Connection::open_with_flags(
db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|e| {
format!(
"Failed to open graphics settings database (readonly): {}",
e
)
})?;
Ok(Self { conn })
}
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open graphics settings database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to enable WAL for graphics settings database: {}", e))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS graphics_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
hardware_acceleration INTEGER NOT NULL DEFAULT 1
);
INSERT OR IGNORE INTO graphics_settings (id, hardware_acceleration) VALUES (1, 1);",
)
.map_err(|e| format!("Failed to create graphics settings table: {}", e))?;
let _ = conn.execute_batch(
"ALTER TABLE graphics_settings ADD COLUMN force_x11 INTEGER NOT NULL DEFAULT 0;",
);
let _ = conn.execute_batch("ALTER TABLE graphics_settings ADD COLUMN gdk_scale TEXT;");
let _ = conn.execute_batch("ALTER TABLE graphics_settings ADD COLUMN gdk_dpi_scale TEXT;");
let _ = conn.execute_batch("ALTER TABLE graphics_settings ADD COLUMN gsk_renderer TEXT;");
let _ = conn.execute_batch(
"ALTER TABLE graphics_settings ADD COLUMN preferred_gpu TEXT NOT NULL DEFAULT 'auto';",
);
let _ = conn.execute_batch(
"ALTER TABLE graphics_settings ADD COLUMN nvidia_compat_mode INTEGER NOT NULL DEFAULT 0;",
);
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
Self::open_at(&data_dir, "graphics_settings.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "graphics_settings.db")
}
pub fn get_settings(&self) -> Result<GraphicsSettings, String> {
self.conn
.query_row(
"SELECT hardware_acceleration, force_x11, gdk_scale, gdk_dpi_scale, gsk_renderer, preferred_gpu, nvidia_compat_mode FROM graphics_settings WHERE id = 1",
[],
|row| {
Ok(GraphicsSettings {
hardware_acceleration: row.get::<_, i64>(0)? != 0,
force_x11: row.get::<_, i64>(1)? != 0,
gdk_scale: row.get::<_, Option<String>>(2)?,
gdk_dpi_scale: row.get::<_, Option<String>>(3)?,
gsk_renderer: row.get::<_, Option<String>>(4)?,
preferred_gpu: row
.get::<_, Option<String>>(5)?
.unwrap_or_else(|| "auto".to_string()),
nvidia_compat_mode: row.get::<_, i64>(6).unwrap_or(0) != 0,
})
},
)
.map_err(|e| format!("Failed to get graphics settings: {}", e))
}
pub fn set_hardware_acceleration(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE graphics_settings SET hardware_acceleration = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set hardware_acceleration: {}", e))?;
Ok(())
}
pub fn set_force_x11(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE graphics_settings SET force_x11 = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set force_x11: {}", e))?;
Ok(())
}
pub fn set_gdk_scale(&self, value: Option<String>) -> Result<(), String> {
self.conn
.execute(
"UPDATE graphics_settings SET gdk_scale = ?1 WHERE id = 1",
params![value],
)
.map_err(|e| format!("Failed to set gdk_scale: {}", e))?;
Ok(())
}
pub fn set_gdk_dpi_scale(&self, value: Option<String>) -> Result<(), String> {
self.conn
.execute(
"UPDATE graphics_settings SET gdk_dpi_scale = ?1 WHERE id = 1",
params![value],
)
.map_err(|e| format!("Failed to set gdk_dpi_scale: {}", e))?;
Ok(())
}
pub fn set_gsk_renderer(&self, value: Option<String>) -> Result<(), String> {
self.conn
.execute(
"UPDATE graphics_settings SET gsk_renderer = ?1 WHERE id = 1",
params![value],
)
.map_err(|e| format!("Failed to set gsk_renderer: {}", e))?;
Ok(())
}
pub fn set_preferred_gpu(&self, value: &str) -> Result<(), String> {
self.conn
.execute(
"UPDATE graphics_settings SET preferred_gpu = ?1 WHERE id = 1",
params![value],
)
.map_err(|e| format!("Failed to set preferred_gpu: {}", e))?;
Ok(())
}
pub fn set_nvidia_compat_mode(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE graphics_settings SET nvidia_compat_mode = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set nvidia_compat_mode: {}", e))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn unique_test_dir(name: &str) -> std::path::PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
}
#[test]
fn graphics_settings_default_values_are_stable() {
let settings = GraphicsSettings::default();
assert!(settings.hardware_acceleration);
assert!(!settings.force_x11);
assert_eq!(settings.gdk_scale, None);
assert_eq!(settings.gdk_dpi_scale, None);
assert_eq!(settings.gsk_renderer, None);
assert_eq!(settings.preferred_gpu, "auto");
assert!(!settings.nvidia_compat_mode);
}
#[test]
fn graphics_settings_store_returns_defaults() {
let dir = unique_test_dir("graphics-default");
let store = GraphicsSettingsStore::new_at(&dir).expect("open store");
let settings = store.get_settings().expect("get settings");
assert!(settings.hardware_acceleration);
assert!(!settings.force_x11);
assert_eq!(settings.preferred_gpu, "auto");
assert!(!settings.nvidia_compat_mode);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn graphics_settings_persist_all_fields() {
let dir = unique_test_dir("graphics-persist");
{
let store = GraphicsSettingsStore::new_at(&dir).expect("open store");
store
.set_hardware_acceleration(false)
.expect("set hardware acceleration");
store.set_force_x11(true).expect("set force x11");
store
.set_gdk_scale(Some("2".to_string()))
.expect("set gdk scale");
store
.set_gdk_dpi_scale(Some("0.5".to_string()))
.expect("set gdk dpi scale");
store
.set_gsk_renderer(Some("ngl".to_string()))
.expect("set gsk renderer");
store
.set_preferred_gpu("discrete")
.expect("set preferred gpu");
store
.set_nvidia_compat_mode(true)
.expect("set nvidia compat mode");
}
let reopened = GraphicsSettingsStore::new_at(&dir).expect("reopen store");
let settings = reopened.get_settings().expect("get settings");
assert!(!settings.hardware_acceleration);
assert!(settings.force_x11);
assert_eq!(settings.gdk_scale.as_deref(), Some("2"));
assert_eq!(settings.gdk_dpi_scale.as_deref(), Some("0.5"));
assert_eq!(settings.gsk_renderer.as_deref(), Some("ngl"));
assert_eq!(settings.preferred_gpu, "discrete");
assert!(settings.nvidia_compat_mode);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn graphics_settings_reopen_does_not_overwrite_existing_row() {
let dir = unique_test_dir("graphics-no-overwrite");
{
let store = GraphicsSettingsStore::new_at(&dir).expect("open store");
store
.set_hardware_acceleration(false)
.expect("set hardware acceleration");
store
.set_preferred_gpu("software")
.expect("set preferred gpu");
}
let reopened = GraphicsSettingsStore::new_at(&dir).expect("reopen store");
let settings = reopened.get_settings().expect("get settings");
assert!(!settings.hardware_acceleration);
assert_eq!(settings.preferred_gpu, "software");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn graphics_settings_readonly_opens_existing_db() {
let dir = unique_test_dir("graphics-readonly");
let db_path = dir.join("graphics_settings.db");
{
let store = GraphicsSettingsStore::new_at(&dir).expect("open store");
store
.set_preferred_gpu("integrated")
.expect("set preferred gpu");
}
let readonly =
GraphicsSettingsStore::new_readonly_at_path(&db_path).expect("open readonly store");
let settings = readonly.get_settings().expect("get readonly settings");
assert_eq!(settings.preferred_gpu, "integrated");
let _ = std::fs::remove_dir_all(dir);
}
}
+204
View File
@@ -0,0 +1,204 @@
//! Image cache settings persistence.
//!
//! This module stores portable image cache preferences only. Cache runtime,
//! file management, stats, and image resolution remain host-owned behavior.
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageCacheSettings {
pub enabled: bool,
pub max_size_mb: u32,
}
impl Default for ImageCacheSettings {
fn default() -> Self {
Self {
enabled: true,
max_size_mb: 200,
}
}
}
pub struct ImageCacheSettingsStore {
conn: Connection,
}
impl ImageCacheSettingsStore {
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open image cache settings database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to enable WAL: {}", e))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS image_cache_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
enabled INTEGER NOT NULL DEFAULT 1,
max_size_mb INTEGER NOT NULL DEFAULT 200
);
INSERT OR IGNORE INTO image_cache_settings (id, enabled, max_size_mb)
VALUES (1, 1, 200);",
)
.map_err(|e| format!("Failed to create image cache settings table: {}", e))?;
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
Self::open_at(&data_dir, "image_cache_settings.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "image_cache_settings.db")
}
pub fn get_settings(&self) -> Result<ImageCacheSettings, String> {
self.conn
.query_row(
"SELECT enabled, max_size_mb FROM image_cache_settings WHERE id = 1",
[],
|row| {
Ok(ImageCacheSettings {
enabled: row.get::<_, i32>(0)? != 0,
max_size_mb: row.get::<_, u32>(1)?,
})
},
)
.map_err(|e| format!("Failed to read image cache settings: {}", e))
}
pub fn set_enabled(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE image_cache_settings SET enabled = ?1 WHERE id = 1",
params![enabled as i32],
)
.map_err(|e| format!("Failed to update image cache enabled: {}", e))?;
Ok(())
}
pub fn set_max_size_mb(&self, max_size_mb: u32) -> Result<(), String> {
self.conn
.execute(
"UPDATE image_cache_settings SET max_size_mb = ?1 WHERE id = 1",
params![max_size_mb],
)
.map_err(|e| format!("Failed to update image cache max size: {}", e))?;
Ok(())
}
}
pub struct ImageCacheSettingsState {
pub store: Arc<Mutex<Option<ImageCacheSettingsStore>>>,
}
impl ImageCacheSettingsState {
pub fn new() -> Result<Self, String> {
Ok(Self {
store: Arc::new(Mutex::new(Some(ImageCacheSettingsStore::new()?))),
})
}
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn unique_test_dir(name: &str) -> std::path::PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
}
fn fresh_store(name: &str) -> (std::path::PathBuf, ImageCacheSettingsStore) {
let dir = unique_test_dir(name);
let store = ImageCacheSettingsStore::new_at(&dir).expect("open store in temp dir");
(dir, store)
}
#[test]
fn image_cache_settings_default_values_are_stable() {
let settings = ImageCacheSettings::default();
assert!(settings.enabled);
assert_eq!(settings.max_size_mb, 200);
}
#[test]
fn image_cache_settings_store_returns_defaults() {
let (dir, store) = fresh_store("image-cache-default");
let settings = store.get_settings().expect("get settings");
assert!(settings.enabled);
assert_eq!(settings.max_size_mb, 200);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn image_cache_settings_persist_enabled() {
let dir = unique_test_dir("image-cache-enabled");
{
let store = ImageCacheSettingsStore::new_at(&dir).expect("open store");
store.set_enabled(false).expect("set enabled");
}
let reopened = ImageCacheSettingsStore::new_at(&dir).expect("reopen store");
let settings = reopened.get_settings().expect("get settings");
assert!(!settings.enabled);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn image_cache_settings_persist_max_size_mb() {
let dir = unique_test_dir("image-cache-size");
{
let store = ImageCacheSettingsStore::new_at(&dir).expect("open store");
store.set_max_size_mb(512).expect("set max size");
}
let reopened = ImageCacheSettingsStore::new_at(&dir).expect("reopen store");
let settings = reopened.get_settings().expect("get settings");
assert_eq!(settings.max_size_mb, 512);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn image_cache_settings_reopen_does_not_overwrite_existing_row() {
let dir = unique_test_dir("image-cache-no-overwrite");
{
let store = ImageCacheSettingsStore::new_at(&dir).expect("open store");
store.set_enabled(false).expect("set enabled");
store.set_max_size_mb(512).expect("set max size");
}
let reopened = ImageCacheSettingsStore::new_at(&dir).expect("reopen store");
let settings = reopened.get_settings().expect("get settings");
assert!(!settings.enabled);
assert_eq!(settings.max_size_mb, 512);
let _ = std::fs::remove_dir_all(dir);
}
}
+147
View File
@@ -0,0 +1,147 @@
//! Legal settings storage.
//!
//! Persists user acceptance of legal terms.
use log::info;
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LegalSettings {
/// User has accepted Qobuz Terms of Service.
pub qobuz_tos_accepted: bool,
}
impl Default for LegalSettings {
fn default() -> Self {
Self {
qobuz_tos_accepted: false,
}
}
}
pub struct LegalSettingsStore {
conn: Connection,
}
impl LegalSettingsStore {
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open legal settings database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to enable WAL for legal settings database: {}", e))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS legal_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
qobuz_tos_accepted INTEGER NOT NULL DEFAULT 0
);",
)
.map_err(|e| format!("Failed to create legal settings table: {}", e))?;
conn.execute(
"INSERT OR IGNORE INTO legal_settings (id, qobuz_tos_accepted) VALUES (1, 0)",
[],
)
.map_err(|e| format!("Failed to insert default legal settings: {}", e))?;
info!("[LegalSettings] Database initialized");
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
Self::open_at(&data_dir, "legal_settings.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "legal_settings.db")
}
pub fn get_settings(&self) -> Result<LegalSettings, String> {
self.conn
.query_row(
"SELECT qobuz_tos_accepted FROM legal_settings WHERE id = 1",
[],
|row| {
let qobuz_tos_accepted: i32 = row.get(0)?;
Ok(LegalSettings {
qobuz_tos_accepted: qobuz_tos_accepted != 0,
})
},
)
.map_err(|e| format!("Failed to get legal settings: {}", e))
}
pub fn set_qobuz_tos_accepted(&self, accepted: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE legal_settings SET qobuz_tos_accepted = ? WHERE id = 1",
params![accepted as i32],
)
.map_err(|e| format!("Failed to update ToS acceptance: {}", e))?;
info!("[LegalSettings] Qobuz ToS accepted: {}", accepted);
Ok(())
}
}
pub type LegalSettingsState = Arc<Mutex<Option<LegalSettingsStore>>>;
pub fn create_legal_settings_state() -> Result<LegalSettingsState, String> {
let store = LegalSettingsStore::new()?;
Ok(Arc::new(Mutex::new(Some(store))))
}
pub fn create_empty_legal_settings_state() -> LegalSettingsState {
Arc::new(Mutex::new(None))
}
#[cfg(test)]
mod tests {
use super::*;
fn unique_test_dir(name: &str) -> std::path::PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
}
#[test]
fn legal_settings_default_to_unaccepted() {
let dir = unique_test_dir("legal-default");
let store = LegalSettingsStore::new_at(&dir).unwrap();
let settings = store.get_settings().unwrap();
assert!(!settings.qobuz_tos_accepted);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn legal_settings_persist_tos_acceptance() {
let dir = unique_test_dir("legal-persist");
{
let store = LegalSettingsStore::new_at(&dir).unwrap();
store.set_qobuz_tos_accepted(true).unwrap();
}
let reopened = LegalSettingsStore::new_at(&dir).unwrap();
let settings = reopened.get_settings().unwrap();
assert!(settings.qobuz_tos_accepted);
let _ = std::fs::remove_dir_all(dir);
}
}
+423
View File
@@ -0,0 +1,423 @@
use rusqlite::{params, Connection, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FoldersViewMode {
Flat,
Tree,
}
impl Default for FoldersViewMode {
fn default() -> Self {
FoldersViewMode::Flat
}
}
impl FoldersViewMode {
pub fn as_str(&self) -> &'static str {
match self {
FoldersViewMode::Flat => "flat",
FoldersViewMode::Tree => "tree",
}
}
pub fn from_str(s: &str) -> Self {
match s {
"tree" => FoldersViewMode::Tree,
_ => FoldersViewMode::Flat,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LibraryPreferences {
pub tab_order: Vec<String>,
pub hidden_tabs: Vec<String>,
#[serde(default)]
pub folders_view_mode: FoldersViewMode,
/// Width (in CSS pixels) of the tree-mode left sidebar in the Folders
/// tab. `None` means "use the frontend default" (currently 302px).
/// Persisted on drag-end so the choice survives restarts. Bounds are
/// enforced on the frontend (min 200px, max 40% of content area).
#[serde(default)]
pub folders_tree_sidebar_width: Option<u32>,
}
impl Default for LibraryPreferences {
fn default() -> Self {
Self {
// Default order matches the current LocalLibraryView tab list,
// with the renamed 'folders' tab in place of the old 'albums'
// tab. Phase 3 adds 'albums' (the new metadata-grouped view)
// to this default.
tab_order: vec![
"tracks".to_string(),
"folders".to_string(),
"albums".to_string(),
"artists".to_string(),
],
hidden_tabs: vec![],
folders_view_mode: FoldersViewMode::Flat,
folders_tree_sidebar_width: None,
}
}
}
pub struct LibraryPreferencesStore {
conn: Connection,
}
impl LibraryPreferencesStore {
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open library preferences database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to set WAL mode on library preferences: {}", e))?;
conn.execute(
"CREATE TABLE IF NOT EXISTS library_preferences (
id INTEGER PRIMARY KEY CHECK (id = 1),
tab_order TEXT NOT NULL,
hidden_tabs TEXT NOT NULL DEFAULT '[]'
)",
[],
)
.map_err(|e| format!("Failed to create library preferences table: {}", e))?;
// Idempotent migrations for columns added after the initial schema.
// These ALTERs fail when the column already exists; that failure is
// expected and ignored.
let _ = conn.execute(
"ALTER TABLE library_preferences ADD COLUMN folders_view_mode TEXT DEFAULT 'flat'",
[],
);
let _ = conn.execute(
"ALTER TABLE library_preferences ADD COLUMN folders_tree_sidebar_width INTEGER DEFAULT NULL",
[],
);
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
Self::open_at(&data_dir, "library_preferences.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "library_preferences.db")
}
pub fn get_preferences(&self) -> Result<LibraryPreferences, String> {
let mut stmt = self
.conn
.prepare(
"SELECT tab_order, hidden_tabs, folders_view_mode, folders_tree_sidebar_width \
FROM library_preferences WHERE id = 1",
)
.map_err(|e| format!("Failed to prepare select: {}", e))?;
let result = stmt.query_row([], |row| {
let tab_order_str: String = row.get(0)?;
let hidden_tabs_str: String = row.get(1)?;
let folders_view_mode_str: Option<String> = row.get(2)?;
let folders_tree_sidebar_width: Option<i64> = row.get(3)?;
let tab_order: Vec<String> = serde_json::from_str(&tab_order_str)
.unwrap_or_else(|_| LibraryPreferences::default().tab_order);
let hidden_tabs: Vec<String> =
serde_json::from_str(&hidden_tabs_str).unwrap_or_default();
let folders_view_mode = folders_view_mode_str
.map(|s| FoldersViewMode::from_str(&s))
.unwrap_or_default();
// Stored as INTEGER in SQLite; clamp to non-negative on read.
// Frontend re-clamps against the live max anyway.
let folders_tree_sidebar_width = folders_tree_sidebar_width
.filter(|v| *v > 0)
.map(|v| v as u32);
Ok(LibraryPreferences {
tab_order,
hidden_tabs,
folders_view_mode,
folders_tree_sidebar_width,
})
});
match result {
Ok(prefs) => Ok(prefs),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(LibraryPreferences::default()),
Err(e) => Err(format!("Failed to query library preferences: {}", e)),
}
}
pub fn save_preferences(
&self,
prefs: LibraryPreferences,
) -> Result<LibraryPreferences, String> {
let tab_order_str = serde_json::to_string(&prefs.tab_order)
.map_err(|e| format!("Failed to serialize tab_order: {}", e))?;
let hidden_tabs_str = serde_json::to_string(&prefs.hidden_tabs)
.map_err(|e| format!("Failed to serialize hidden_tabs: {}", e))?;
self.conn
.execute(
"INSERT OR REPLACE INTO library_preferences \
(id, tab_order, hidden_tabs, folders_view_mode, folders_tree_sidebar_width) \
VALUES (1, ?1, ?2, ?3, ?4)",
params![
tab_order_str,
hidden_tabs_str,
prefs.folders_view_mode.as_str(),
prefs.folders_tree_sidebar_width.map(|v| v as i64),
],
)
.map_err(|e| format!("Failed to save library preferences: {}", e))?;
Ok(prefs)
}
pub fn set_folders_tree_sidebar_width(&self, width: u32) -> Result<(), String> {
// Same upsert pattern as `set_folders_view_mode`: try the cheap
// UPDATE first; if no row exists yet, fall back to inserting a
// default row carrying just the new width.
let updated = self
.conn
.execute(
"UPDATE library_preferences SET folders_tree_sidebar_width = ?1 WHERE id = 1",
params![width as i64],
)
.map_err(|e| format!("Failed to set folders_tree_sidebar_width: {}", e))?;
if updated == 0 {
let mut prefs = LibraryPreferences::default();
prefs.folders_tree_sidebar_width = Some(width);
self.save_preferences(prefs)?;
}
Ok(())
}
pub fn set_folders_view_mode(&self, mode: FoldersViewMode) -> Result<(), String> {
// Ensure the row exists before updating; if no preferences have been
// saved yet, fall back to inserting a default row carrying the new
// folders_view_mode value.
let updated = self
.conn
.execute(
"UPDATE library_preferences SET folders_view_mode = ?1 WHERE id = 1",
params![mode.as_str()],
)
.map_err(|e| format!("Failed to set folders_view_mode: {}", e))?;
if updated == 0 {
let mut prefs = LibraryPreferences::default();
prefs.folders_view_mode = mode;
self.save_preferences(prefs)?;
}
Ok(())
}
}
pub struct LibraryPreferencesState {
pub store: Arc<Mutex<Option<LibraryPreferencesStore>>>,
}
impl LibraryPreferencesState {
pub fn new() -> Result<Self, String> {
let store = LibraryPreferencesStore::new()?;
Ok(Self {
store: Arc::new(Mutex::new(Some(store))),
})
}
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
let new_store = LibraryPreferencesStore::new_at(base_dir)?;
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock library preferences store".to_string())?;
*guard = Some(new_store);
Ok(())
}
pub fn teardown(&self) -> Result<(), String> {
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock library preferences store".to_string())?;
*guard = None;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn unique_test_dir(name: &str) -> std::path::PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
}
fn fresh_store(name: &str) -> (std::path::PathBuf, LibraryPreferencesStore) {
let dir = unique_test_dir(name);
let store = LibraryPreferencesStore::new_at(&dir).expect("open store in temp dir");
(dir, store)
}
#[test]
fn default_is_flat() {
let prefs = LibraryPreferences::default();
assert_eq!(prefs.folders_view_mode, FoldersViewMode::Flat);
}
#[test]
fn legacy_json_without_folders_view_mode_defaults_to_flat() {
let legacy = r#"{
"tab_order": ["tracks", "folders", "albums", "artists"],
"hidden_tabs": []
}"#;
let prefs: LibraryPreferences = serde_json::from_str(legacy).expect("parse legacy json");
assert_eq!(prefs.folders_view_mode, FoldersViewMode::Flat);
}
#[test]
fn round_trip_with_tree_set() {
let mut prefs = LibraryPreferences::default();
prefs.folders_view_mode = FoldersViewMode::Tree;
let json = serde_json::to_string(&prefs).expect("serialize");
let parsed: LibraryPreferences = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed.folders_view_mode, FoldersViewMode::Tree);
}
#[test]
fn folders_view_mode_str_round_trip() {
assert_eq!(FoldersViewMode::Flat.as_str(), "flat");
assert_eq!(FoldersViewMode::Tree.as_str(), "tree");
assert_eq!(FoldersViewMode::from_str("flat"), FoldersViewMode::Flat);
assert_eq!(FoldersViewMode::from_str("tree"), FoldersViewMode::Tree);
assert_eq!(FoldersViewMode::from_str("garbage"), FoldersViewMode::Flat);
}
#[test]
fn fresh_store_returns_default_folders_view_mode() {
let (dir, store) = fresh_store("library-default-mode");
let prefs = store.get_preferences().expect("get prefs");
assert_eq!(prefs.folders_view_mode, FoldersViewMode::Flat);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn set_folders_view_mode_persists_on_empty_table() {
let (dir, store) = fresh_store("library-mode-empty");
store
.set_folders_view_mode(FoldersViewMode::Tree)
.expect("set mode");
let prefs = store.get_preferences().expect("get prefs");
assert_eq!(prefs.folders_view_mode, FoldersViewMode::Tree);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn set_folders_view_mode_updates_existing_row() {
let (dir, store) = fresh_store("library-mode-update");
// Seed a row with the default save path so the UPDATE branch runs.
let mut seeded = LibraryPreferences::default();
seeded.tab_order = vec!["tracks".into(), "albums".into()];
store.save_preferences(seeded).expect("save seed");
store
.set_folders_view_mode(FoldersViewMode::Tree)
.expect("set mode");
let after = store.get_preferences().expect("get prefs");
assert_eq!(after.folders_view_mode, FoldersViewMode::Tree);
// Sibling fields preserved.
assert_eq!(
after.tab_order,
vec!["tracks".to_string(), "albums".to_string()]
);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn save_preferences_persists_folders_view_mode() {
let (dir, store) = fresh_store("library-save-mode");
let mut prefs = LibraryPreferences::default();
prefs.folders_view_mode = FoldersViewMode::Tree;
store.save_preferences(prefs).expect("save");
let loaded = store.get_preferences().expect("load");
assert_eq!(loaded.folders_view_mode, FoldersViewMode::Tree);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn fresh_store_returns_none_folders_tree_sidebar_width() {
let (dir, store) = fresh_store("library-default-width");
let prefs = store.get_preferences().expect("get prefs");
assert_eq!(prefs.folders_tree_sidebar_width, None);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn set_folders_tree_sidebar_width_persists_on_empty_table() {
let (dir, store) = fresh_store("library-width-empty");
store
.set_folders_tree_sidebar_width(512)
.expect("set width");
let prefs = store.get_preferences().expect("get prefs");
assert_eq!(prefs.folders_tree_sidebar_width, Some(512));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn set_folders_tree_sidebar_width_updates_existing_row() {
let (dir, store) = fresh_store("library-width-update");
let mut seeded = LibraryPreferences::default();
seeded.tab_order = vec!["tracks".into(), "folders".into()];
store.save_preferences(seeded).expect("save seed");
store
.set_folders_tree_sidebar_width(640)
.expect("set width");
let after = store.get_preferences().expect("get prefs");
assert_eq!(after.folders_tree_sidebar_width, Some(640));
assert_eq!(
after.tab_order,
vec!["tracks".to_string(), "folders".to_string()]
);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn legacy_json_without_sidebar_width_defaults_to_none() {
let legacy = r#"{
"tab_order": ["tracks", "folders", "albums", "artists"],
"hidden_tabs": [],
"folders_view_mode": "tree"
}"#;
let prefs: LibraryPreferences = serde_json::from_str(legacy).expect("parse legacy json");
assert_eq!(prefs.folders_tree_sidebar_width, None);
}
}
+11
View File
@@ -0,0 +1,11 @@
pub mod developer;
pub mod download;
pub mod favorites;
pub mod graphics;
pub mod image_cache;
pub mod legal;
pub mod library;
pub mod playback;
pub mod remote_control;
pub mod subscription;
pub mod tray;
+451
View File
@@ -0,0 +1,451 @@
//! Playback and small playback-adjacent UI preferences.
//!
//! `show_context_icon` is persisted here to preserve the existing settings
//! contract, but it is a portable UI preference, not playback domain logic.
use log::info;
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AutoplayMode {
/// Continue playing within the source (album, playlist, etc.)
#[serde(rename = "continue")]
ContinueWithinSource,
/// Play only the selected track, then stop.
#[serde(rename = "track_only")]
PlayTrackOnly,
/// Create infinite radio when queue ends (based on recent tracks).
#[serde(rename = "infinite")]
InfiniteRadio,
}
impl Default for AutoplayMode {
fn default() -> Self {
Self::ContinueWithinSource
}
}
impl AutoplayMode {
fn to_db_value(self) -> &'static str {
match self {
AutoplayMode::ContinueWithinSource => "continue",
AutoplayMode::PlayTrackOnly => "track_only",
AutoplayMode::InfiniteRadio => "infinite",
}
}
fn from_db_value(value: &str) -> Self {
match value {
"track_only" => AutoplayMode::PlayTrackOnly,
"infinite" => AutoplayMode::InfiniteRadio,
_ => AutoplayMode::ContinueWithinSource,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaybackPreferences {
pub autoplay_mode: AutoplayMode,
/// Portable UI preference for showing the context-stack icon.
/// This is not domain playback behavior.
pub show_context_icon: bool,
pub persist_session: bool,
/// Sub-preference of `persist_session`. When true, restoring a
/// session also seeks to `current_position_secs` of the saved
/// track. When false (default), the saved track is shown paused at
/// 0:00 and the user starts the next listen fresh.
pub resume_playback_position: bool,
}
impl Default for PlaybackPreferences {
fn default() -> Self {
Self {
autoplay_mode: AutoplayMode::ContinueWithinSource,
show_context_icon: false,
persist_session: false,
resume_playback_position: false,
}
}
}
pub struct PlaybackPreferencesStore {
conn: Connection,
}
impl PlaybackPreferencesStore {
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open playback preferences database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| {
format!(
"Failed to enable WAL for playback preferences database: {}",
e
)
})?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS playback_preferences (
id INTEGER PRIMARY KEY CHECK (id = 1),
autoplay_mode TEXT NOT NULL DEFAULT 'continue'
);",
)
.map_err(|e| format!("Failed to create playback preferences table: {}", e))?;
let show_context_icon_exists =
column_exists(&conn, "playback_preferences", "show_context_icon");
info!(
"[PlaybackPrefs] Column show_context_icon exists: {}",
show_context_icon_exists
);
if !show_context_icon_exists {
info!("[PlaybackPrefs] Migrating: adding show_context_icon column");
conn.execute(
"ALTER TABLE playback_preferences ADD COLUMN show_context_icon INTEGER NOT NULL DEFAULT 0",
[],
)
.map_err(|e| format!("Failed to add show_context_icon column: {}", e))?;
info!("[PlaybackPrefs] Migration successful");
}
if !column_exists(&conn, "playback_preferences", "persist_session") {
info!("[PlaybackPrefs] Migrating: adding persist_session column");
conn.execute(
"ALTER TABLE playback_preferences ADD COLUMN persist_session INTEGER NOT NULL DEFAULT 0",
[],
)
.map_err(|e| format!("Failed to add persist_session column: {}", e))?;
info!("[PlaybackPrefs] persist_session migration successful");
}
if !column_exists(&conn, "playback_preferences", "resume_playback_position") {
info!("[PlaybackPrefs] Migrating: adding resume_playback_position column");
conn.execute(
"ALTER TABLE playback_preferences ADD COLUMN resume_playback_position INTEGER NOT NULL DEFAULT 0",
[],
)
.map_err(|e| format!("Failed to add resume_playback_position column: {}", e))?;
info!("[PlaybackPrefs] resume_playback_position migration successful");
}
conn.execute(
"INSERT OR IGNORE INTO playback_preferences (id, autoplay_mode, show_context_icon, persist_session, resume_playback_position)
VALUES (1, 'continue', 0, 0, 0)",
[],
)
.map_err(|e| format!("Failed to insert default preferences: {}", e))?;
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
Self::open_at(&data_dir, "playback_preferences.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "playback_preferences.db")
}
pub fn get_preferences(&self) -> Result<PlaybackPreferences, String> {
self.conn
.query_row(
"SELECT autoplay_mode, show_context_icon, persist_session, resume_playback_position FROM playback_preferences WHERE id = 1",
[],
|row| {
let autoplay_str: String = row.get(0)?;
let show_icon: i32 = row.get(1)?;
let persist: i32 = row.get(2)?;
let resume_pos: i32 = row.get(3)?;
Ok(PlaybackPreferences {
autoplay_mode: AutoplayMode::from_db_value(&autoplay_str),
show_context_icon: show_icon != 0,
persist_session: persist != 0,
resume_playback_position: resume_pos != 0,
})
},
)
.map_err(|e| format!("Failed to get playback preferences: {}", e))
}
pub fn set_autoplay_mode(&self, mode: AutoplayMode) -> Result<(), String> {
self.conn
.execute(
"UPDATE playback_preferences SET autoplay_mode = ?1 WHERE id = 1",
params![mode.to_db_value()],
)
.map_err(|e| format!("Failed to set autoplay mode: {}", e))?;
Ok(())
}
pub fn set_show_context_icon(&self, show: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE playback_preferences SET show_context_icon = ?1 WHERE id = 1",
params![if show { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set show context icon: {}", e))?;
Ok(())
}
pub fn set_persist_session(&self, persist: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE playback_preferences SET persist_session = ?1 WHERE id = 1",
params![if persist { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set persist session: {}", e))?;
Ok(())
}
pub fn set_resume_playback_position(&self, resume: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE playback_preferences SET resume_playback_position = ?1 WHERE id = 1",
params![if resume { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set resume playback position: {}", e))?;
Ok(())
}
/// Reset all playback preferences to their default values.
pub fn reset_all(&self) -> Result<PlaybackPreferences, String> {
let defaults = PlaybackPreferences::default();
self.conn
.execute(
"UPDATE playback_preferences SET autoplay_mode = ?1, show_context_icon = ?2, persist_session = ?3, resume_playback_position = ?4 WHERE id = 1",
params![
defaults.autoplay_mode.to_db_value(),
if defaults.show_context_icon { 1 } else { 0 },
if defaults.persist_session { 1 } else { 0 },
if defaults.resume_playback_position { 1 } else { 0 },
],
)
.map_err(|e| format!("Failed to reset playback preferences: {}", e))?;
Ok(defaults)
}
}
pub struct PlaybackPreferencesState {
pub store: Arc<Mutex<Option<PlaybackPreferencesStore>>>,
}
impl PlaybackPreferencesState {
pub fn new() -> Result<Self, String> {
let store = PlaybackPreferencesStore::new()?;
Ok(Self {
store: Arc::new(Mutex::new(Some(store))),
})
}
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
let new_store = PlaybackPreferencesStore::new_at(base_dir)?;
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
*guard = Some(new_store);
Ok(())
}
pub fn teardown(&self) -> Result<(), String> {
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
*guard = None;
Ok(())
}
pub fn get_preferences(&self) -> Result<PlaybackPreferences, String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.get_preferences()
}
pub fn set_autoplay_mode(&self, mode: AutoplayMode) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_autoplay_mode(mode)
}
pub fn set_show_context_icon(&self, show: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_show_context_icon(show)
}
pub fn set_persist_session(&self, persist: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_persist_session(persist)
}
pub fn set_resume_playback_position(&self, resume: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_resume_playback_position(resume)
}
}
fn column_exists(conn: &Connection, table: &str, column: &str) -> bool {
conn.query_row(
&format!("SELECT COUNT(*) FROM pragma_table_info('{table}') WHERE name = ?1"),
[column],
|row| {
let count: i32 = row.get(0)?;
Ok(count > 0)
},
)
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
fn unique_test_dir(name: &str) -> std::path::PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
}
fn fresh_store(name: &str) -> (std::path::PathBuf, PlaybackPreferencesStore) {
let dir = unique_test_dir(name);
let store = PlaybackPreferencesStore::new_at(&dir).expect("open store in temp dir");
(dir, store)
}
#[test]
fn playback_preferences_default_values_are_stable() {
let prefs = PlaybackPreferences::default();
assert_eq!(prefs.autoplay_mode, AutoplayMode::ContinueWithinSource);
assert!(!prefs.show_context_icon);
assert!(!prefs.persist_session);
assert!(!prefs.resume_playback_position);
}
#[test]
fn playback_preferences_store_returns_defaults() {
let (dir, store) = fresh_store("playback-default");
let prefs = store.get_preferences().expect("get prefs");
assert_eq!(prefs.autoplay_mode, AutoplayMode::ContinueWithinSource);
assert!(!prefs.show_context_icon);
assert!(!prefs.persist_session);
assert!(!prefs.resume_playback_position);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn playback_preferences_persist_all_fields() {
let dir = unique_test_dir("playback-persist");
{
let store = PlaybackPreferencesStore::new_at(&dir).expect("open store");
store
.set_autoplay_mode(AutoplayMode::InfiniteRadio)
.expect("set autoplay");
store.set_show_context_icon(true).expect("set context icon");
store
.set_persist_session(true)
.expect("set persist session");
store
.set_resume_playback_position(true)
.expect("set resume position");
}
let reopened = PlaybackPreferencesStore::new_at(&dir).expect("reopen store");
let prefs = reopened.get_preferences().expect("get prefs");
assert_eq!(prefs.autoplay_mode, AutoplayMode::InfiniteRadio);
assert!(prefs.show_context_icon);
assert!(prefs.persist_session);
assert!(prefs.resume_playback_position);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn playback_preferences_migrates_legacy_schema() {
let dir = unique_test_dir("playback-migrate");
std::fs::create_dir_all(&dir).expect("create temp dir");
let db_path = dir.join("playback_preferences.db");
{
let conn = Connection::open(&db_path).expect("open legacy db");
conn.execute_batch(
"CREATE TABLE playback_preferences (
id INTEGER PRIMARY KEY CHECK (id = 1),
autoplay_mode TEXT NOT NULL DEFAULT 'continue'
);
INSERT INTO playback_preferences (id, autoplay_mode) VALUES (1, 'track_only');",
)
.expect("create legacy schema");
}
let store = PlaybackPreferencesStore::new_at(&dir).expect("migrate store");
let prefs = store.get_preferences().expect("get prefs");
assert_eq!(prefs.autoplay_mode, AutoplayMode::PlayTrackOnly);
assert!(!prefs.show_context_icon);
assert!(!prefs.persist_session);
assert!(!prefs.resume_playback_position);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn playback_preferences_reset_all_preserves_existing_behavior() {
let (dir, store) = fresh_store("playback-reset");
store
.set_autoplay_mode(AutoplayMode::InfiniteRadio)
.expect("set autoplay");
store.set_show_context_icon(true).expect("set context icon");
store.set_persist_session(true).expect("set persist");
store
.set_resume_playback_position(true)
.expect("set resume position");
let defaults = store.reset_all().expect("reset prefs");
let prefs = store.get_preferences().expect("get prefs");
assert_eq!(defaults.autoplay_mode, AutoplayMode::ContinueWithinSource);
assert_eq!(prefs.autoplay_mode, AutoplayMode::ContinueWithinSource);
assert!(!prefs.show_context_icon);
assert!(!prefs.persist_session);
assert!(!prefs.resume_playback_position);
let _ = std::fs::remove_dir_all(dir);
}
}
@@ -0,0 +1,729 @@
//! Remote control settings and allowed-origin persistence.
//!
//! This module owns portable storage only. Server startup, TLS certificate
//! management, QR generation, router/CORS application, and live restarts remain
//! host-owned behavior.
use base64::Engine;
use rand::RngExt;
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex};
const DEFAULT_ALLOWED_ORIGINS: &[&str] = &[
"vicrodh.github.io",
"control.qbz.lol",
"www.control.qbz.lol",
];
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RemoteControlSettings {
pub enabled: bool,
pub port: u16,
pub secure: bool,
pub token: String,
}
impl Default for RemoteControlSettings {
fn default() -> Self {
Self {
enabled: false,
port: 8182,
secure: true,
token: String::new(),
}
}
}
pub struct RemoteControlSettingsStore {
conn: Connection,
}
impl RemoteControlSettingsStore {
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open remote control settings database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| {
format!(
"Failed to enable WAL for remote control settings database: {}",
e
)
})?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS remote_control_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
enabled INTEGER NOT NULL DEFAULT 0,
port INTEGER NOT NULL DEFAULT 8182,
secure INTEGER NOT NULL DEFAULT 1,
token TEXT NOT NULL DEFAULT ''
);",
)
.map_err(|e| format!("Failed to create remote control settings table: {}", e))?;
ensure_secure_column(&conn)?;
let token = generate_token();
conn.execute(
"INSERT OR IGNORE INTO remote_control_settings (id, enabled, port, secure, token)
VALUES (1, 0, 8182, 0, ?1)",
params![token],
)
.map_err(|e| format!("Failed to insert default remote control settings: {}", e))?;
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
Self::open_at(&data_dir, "remote_control_settings.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "remote_control_settings.db")
}
pub fn get_settings(&self) -> Result<RemoteControlSettings, String> {
let mut settings = self
.conn
.query_row(
"SELECT enabled, port, secure, token FROM remote_control_settings WHERE id = 1",
[],
|row| {
let enabled: i32 = row.get(0)?;
let port: i64 = row.get(1)?;
let secure: i32 = row.get(2)?;
let token: String = row.get(3)?;
Ok(RemoteControlSettings {
enabled: enabled != 0,
port: port as u16,
secure: secure != 0,
token,
})
},
)
.map_err(|e| format!("Failed to get remote control settings: {}", e))?;
if settings.token.is_empty() {
settings.token = generate_token();
self.set_token(&settings.token)?;
}
Ok(settings)
}
pub fn set_enabled(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE remote_control_settings SET enabled = ?1 WHERE id = 1",
params![if enabled { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set remote control enabled: {}", e))?;
Ok(())
}
pub fn set_port(&self, port: u16) -> Result<(), String> {
self.conn
.execute(
"UPDATE remote_control_settings SET port = ?1 WHERE id = 1",
params![port as i64],
)
.map_err(|e| format!("Failed to set remote control port: {}", e))?;
Ok(())
}
pub fn set_secure(&self, secure: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE remote_control_settings SET secure = ?1 WHERE id = 1",
params![if secure { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set remote control secure: {}", e))?;
Ok(())
}
pub fn set_token(&self, token: &str) -> Result<(), String> {
self.conn
.execute(
"UPDATE remote_control_settings SET token = ?1 WHERE id = 1",
params![token],
)
.map_err(|e| format!("Failed to set remote control token: {}", e))?;
Ok(())
}
pub fn regenerate_token(&self) -> Result<String, String> {
let token = generate_token();
self.set_token(&token)?;
Ok(token)
}
}
pub struct RemoteControlSettingsState {
pub store: Arc<Mutex<Option<RemoteControlSettingsStore>>>,
}
impl RemoteControlSettingsState {
pub fn new() -> Result<Self, String> {
Ok(Self {
store: Arc::new(Mutex::new(Some(RemoteControlSettingsStore::new()?))),
})
}
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
let new_store = RemoteControlSettingsStore::new_at(base_dir)?;
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock remote control settings store".to_string())?;
*guard = Some(new_store);
Ok(())
}
pub fn teardown(&self) -> Result<(), String> {
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock remote control settings store".to_string())?;
*guard = None;
Ok(())
}
pub fn get_settings(&self) -> Result<RemoteControlSettings, String> {
let guard = self
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.get_settings()
}
pub fn set_enabled(&self, enabled: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_enabled(enabled)
}
pub fn set_port(&self, port: u16) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_port(port)
}
pub fn set_secure(&self, secure: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_secure(secure)
}
pub fn regenerate_token(&self) -> Result<String, String> {
let guard = self
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.regenerate_token()
}
}
fn generate_token() -> String {
let mut bytes = [0u8; 32];
rand::rng().fill(&mut bytes);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
fn ensure_secure_column(conn: &Connection) -> Result<(), String> {
let mut stmt = conn
.prepare("PRAGMA table_info(remote_control_settings)")
.map_err(|e| format!("Failed to read settings schema: {}", e))?;
let mut rows = stmt
.query([])
.map_err(|e| format!("Failed to read settings schema: {}", e))?;
while let Some(row) = rows
.next()
.map_err(|e| format!("Schema read error: {}", e))?
{
let name: String = row
.get(1)
.map_err(|e| format!("Schema read error: {}", e))?;
if name == "secure" {
return Ok(());
}
}
conn.execute(
"ALTER TABLE remote_control_settings ADD COLUMN secure INTEGER NOT NULL DEFAULT 0",
[],
)
.map_err(|e| format!("Failed to migrate remote control settings: {}", e))?;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AllowedOrigin {
pub id: i64,
pub origin: String,
pub is_default: bool,
}
pub struct AllowedOriginsStore {
conn: Connection,
}
impl AllowedOriginsStore {
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open allowed origins database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to enable WAL for allowed origins database: {}", e))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS allowed_origins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
origin TEXT NOT NULL UNIQUE,
is_default INTEGER NOT NULL DEFAULT 0
);",
)
.map_err(|e| format!("Failed to create allowed_origins table: {}", e))?;
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM allowed_origins", [], |row| row.get(0))
.unwrap_or(0);
if count == 0 {
for origin in DEFAULT_ALLOWED_ORIGINS {
let _ = conn.execute(
"INSERT OR IGNORE INTO allowed_origins (origin, is_default) VALUES (?1, 1)",
params![origin],
);
}
}
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
Self::open_at(&data_dir, "remote_control_settings.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "remote_control_settings.db")
}
pub fn get_origins(&self) -> Result<Vec<AllowedOrigin>, String> {
let mut stmt = self.conn
.prepare("SELECT id, origin, is_default FROM allowed_origins ORDER BY is_default DESC, origin ASC")
.map_err(|e| format!("Failed to prepare query: {}", e))?;
let origins = stmt
.query_map([], |row| {
Ok(AllowedOrigin {
id: row.get(0)?,
origin: row.get(1)?,
is_default: row.get::<_, i32>(2)? != 0,
})
})
.map_err(|e| format!("Failed to query origins: {}", e))?;
origins
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Failed to collect origins: {}", e))
}
pub fn is_origin_allowed(&self, origin: &str) -> bool {
self.conn
.query_row(
"SELECT 1 FROM allowed_origins WHERE origin = ?1",
params![origin],
|_| Ok(()),
)
.is_ok()
}
pub fn add_origin(&self, origin: &str) -> Result<AllowedOrigin, String> {
let normalized = origin.trim().to_lowercase();
if normalized.is_empty() {
return Err("Origin cannot be empty".to_string());
}
self.conn
.execute(
"INSERT INTO allowed_origins (origin, is_default) VALUES (?1, 0)",
params![normalized],
)
.map_err(|e| {
if e.to_string().contains("UNIQUE constraint") {
"Origin already exists".to_string()
} else {
format!("Failed to add origin: {}", e)
}
})?;
let id = self.conn.last_insert_rowid();
Ok(AllowedOrigin {
id,
origin: normalized,
is_default: false,
})
}
pub fn remove_origin(&self, id: i64) -> Result<(), String> {
let affected = self
.conn
.execute("DELETE FROM allowed_origins WHERE id = ?1", params![id])
.map_err(|e| format!("Failed to remove origin: {}", e))?;
if affected == 0 {
return Err("Origin not found".to_string());
}
Ok(())
}
pub fn restore_defaults(&self) -> Result<(), String> {
for origin in DEFAULT_ALLOWED_ORIGINS {
let _ = self.conn.execute(
"INSERT OR IGNORE INTO allowed_origins (origin, is_default) VALUES (?1, 1)",
params![origin],
);
}
Ok(())
}
}
pub struct AllowedOriginsState {
pub store: Arc<Mutex<Option<AllowedOriginsStore>>>,
}
impl AllowedOriginsState {
pub fn new() -> Result<Self, String> {
Ok(Self {
store: Arc::new(Mutex::new(Some(AllowedOriginsStore::new()?))),
})
}
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
let new_store = AllowedOriginsStore::new_at(base_dir)?;
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock allowed origins store".to_string())?;
*guard = Some(new_store);
Ok(())
}
pub fn teardown(&self) -> Result<(), String> {
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock allowed origins store".to_string())?;
*guard = None;
Ok(())
}
pub fn get_origins(&self) -> Result<Vec<AllowedOrigin>, String> {
let guard = self
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.get_origins()
}
pub fn is_origin_allowed(&self, origin: &str) -> bool {
self.store
.lock()
.map(|guard| {
guard
.as_ref()
.map(|store| store.is_origin_allowed(origin))
.unwrap_or(false)
})
.unwrap_or(false)
}
pub fn add_origin(&self, origin: &str) -> Result<AllowedOrigin, String> {
let guard = self
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.add_origin(origin)
}
pub fn remove_origin(&self, id: i64) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.remove_origin(id)
}
pub fn restore_defaults(&self) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.restore_defaults()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn unique_test_dir(name: &str) -> std::path::PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
}
fn fresh_remote_store(name: &str) -> (std::path::PathBuf, RemoteControlSettingsStore) {
let dir = unique_test_dir(name);
let store = RemoteControlSettingsStore::new_at(&dir).expect("open store in temp dir");
(dir, store)
}
fn fresh_origins_store(name: &str) -> (std::path::PathBuf, AllowedOriginsStore) {
let dir = unique_test_dir(name);
let store = AllowedOriginsStore::new_at(&dir).expect("open origins store in temp dir");
(dir, store)
}
#[test]
fn remote_control_default_struct_values_are_stable() {
let defaults = RemoteControlSettings::default();
assert!(!defaults.enabled);
assert_eq!(defaults.port, 8182);
assert!(defaults.secure);
assert!(defaults.token.is_empty());
}
#[test]
fn remote_control_store_creates_token_and_defaults_internal_remote_to_http() {
let (dir, store) = fresh_remote_store("remote-default");
let settings = store.get_settings().expect("get settings");
assert!(!settings.enabled);
assert_eq!(settings.port, 8182);
assert!(!settings.secure);
assert!(!settings.token.is_empty());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn remote_control_settings_persist_across_reopen() {
let dir = unique_test_dir("remote-persist");
let token;
{
let store = RemoteControlSettingsStore::new_at(&dir).expect("open store");
token = store.get_settings().expect("get settings").token;
store.set_enabled(true).expect("set enabled");
store.set_port(9310).expect("set port");
store.set_secure(true).expect("set secure");
}
let reopened = RemoteControlSettingsStore::new_at(&dir).expect("reopen store");
let settings = reopened.get_settings().expect("get settings");
assert!(settings.enabled);
assert_eq!(settings.port, 9310);
assert!(settings.secure);
assert_eq!(settings.token, token);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn remote_control_regenerate_token_changes_and_persists_token() {
let dir = unique_test_dir("remote-token");
let original;
let regenerated;
{
let store = RemoteControlSettingsStore::new_at(&dir).expect("open store");
original = store.get_settings().expect("get settings").token;
regenerated = store.regenerate_token().expect("regenerate token");
}
let reopened = RemoteControlSettingsStore::new_at(&dir).expect("reopen store");
let settings = reopened.get_settings().expect("get settings");
assert_ne!(original, regenerated);
assert_eq!(settings.token, regenerated);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn remote_control_empty_token_is_repaired_on_read() {
let dir = unique_test_dir("remote-empty-token");
let db_path = dir.join("remote_control_settings.db");
std::fs::create_dir_all(&dir).expect("create temp dir");
{
let conn = Connection::open(&db_path).expect("open db");
conn.execute_batch(
"CREATE TABLE remote_control_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
enabled INTEGER NOT NULL DEFAULT 0,
port INTEGER NOT NULL DEFAULT 8182,
secure INTEGER NOT NULL DEFAULT 1,
token TEXT NOT NULL DEFAULT ''
);
INSERT INTO remote_control_settings (id, enabled, port, secure, token)
VALUES (1, 0, 8182, 0, '');",
)
.expect("create schema");
}
let store = RemoteControlSettingsStore::new_at(&dir).expect("open store");
let settings = store.get_settings().expect("get settings");
assert!(!settings.token.is_empty());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn remote_control_legacy_schema_adds_secure_default_false() {
let dir = unique_test_dir("remote-legacy");
let db_path = dir.join("remote_control_settings.db");
std::fs::create_dir_all(&dir).expect("create temp dir");
{
let conn = Connection::open(&db_path).expect("open legacy db");
conn.execute_batch(
"CREATE TABLE remote_control_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
enabled INTEGER NOT NULL DEFAULT 0,
port INTEGER NOT NULL DEFAULT 8182,
token TEXT NOT NULL DEFAULT ''
);
INSERT INTO remote_control_settings (id, enabled, port, token)
VALUES (1, 1, 8183, 'legacy-token');",
)
.expect("create legacy schema");
}
let store = RemoteControlSettingsStore::new_at(&dir).expect("migrate store");
let settings = store.get_settings().expect("get settings");
assert!(settings.enabled);
assert_eq!(settings.port, 8183);
assert!(!settings.secure);
assert_eq!(settings.token, "legacy-token");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn allowed_origins_store_creates_defaults() {
let (dir, store) = fresh_origins_store("origins-default");
let origins = store.get_origins().expect("get origins");
let names = origins
.iter()
.map(|origin| origin.origin.as_str())
.collect::<Vec<_>>();
assert_eq!(
names,
vec![
"control.qbz.lol",
"vicrodh.github.io",
"www.control.qbz.lol"
]
);
assert!(origins.iter().all(|origin| origin.is_default));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn allowed_origins_add_normalizes_and_remove_works() {
let (dir, store) = fresh_origins_store("origins-add-remove");
let origin = store
.add_origin(" EXAMPLE.com ")
.expect("add custom origin");
assert_eq!(origin.origin, "example.com");
assert!(!origin.is_default);
assert!(store.is_origin_allowed("example.com"));
store.remove_origin(origin.id).expect("remove origin");
assert!(!store.is_origin_allowed("example.com"));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn allowed_origins_rejects_empty_and_duplicate_origins() {
let (dir, store) = fresh_origins_store("origins-invalid");
assert!(store.add_origin(" ").is_err());
store.add_origin("example.com").expect("add origin");
let duplicate = store.add_origin("example.com").expect_err("duplicate");
assert_eq!(duplicate, "Origin already exists");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn allowed_origins_restore_defaults_adds_missing_defaults() {
let (dir, store) = fresh_origins_store("origins-restore");
let origin = store.add_origin("example.com").expect("add origin");
let default_id = store
.get_origins()
.expect("get origins")
.into_iter()
.find(|entry| entry.origin == "control.qbz.lol")
.expect("find default")
.id;
store.remove_origin(default_id).expect("remove default");
store.restore_defaults().expect("restore defaults");
let origins = store.get_origins().expect("get origins");
assert!(origins.iter().any(|entry| entry.origin == "control.qbz.lol"));
assert!(origins.iter().any(|entry| entry.id == origin.id));
let _ = std::fs::remove_dir_all(dir);
}
}
+256
View File
@@ -0,0 +1,256 @@
//! Subscription validity tracking for offline download compliance.
//!
//! Tracks when a user was first observed without a valid subscription. If the
//! invalid state persists for more than the grace period, offline downloads are
//! purged by the host-side session lifecycle.
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex};
/// How long we keep honoring offline access after the server first reports an
/// invalid subscription.
///
/// Qobuz's own mobile app gives a 30-day offline grace window, so QBZ matches
/// that posture for compliance. Shorter windows would punish users on flaky
/// networks; longer windows would be more lenient than the official client.
///
/// The primary protection for offline files is the CMAF-at-rest cache format.
/// This grace period is an additional compliance guard, not the main defense.
const GRACE_PERIOD_SECS: i64 = 30 * 24 * 60 * 60;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubscriptionState {
pub invalid_since: Option<i64>,
pub last_invalid_at: Option<i64>,
pub last_valid_at: Option<i64>,
pub last_checked_at: Option<i64>,
pub downloads_purged_at: Option<i64>,
}
impl Default for SubscriptionState {
fn default() -> Self {
Self {
invalid_since: None,
last_invalid_at: None,
last_valid_at: None,
last_checked_at: None,
downloads_purged_at: None,
}
}
}
pub struct SubscriptionStateStore {
conn: Connection,
}
impl SubscriptionStateStore {
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open subscription state database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| {
format!(
"Failed to enable WAL for subscription state database: {}",
e
)
})?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS subscription_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
invalid_since INTEGER,
last_invalid_at INTEGER,
last_valid_at INTEGER,
last_checked_at INTEGER,
downloads_purged_at INTEGER
);
INSERT OR IGNORE INTO subscription_state (id) VALUES (1);",
)
.map_err(|e| format!("Failed to create subscription state table: {}", e))?;
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
Self::open_at(&data_dir, "subscription_state.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "subscription_state.db")
}
pub fn get_state(&self) -> Result<SubscriptionState, String> {
self.conn
.query_row(
"SELECT invalid_since, last_invalid_at, last_valid_at, last_checked_at, downloads_purged_at
FROM subscription_state WHERE id = 1",
[],
|row| {
Ok(SubscriptionState {
invalid_since: row.get(0)?,
last_invalid_at: row.get(1)?,
last_valid_at: row.get(2)?,
last_checked_at: row.get(3)?,
downloads_purged_at: row.get(4)?,
})
},
)
.map_err(|e| format!("Failed to read subscription state: {}", e))
}
pub fn mark_valid(&self, now: i64) -> Result<(), String> {
self.conn
.execute(
"UPDATE subscription_state
SET invalid_since = NULL,
last_valid_at = ?1,
last_checked_at = ?1
WHERE id = 1",
params![now],
)
.map_err(|e| format!("Failed to update subscription state: {}", e))?;
Ok(())
}
pub fn mark_invalid(&self, now: i64) -> Result<(), String> {
self.conn
.execute(
"UPDATE subscription_state
SET invalid_since = COALESCE(invalid_since, ?1),
last_invalid_at = ?1,
last_checked_at = ?1
WHERE id = 1",
params![now],
)
.map_err(|e| format!("Failed to update subscription state: {}", e))?;
Ok(())
}
pub fn mark_offline_cache_purged(&self, now: i64) -> Result<(), String> {
self.conn
.execute(
"UPDATE subscription_state SET downloads_purged_at = ?1 WHERE id = 1",
params![now],
)
.map_err(|e| format!("Failed to update purge timestamp: {}", e))?;
Ok(())
}
pub fn should_purge_offline_cache(&self, now: i64) -> Result<bool, String> {
let state = self.get_state()?;
let Some(invalid_since) = state.invalid_since else {
return Ok(false);
};
if now - invalid_since < GRACE_PERIOD_SECS {
return Ok(false);
}
if let Some(purged_at) = state.downloads_purged_at {
if purged_at >= invalid_since {
return Ok(false);
}
}
Ok(true)
}
}
pub type SubscriptionStateState = Arc<Mutex<Option<SubscriptionStateStore>>>;
pub fn create_subscription_state() -> Result<SubscriptionStateState, String> {
let store = SubscriptionStateStore::new()?;
Ok(Arc::new(Mutex::new(Some(store))))
}
pub fn create_empty_subscription_state() -> SubscriptionStateState {
Arc::new(Mutex::new(None))
}
#[cfg(test)]
mod tests {
use super::*;
fn unique_test_dir(name: &str) -> std::path::PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
}
#[test]
fn subscription_state_defaults_to_valid_access() {
let dir = unique_test_dir("subscription-default");
let store = SubscriptionStateStore::new_at(&dir).unwrap();
let state = store.get_state().unwrap();
assert!(state.invalid_since.is_none());
assert!(!store.should_purge_offline_cache(0).unwrap());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn subscription_invalid_since_preserves_first_invalid_observation() {
let dir = unique_test_dir("subscription-invalid");
let store = SubscriptionStateStore::new_at(&dir).unwrap();
store.mark_invalid(100).unwrap();
store.mark_invalid(200).unwrap();
let state = store.get_state().unwrap();
assert_eq!(state.invalid_since, Some(100));
assert_eq!(state.last_invalid_at, Some(200));
assert_eq!(state.last_checked_at, Some(200));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn subscription_purge_waits_for_grace_period_and_only_runs_once() {
let dir = unique_test_dir("subscription-purge");
let store = SubscriptionStateStore::new_at(&dir).unwrap();
store.mark_invalid(100).unwrap();
assert!(!store
.should_purge_offline_cache(100 + GRACE_PERIOD_SECS - 1)
.unwrap());
assert!(store
.should_purge_offline_cache(100 + GRACE_PERIOD_SECS)
.unwrap());
store
.mark_offline_cache_purged(100 + GRACE_PERIOD_SECS)
.unwrap();
assert!(!store
.should_purge_offline_cache(100 + GRACE_PERIOD_SECS + 1)
.unwrap());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn subscription_mark_valid_clears_invalid_since() {
let dir = unique_test_dir("subscription-valid");
let store = SubscriptionStateStore::new_at(&dir).unwrap();
store.mark_invalid(100).unwrap();
store.mark_valid(200).unwrap();
let state = store.get_state().unwrap();
assert_eq!(state.invalid_since, None);
assert_eq!(state.last_valid_at, Some(200));
assert_eq!(state.last_checked_at, Some(200));
assert!(!store.should_purge_offline_cache(1000).unwrap());
let _ = std::fs::remove_dir_all(dir);
}
}
+373
View File
@@ -0,0 +1,373 @@
//! Portable tray preference storage.
//!
//! This module owns persisted tray preferences only. Runtime tray creation,
//! icon updates, window hiding/showing, and emitted events remain in the
//! host application layer.
use log::info;
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraySettings {
/// Show system tray icon (requires restart to take effect).
pub enable_tray: bool,
/// Hide window to tray when clicking minimize.
pub minimize_to_tray: bool,
/// Hide window to tray instead of quitting when clicking close.
pub close_to_tray: bool,
/// Tray icon variant override:
/// - "auto" follows system color scheme,
/// - "mono-light" uses a light glyph for dark panels,
/// - "mono-dark" uses a dark glyph for light panels,
/// - "color" uses the full-color vinyl logo.
#[serde(default = "default_tray_icon_theme")]
pub tray_icon_theme: String,
}
fn default_tray_icon_theme() -> String {
"auto".to_string()
}
/// Coerce free-form values to the supported set. Anything outside the
/// supported list falls back to "auto".
///
/// Legacy 1.2.9-pre values "light"/"dark" had inverted semantics
/// relative to the labels users saw. They are remapped here to the value
/// that matches the user's original intent.
pub fn normalize_tray_icon_theme(input: &str) -> String {
match input {
"mono-light" | "mono-dark" | "color" | "auto" => input.to_string(),
"light" => "mono-light".to_string(),
"dark" => "mono-dark".to_string(),
_ => "auto".to_string(),
}
}
impl Default for TraySettings {
fn default() -> Self {
Self {
enable_tray: true,
minimize_to_tray: false,
close_to_tray: false,
tray_icon_theme: default_tray_icon_theme(),
}
}
}
pub struct TraySettingsStore {
conn: Connection,
}
impl TraySettingsStore {
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open tray settings database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to enable WAL for tray settings database: {}", e))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS tray_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
enable_tray INTEGER NOT NULL DEFAULT 1,
minimize_to_tray INTEGER NOT NULL DEFAULT 0,
close_to_tray INTEGER NOT NULL DEFAULT 0
);",
)
.map_err(|e| format!("Failed to create tray settings table: {}", e))?;
let has_theme_column: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('tray_settings') WHERE name = 'tray_icon_theme'",
[],
|row| row.get(0),
)
.map_err(|e| format!("Failed to check tray_icon_theme column: {}", e))?;
if has_theme_column == 0 {
conn.execute_batch(
"ALTER TABLE tray_settings ADD COLUMN tray_icon_theme TEXT NOT NULL DEFAULT 'auto';",
)
.map_err(|e| format!("Failed to add tray_icon_theme column: {}", e))?;
}
conn.execute(
"INSERT OR IGNORE INTO tray_settings (id, enable_tray, minimize_to_tray, close_to_tray, tray_icon_theme)
VALUES (1, 1, 0, 0, 'auto')",
[],
)
.map_err(|e| format!("Failed to insert default tray settings: {}", e))?;
info!("[TraySettings] Database initialized");
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
Self::open_at(&data_dir, "tray_settings.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "tray_settings.db")
}
pub fn get_settings(&self) -> Result<TraySettings, String> {
self.conn
.query_row(
"SELECT enable_tray, minimize_to_tray, close_to_tray, tray_icon_theme FROM tray_settings WHERE id = 1",
[],
|row| {
let enable_tray: i32 = row.get(0)?;
let minimize_to_tray: i32 = row.get(1)?;
let close_to_tray: i32 = row.get(2)?;
let tray_icon_theme: String = row.get(3)?;
Ok(TraySettings {
enable_tray: enable_tray != 0,
minimize_to_tray: minimize_to_tray != 0,
close_to_tray: close_to_tray != 0,
tray_icon_theme: normalize_tray_icon_theme(&tray_icon_theme),
})
},
)
.map_err(|e| format!("Failed to get tray settings: {}", e))
}
pub fn set_enable_tray(&self, value: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE tray_settings SET enable_tray = ?1 WHERE id = 1",
params![if value { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set enable_tray: {}", e))?;
Ok(())
}
pub fn set_minimize_to_tray(&self, value: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE tray_settings SET minimize_to_tray = ?1 WHERE id = 1",
params![if value { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set minimize_to_tray: {}", e))?;
Ok(())
}
pub fn set_close_to_tray(&self, value: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE tray_settings SET close_to_tray = ?1 WHERE id = 1",
params![if value { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set close_to_tray: {}", e))?;
Ok(())
}
pub fn set_tray_icon_theme(&self, value: &str) -> Result<(), String> {
let normalized = normalize_tray_icon_theme(value);
self.conn
.execute(
"UPDATE tray_settings SET tray_icon_theme = ?1 WHERE id = 1",
params![normalized],
)
.map_err(|e| format!("Failed to set tray_icon_theme: {}", e))?;
Ok(())
}
}
pub struct TraySettingsState {
pub store: Arc<Mutex<Option<TraySettingsStore>>>,
}
impl TraySettingsState {
pub fn new() -> Result<Self, String> {
let store = TraySettingsStore::new()?;
Ok(Self {
store: Arc::new(Mutex::new(Some(store))),
})
}
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
let new_store = TraySettingsStore::new_at(base_dir)?;
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
*guard = Some(new_store);
Ok(())
}
pub fn teardown(&self) -> Result<(), String> {
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
*guard = None;
Ok(())
}
pub fn get_settings(&self) -> Result<TraySettings, String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.get_settings()
}
pub fn set_enable_tray(&self, value: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_enable_tray(value)
}
pub fn set_minimize_to_tray(&self, value: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_minimize_to_tray(value)
}
pub fn set_close_to_tray(&self, value: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_close_to_tray(value)
}
pub fn set_tray_icon_theme(&self, value: &str) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_tray_icon_theme(value)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn unique_test_dir(name: &str) -> std::path::PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
}
fn fresh_store(name: &str) -> (std::path::PathBuf, TraySettingsStore) {
let dir = unique_test_dir(name);
let store = TraySettingsStore::new_at(&dir).expect("open store in temp dir");
(dir, store)
}
#[test]
fn tray_settings_default_values_are_stable() {
let settings = TraySettings::default();
assert!(settings.enable_tray);
assert!(!settings.minimize_to_tray);
assert!(!settings.close_to_tray);
assert_eq!(settings.tray_icon_theme, "auto");
}
#[test]
fn tray_settings_store_returns_defaults() {
let (dir, store) = fresh_store("tray-default");
let settings = store.get_settings().expect("get settings");
assert!(settings.enable_tray);
assert!(!settings.minimize_to_tray);
assert!(!settings.close_to_tray);
assert_eq!(settings.tray_icon_theme, "auto");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn tray_settings_persist_all_fields() {
let dir = unique_test_dir("tray-persist");
{
let store = TraySettingsStore::new_at(&dir).expect("open store");
store.set_enable_tray(false).expect("set enable");
store.set_minimize_to_tray(true).expect("set minimize");
store.set_close_to_tray(true).expect("set close");
store
.set_tray_icon_theme("mono-light")
.expect("set icon theme");
}
let reopened = TraySettingsStore::new_at(&dir).expect("reopen store");
let settings = reopened.get_settings().expect("get settings");
assert!(!settings.enable_tray);
assert!(settings.minimize_to_tray);
assert!(settings.close_to_tray);
assert_eq!(settings.tray_icon_theme, "mono-light");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn tray_icon_theme_normalizes_supported_legacy_and_unknown_values() {
assert_eq!(normalize_tray_icon_theme("auto"), "auto");
assert_eq!(normalize_tray_icon_theme("mono-light"), "mono-light");
assert_eq!(normalize_tray_icon_theme("mono-dark"), "mono-dark");
assert_eq!(normalize_tray_icon_theme("color"), "color");
assert_eq!(normalize_tray_icon_theme("light"), "mono-light");
assert_eq!(normalize_tray_icon_theme("dark"), "mono-dark");
assert_eq!(normalize_tray_icon_theme("invalid"), "auto");
}
#[test]
fn tray_settings_migrates_legacy_schema() {
let dir = unique_test_dir("tray-migrate");
std::fs::create_dir_all(&dir).expect("create temp dir");
let db_path = dir.join("tray_settings.db");
{
let conn = Connection::open(&db_path).expect("open legacy db");
conn.execute_batch(
"CREATE TABLE tray_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
enable_tray INTEGER NOT NULL DEFAULT 1,
minimize_to_tray INTEGER NOT NULL DEFAULT 0,
close_to_tray INTEGER NOT NULL DEFAULT 0
);
INSERT INTO tray_settings (id, enable_tray, minimize_to_tray, close_to_tray)
VALUES (1, 0, 1, 1);",
)
.expect("create legacy schema");
}
let store = TraySettingsStore::new_at(&dir).expect("migrate store");
let settings = store.get_settings().expect("get settings");
assert!(!settings.enable_tray);
assert!(settings.minimize_to_tray);
assert!(settings.close_to_tray);
assert_eq!(settings.tray_icon_theme, "auto");
let _ = std::fs::remove_dir_all(dir);
}
}
+185
View File
@@ -0,0 +1,185 @@
//! Per-user data path management.
//!
//! Each Qobuz user gets their own subdirectory under the app's data/cache paths.
//! This module provides the central path provider that host shells use to
//! determine where to store per-user databases and cache files.
use std::path::PathBuf;
use std::sync::RwLock;
/// Central path provider for per-user data isolation.
///
/// Holds the current user_id and provides methods to get user-scoped data and
/// cache directories.
pub struct UserDataPaths {
user_id: RwLock<Option<u64>>,
}
impl UserDataPaths {
pub fn new() -> Self {
Self {
user_id: RwLock::new(None),
}
}
/// Set the current user after login.
pub fn set_user(&self, user_id: u64) {
*self
.user_id
.write()
.expect("UserDataPaths write lock poisoned") = Some(user_id);
log::info!("UserDataPaths: active user set");
}
/// Clear the current user on logout.
pub fn clear_user(&self) {
*self
.user_id
.write()
.expect("UserDataPaths write lock poisoned") = None;
log::info!("UserDataPaths: active user cleared");
}
/// Get the current user ID, if set.
pub fn current_user_id(&self) -> Option<u64> {
*self
.user_id
.read()
.expect("UserDataPaths read lock poisoned")
}
/// Get the user-scoped data directory: ~/.local/share/qbz/users/{uid}/
pub fn user_data_dir(&self) -> Result<PathBuf, String> {
let uid = self
.user_id
.read()
.map_err(|e| format!("UserDataPaths read lock error: {}", e))?
.ok_or("No active user - please log in")?;
let base = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz")
.join("users")
.join(uid.to_string());
Ok(base)
}
/// Get the user-scoped cache directory: ~/.cache/qbz/users/{uid}/
pub fn user_cache_dir(&self) -> Result<PathBuf, String> {
let uid = self
.user_id
.read()
.map_err(|e| format!("UserDataPaths read lock error: {}", e))?
.ok_or("No active user - please log in")?;
let base = dirs::cache_dir()
.ok_or("Could not determine cache directory")?
.join("qbz")
.join("users")
.join(uid.to_string());
Ok(base)
}
/// Get the global (non-user-scoped) data directory: ~/.local/share/qbz/
pub fn global_data_dir() -> Result<PathBuf, String> {
dirs::data_dir()
.ok_or_else(|| "Could not determine data directory".to_string())
.map(|d| d.join("qbz"))
}
/// Get the global (non-user-scoped) cache directory: ~/.cache/qbz/
pub fn global_cache_dir() -> Result<PathBuf, String> {
dirs::cache_dir()
.ok_or_else(|| "Could not determine cache directory".to_string())
.map(|d| d.join("qbz"))
}
/// Save the last active user_id to a flat-path file so the session can be
/// restored on next app launch when remember-me is active.
pub fn save_last_user_id(user_id: u64) -> Result<(), String> {
let path = Self::last_user_id_path()?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create global data directory: {}", e))?;
}
std::fs::write(&path, user_id.to_string())
.map_err(|e| format!("Failed to save last user id: {}", e))?;
log::info!("Saved last_user_id marker");
Ok(())
}
/// Read the last active user_id. Returns None if the file is missing or
/// invalid.
pub fn load_last_user_id() -> Option<u64> {
let path = Self::last_user_id_path().ok()?;
let contents = std::fs::read_to_string(&path).ok()?;
contents.trim().parse::<u64>().ok()
}
/// Clear the last user_id file, called on explicit logout.
pub fn clear_last_user_id() {
if let Ok(path) = Self::last_user_id_path() {
let _ = std::fs::remove_file(&path);
log::info!("Cleared last_user_id file");
}
}
fn last_user_id_path() -> Result<PathBuf, String> {
let dir = Self::global_data_dir()?;
Ok(dir.join("last_user_id"))
}
}
impl Default for UserDataPaths {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn starts_without_active_user() {
let paths = UserDataPaths::new();
assert_eq!(paths.current_user_id(), None);
assert!(paths.user_data_dir().is_err());
assert!(paths.user_cache_dir().is_err());
}
#[test]
fn set_and_clear_user_updates_current_user() {
let paths = UserDataPaths::new();
paths.set_user(10385965);
assert_eq!(paths.current_user_id(), Some(10385965));
paths.clear_user();
assert_eq!(paths.current_user_id(), None);
}
#[test]
fn user_dirs_are_scoped_by_user_id() {
let paths = UserDataPaths::new();
paths.set_user(42);
let data_dir = paths.user_data_dir().expect("data dir");
let cache_dir = paths.user_cache_dir().expect("cache dir");
assert!(data_dir.ends_with("qbz/users/42"));
assert!(cache_dir.ends_with("qbz/users/42"));
}
#[test]
fn global_dirs_are_scoped_to_qbz() {
let data_dir = UserDataPaths::global_data_dir().expect("global data dir");
let cache_dir = UserDataPaths::global_cache_dir().expect("global cache dir");
assert!(data_dir.ends_with("qbz"));
assert!(cache_dir.ends_with("qbz"));
}
}
+111 -33
View File
@@ -488,6 +488,14 @@ impl AudioBackend for CpalDefaultBackend {
#[cfg(not(target_os = "macos"))]
let effective_device_id = config.device_id.as_ref();
#[cfg(target_os = "macos")]
if !config.exclusive_mode {
return self.open_macos_shared_stream_with_retry(
effective_device_id.map(|name| name.as_str()),
MACOS_SHARED_OPEN_MAX_ATTEMPTS,
);
}
let device = if let Some(device_id) = effective_device_id {
self.host
.output_devices()
@@ -504,35 +512,9 @@ impl AudioBackend for CpalDefaultBackend {
.ok_or_else(|| "No default output device found".to_string())?
};
#[cfg(target_os = "macos")]
let shared_mode_override = if !config.exclusive_mode {
Self::shared_mode_nominal_stream_config(
&device,
effective_device_id.map(|name| name.as_str()),
)
} else {
None
};
let builder = DeviceSinkBuilder::from_device(device)
.map_err(|e| format!("Failed to create device sink builder: {}", e))?;
#[cfg(target_os = "macos")]
let mixer_sink = if let Some(override_config) = shared_mode_override {
let buffer_size =
rodio::cpal::BufferSize::Fixed((override_config.sample_rate() / 20).max(64));
builder
.with_supported_config(&override_config)
.with_buffer_size(buffer_size)
.open_stream()
.map_err(|e| format!("Failed to create output stream: {}", e))?
} else {
builder
.open_stream()
.map_err(|e| format!("Failed to create output stream: {}", e))?
};
#[cfg(not(target_os = "macos"))]
let mixer_sink = builder
.open_stream()
.map_err(|e| format!("Failed to create output stream: {}", e))?;
@@ -587,8 +569,110 @@ impl AudioBackend for CpalDefaultBackend {
}
}
#[cfg(target_os = "macos")]
const MACOS_SHARED_OPEN_MAX_ATTEMPTS: usize = 2;
#[cfg(target_os = "macos")]
const MACOS_SHARED_OPEN_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(50);
#[cfg(target_os = "macos")]
impl CpalDefaultBackend {
/// Open a macOS shared-mode CPAL stream and verify it actually opened at
/// CoreAudio's current nominal rate. CPAL caches device configs and can
/// return a stale rate when the OS rate has just changed; passing the
/// stale rate to CPAL produces a stream that runs at the wrong speed.
///
/// Retries up to `max_attempts` times because the mismatch is racy — a
/// fresh resolve + reopen usually lands on the correct rate.
fn open_macos_shared_stream_with_retry(
&self,
effective_device_name: Option<&str>,
max_attempts: usize,
) -> BackendResult<MixerDeviceSink> {
debug_assert!(max_attempts >= 1, "max_attempts must be at least 1");
let mut last_err: Option<String> = None;
for attempt in 0..max_attempts {
let device = if let Some(device_name) = effective_device_name {
self.host
.output_devices()
.map_err(|e| format!("Failed to enumerate devices: {}", e))?
.find(|d| {
d.description()
.map(|desc| desc.name() == device_name)
.unwrap_or(false)
})
.ok_or_else(|| format!("Device '{}' not found", device_name))?
} else {
self.host
.default_output_device()
.ok_or_else(|| "No default output device found".to_string())?
};
let override_config =
Self::shared_mode_nominal_stream_config(&device, effective_device_name);
let builder = DeviceSinkBuilder::from_device(device)
.map_err(|e| format!("Failed to create device sink builder: {}", e))?;
let open_result = if let Some(ref cfg) = override_config {
let buffer_size = rodio::cpal::BufferSize::Fixed((cfg.sample_rate() / 20).max(64));
builder
.with_supported_config(cfg)
.with_buffer_size(buffer_size)
.open_stream()
} else {
builder.open_stream()
};
let mixer_sink = match open_result {
Ok(s) => s,
Err(e) => {
last_err = Some(format!("Failed to create output stream: {}", e));
break;
}
};
let nominal_rate = Self::current_macos_nominal_rate(effective_device_name);
let output_rate = mixer_sink.config().sample_rate().get();
match nominal_rate {
Some(nominal) if nominal != output_rate => {
drop(mixer_sink);
if attempt + 1 < max_attempts {
log::warn!(
"[CoreAudio] System audio rate changed during stream open (stream {}Hz, device now {}Hz). Retrying open ({}/{})",
output_rate,
nominal,
attempt + 2,
max_attempts
);
std::thread::sleep(MACOS_SHARED_OPEN_RETRY_DELAY);
}
last_err = Some(format!(
"macOS audio device changed its sample rate during stream open (opened at {}Hz, device is now {}Hz). This usually self-corrects — try playing again.",
output_rate, nominal
));
continue;
}
_ => return Ok(mixer_sink),
}
}
Err(last_err.unwrap_or_else(|| {
"Could not open the macOS audio output stream after multiple attempts. Try selecting the device again or restarting playback.".to_string()
}))
}
fn current_macos_nominal_rate(effective_device_name: Option<&str>) -> Option<u32> {
use crate::coreaudio_direct;
let device_id = match effective_device_name {
Some(name) => coreaudio_direct::find_device_by_name(name).ok().flatten(),
None => coreaudio_direct::get_default_output_device().ok(),
}?;
coreaudio_direct::get_nominal_sample_rate(device_id).ok()
}
/// In macOS shared mode, CPAL's default config can briefly report a stale
/// sample rate after CoreAudio changes the device's nominal rate. If we
/// trust the stale rate, playback can run at the wrong speed until the
@@ -598,13 +682,7 @@ impl CpalDefaultBackend {
device: &rodio::cpal::Device,
effective_device_name: Option<&str>,
) -> Option<rodio::cpal::SupportedStreamConfig> {
use crate::coreaudio_direct;
let device_id = match effective_device_name {
Some(name) => coreaudio_direct::find_device_by_name(name).ok().flatten(),
None => coreaudio_direct::get_default_output_device().ok(),
}?;
let nominal_rate = coreaudio_direct::get_nominal_sample_rate(device_id).ok()?;
let nominal_rate = Self::current_macos_nominal_rate(effective_device_name)?;
let default_config = device.default_output_config().ok()?;
let default_rate = default_config.sample_rate();
if nominal_rate == default_rate {
+402 -9
View File
@@ -44,17 +44,33 @@ pub struct AudioSettings {
/// Target loudness in LUFS for normalization.
/// Common values: -14.0 (Spotify/YouTube), -18.0 (audiophile), -23.0 (EBU broadcast)
pub normalization_target_lufs: f32,
/// When true, tracks with the same format are cross-faded seamlessly via Rodio Sink queueing.
/// Only works with cached tracks on Rodio backend (not ALSA Direct or streaming).
/// When true, consecutive same-format tracks play without gap.
/// Works on Rodio (PipeWire/Pulse) and ALSA Direct backends. Requires cached tracks.
pub gapless_enabled: bool,
/// When true, force PipeWire clock.force-quantum alongside clock.force-rate for bit-perfect.
/// Reset both to 0 on stop. PipeWire-only, requires dac_passthrough.
pub pw_force_bitperfect: bool,
/// When true, reload audio settings from DB into the player on app startup.
/// Useful when Player::new() may hold stale settings (e.g., after Flatpak updates).
/// Default: false (most users don't need this).
pub sync_audio_on_startup: bool,
/// User preference for what happens when all quality retries fail.
/// Values: "ask" (default), "always_fallback", "always_skip"
/// Protected by ADR-003: must survive reset_all() and migrations.
pub quality_fallback_behavior: String,
/// When true, skip `pactl set-default-sink` on stream creation.
/// Preserves external routing (JACK, qjackctl, Reaper).
/// Mutually exclusive with dac_passthrough.
pub skip_sink_switch: bool,
/// When true, automatically try lower quality tiers if the requested one fails.
/// When false (default), playback or download fails if the exact quality is unavailable.
pub allow_quality_fallback: bool,
/// When true, hold a per-process ALSA device reservation (Lifetime B) for the
/// configured output device while QBZ is running, so other PulseAudio/PipeWire
/// clients won't grab the DAC and break exclusive playback. Off by default.
/// See `qbz-nix-docs/specs/2026-05-07-alsa-exclusive-hardening-design.md`.
#[serde(default)]
pub reserve_dac_while_running: bool,
}
impl Default for AudioSettings {
@@ -64,7 +80,15 @@ impl Default for AudioSettings {
exclusive_mode: false,
dac_passthrough: false,
preferred_sample_rate: None,
backend_type: None, // Auto-detect (PipeWire if available, else ALSA)
// Default to a per-OS-correct backend (PipeWire on Linux, SystemDefault
// elsewhere) instead of None. None used to mean "auto-detect" but it
// actually fell through to the legacy CPAL path in init_device, which
// has a known race against rodio's DeviceSink drop on resume — visible
// as "audio doesn't come back after pause" (#375). Picking a concrete
// backend here puts new installs on the proper backend init path; the
// runtime still degrades gracefully if the chosen backend isn't
// available on the host.
backend_type: Some(AudioBackendType::default()),
alsa_plugin: Some(AlsaPlugin::Hw), // Default to hw (bit-perfect)
alsa_hardware_volume: false, // Disabled by default (maximum compatibility)
stream_first_track: false, // Disabled by default — user opts in
@@ -75,10 +99,13 @@ impl Default for AudioSettings {
device_sample_rate_limits: HashMap::new(), // Per-device limits (empty = no limit)
normalization_enabled: false, // Off by default — preserves bit-perfect pipeline
normalization_target_lufs: -14.0, // Spotify/YouTube standard
gapless_enabled: false, // Off by default — user opts in
gapless_enabled: true, // On by default — works for same-format tracks on all backends
pw_force_bitperfect: false, // Off by default — experimental PipeWire feature
sync_audio_on_startup: false, // Off by default — opt-in for stale-settings edge case
quality_fallback_behavior: "ask".to_string(),
skip_sink_switch: false, // Off by default — only for JACK/DAW routing setups
allow_quality_fallback: false, // Off by default — fail rather than silently downgrade
reserve_dac_while_running: false, // Off by default — opt-in DAC reservation (Lifetime B)
}
}
}
@@ -167,6 +194,14 @@ impl AudioSettingsStore {
"ALTER TABLE audio_settings ADD COLUMN pw_force_bitperfect INTEGER DEFAULT 0",
[],
);
let _ = conn.execute(
"ALTER TABLE audio_settings ADD COLUMN sync_audio_on_startup INTEGER DEFAULT 0",
[],
);
let _ = conn.execute(
"ALTER TABLE audio_settings ADD COLUMN quality_fallback_behavior TEXT DEFAULT 'ask'",
[],
);
let _ = conn.execute(
"ALTER TABLE audio_settings ADD COLUMN skip_sink_switch INTEGER DEFAULT 0",
[],
@@ -175,6 +210,34 @@ impl AudioSettingsStore {
"ALTER TABLE audio_settings ADD COLUMN allow_quality_fallback INTEGER DEFAULT 0",
[],
);
let _ = conn.execute(
"ALTER TABLE audio_settings ADD COLUMN reserve_dac_while_running INTEGER DEFAULT 0",
[],
);
// Migration (#375): existing installs persisted backend_type = NULL when
// the user never explicitly picked a backend. NULL fell through to the
// legacy CPAL path in the player, which has a race against rodio's
// DeviceSink drop on resume that produces silent streams after pause.
// Backfill NULL rows with the per-OS default so those installs move onto
// the proper backend init path. set_backend_type is the canonical writer
// (it serialises via serde_json), so we go through the same JSON shape
// here for the row read path to round-trip cleanly.
let default_backend_json = serde_json::to_string(&AudioBackendType::default())
.unwrap_or_else(|_| {
// Fallback only matters if serde_json::to_string fails for a
// unit-variant enum, which it can't — but keep a sane string in
// case the enum gains a non-trivial variant in the future.
if cfg!(target_os = "linux") {
"\"PipeWire\"".to_string()
} else {
"\"SystemDefault\"".to_string()
}
});
let _ = conn.execute(
"UPDATE audio_settings SET backend_type = ?1 WHERE id = 1 AND backend_type IS NULL",
params![default_backend_json],
);
Ok(Self { conn })
}
@@ -193,7 +256,7 @@ impl AudioSettingsStore {
pub fn get_settings(&self) -> Result<AudioSettings, String> {
self.conn
.query_row(
"SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, skip_sink_switch, allow_quality_fallback FROM audio_settings WHERE id = 1",
"SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, sync_audio_on_startup, quality_fallback_behavior, skip_sink_switch, allow_quality_fallback, reserve_dac_while_running FROM audio_settings WHERE id = 1",
[],
|row| {
// Parse backend_type from JSON string
@@ -230,8 +293,16 @@ impl AudioSettingsStore {
normalization_target_lufs: row.get::<_, Option<f64>>(13)?.unwrap_or(-14.0) as f32,
gapless_enabled: row.get::<_, Option<i64>>(14)?.unwrap_or(0) != 0,
pw_force_bitperfect: row.get::<_, Option<i64>>(16)?.unwrap_or(0) != 0,
skip_sink_switch: row.get::<_, Option<i64>>(17)?.unwrap_or(0) != 0,
allow_quality_fallback: row.get::<_, Option<i64>>(18)?.unwrap_or(0) != 0,
sync_audio_on_startup: row.get::<_, Option<i64>>(17)?.unwrap_or(0) != 0,
quality_fallback_behavior: row
.get::<_, Option<String>>(18)?
.unwrap_or_else(|| "ask".to_string()),
skip_sink_switch: row.get::<_, Option<i64>>(19)?.unwrap_or(0) != 0,
allow_quality_fallback: row.get::<_, Option<i64>>(20)?.unwrap_or(0) != 0,
reserve_dac_while_running: row
.get::<_, Option<i64>>(21)?
.unwrap_or(0)
!= 0,
})
},
)
@@ -290,6 +361,25 @@ impl AudioSettingsStore {
params![backend_json],
)
.map_err(|e| format!("Failed to set backend type: {}", e))?;
// When switching to ALSA, ensure alsa_plugin has a value (default to Hw)
if backend == Some(AudioBackendType::Alsa) {
let current_plugin: Option<String> = self
.conn
.query_row(
"SELECT alsa_plugin FROM audio_settings WHERE id = 1",
[],
|row| row.get(0),
)
.map_err(|e| format!("Failed to check alsa_plugin: {}", e))?;
if current_plugin.is_none() {
log::info!(
"[AudioSettings] ALSA backend selected with no plugin set, defaulting to Hw"
);
self.set_alsa_plugin(Some(AlsaPlugin::Hw))?;
}
}
Ok(())
}
@@ -445,6 +535,40 @@ impl AudioSettingsStore {
Ok(())
}
pub fn set_allow_quality_fallback(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE audio_settings SET allow_quality_fallback = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set allow_quality_fallback: {}", e))?;
Ok(())
}
pub fn set_skip_sink_switch(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE audio_settings SET skip_sink_switch = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set skip_sink_switch: {}", e))?;
Ok(())
}
/// Persist the `reserve_dac_while_running` flag (Lifetime B from the
/// ALSA exclusive-hardening design spec). Toggling this only updates
/// the DB row; applying the change to the live `DeviceReservation`
/// guard is the caller's responsibility.
pub fn set_reserve_dac_while_running(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE audio_settings SET reserve_dac_while_running = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set reserve_dac_while_running: {}", e))?;
Ok(())
}
pub fn set_pw_force_bitperfect(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
@@ -455,6 +579,39 @@ impl AudioSettingsStore {
Ok(())
}
pub fn set_sync_audio_on_startup(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE audio_settings SET sync_audio_on_startup = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set sync_audio_on_startup: {}", e))?;
Ok(())
}
pub fn get_quality_fallback_behavior(&self) -> Result<String, String> {
let settings = self.get_settings()?;
let value = &settings.quality_fallback_behavior;
match value.as_str() {
"ask" | "always_fallback" | "always_skip" => Ok(value.clone()),
_ => Ok("ask".to_string()),
}
}
pub fn set_quality_fallback_behavior(&self, behavior: &str) -> Result<(), String> {
match behavior {
"ask" | "always_fallback" | "always_skip" => {}
_ => return Err(format!("Invalid quality_fallback_behavior: {}", behavior)),
}
self.conn
.execute(
"UPDATE audio_settings SET quality_fallback_behavior = ?1 WHERE id = 1",
params![behavior],
)
.map_err(|e| format!("Failed to set quality_fallback_behavior: {}", e))?;
Ok(())
}
pub fn set_normalization_target_lufs(&self, target: f32) -> Result<(), String> {
self.conn
.execute(
@@ -467,6 +624,11 @@ impl AudioSettingsStore {
/// Reset all audio settings to their default values
pub fn reset_all(&self) -> Result<AudioSettings, String> {
// ADR-003: quality_fallback_behavior must survive reset_all()
let saved_fallback = self
.get_quality_fallback_behavior()
.unwrap_or_else(|_| "ask".to_string());
let defaults = AudioSettings::default();
let backend_json: Option<String> = defaults
.backend_type
@@ -502,7 +664,11 @@ impl AudioSettingsStore {
normalization_target_lufs = ?14,
gapless_enabled = ?15,
device_sample_rate_limits = ?16,
pw_force_bitperfect = ?17
pw_force_bitperfect = ?17,
sync_audio_on_startup = ?18,
skip_sink_switch = ?19,
allow_quality_fallback = ?20,
reserve_dac_while_running = ?21
WHERE id = 1",
params![
defaults.output_device,
@@ -522,11 +688,30 @@ impl AudioSettingsStore {
defaults.gapless_enabled as i64,
limits_json,
defaults.pw_force_bitperfect as i64,
defaults.sync_audio_on_startup as i64,
defaults.skip_sink_switch as i64,
defaults.allow_quality_fallback as i64,
defaults.reserve_dac_while_running as i64,
],
)
.map_err(|e| format!("Failed to reset audio settings: {}", e))?;
Ok(defaults)
// ADR-003: restore quality_fallback_behavior after reset (it is not an audio config)
self.conn
.execute(
"UPDATE audio_settings SET quality_fallback_behavior = ?1 WHERE id = 1",
params![saved_fallback],
)
.map_err(|e| {
format!(
"Failed to restore quality_fallback_behavior after reset: {}",
e
)
})?;
let mut result = defaults;
result.quality_fallback_behavior = saved_fallback;
Ok(result)
}
}
@@ -573,3 +758,211 @@ impl Default for AudioSettingsState {
Self::new_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn unique_test_dir(name: &str) -> std::path::PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("qbz-audio-{name}-{}-{nonce}", std::process::id()))
}
fn fresh_store(name: &str) -> (std::path::PathBuf, AudioSettingsStore) {
let dir = unique_test_dir(name);
let store = AudioSettingsStore::new_at(&dir).expect("open store in temp dir");
(dir, store)
}
#[test]
fn audio_settings_default_values_are_stable() {
let settings = AudioSettings::default();
assert_eq!(settings.backend_type, Some(AudioBackendType::default()));
assert_eq!(settings.alsa_plugin, Some(AlsaPlugin::Hw));
assert!(settings.gapless_enabled);
assert!(!settings.sync_audio_on_startup);
assert_eq!(settings.quality_fallback_behavior, "ask");
assert!(!settings.skip_sink_switch);
assert!(!settings.allow_quality_fallback);
assert!(!settings.reserve_dac_while_running);
}
#[test]
fn audio_settings_store_returns_current_defaults() {
let (dir, store) = fresh_store("defaults");
let settings = store.get_settings().expect("get settings");
assert_eq!(settings.backend_type, Some(AudioBackendType::default()));
assert_eq!(settings.alsa_plugin, None);
assert!(!settings.gapless_enabled);
assert_eq!(settings.quality_fallback_behavior, "ask");
assert!(!settings.reserve_dac_while_running);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn backend_null_is_backfilled_to_default_backend() {
let dir = unique_test_dir("backend-backfill");
{
let store = AudioSettingsStore::new_at(&dir).expect("open store");
store
.conn
.execute(
"UPDATE audio_settings SET backend_type = NULL WHERE id = 1",
[],
)
.expect("force legacy null backend");
}
let reopened = AudioSettingsStore::new_at(&dir).expect("reopen store");
let settings = reopened.get_settings().expect("get settings");
assert_eq!(settings.backend_type, Some(AudioBackendType::default()));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn switching_to_alsa_sets_default_plugin_when_missing() {
let (dir, store) = fresh_store("alsa-plugin-default");
store.set_alsa_plugin(None).expect("clear alsa plugin");
store
.set_backend_type(Some(AudioBackendType::Alsa))
.expect("set backend");
let settings = store.get_settings().expect("get settings");
assert_eq!(settings.backend_type, Some(AudioBackendType::Alsa));
assert_eq!(settings.alsa_plugin, Some(AlsaPlugin::Hw));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn stream_buffer_seconds_clamps_to_valid_range() {
let (dir, store) = fresh_store("stream-buffer-clamp");
store
.set_stream_buffer_seconds(0)
.expect("set low buffer");
assert_eq!(
store.get_settings().expect("get settings").stream_buffer_seconds,
1
);
store
.set_stream_buffer_seconds(99)
.expect("set high buffer");
assert_eq!(
store.get_settings().expect("get settings").stream_buffer_seconds,
10
);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn quality_fallback_invalid_value_reads_as_ask() {
let (dir, store) = fresh_store("quality-invalid");
store
.conn
.execute(
"UPDATE audio_settings SET quality_fallback_behavior = 'bad-value' WHERE id = 1",
[],
)
.expect("write invalid fallback behavior");
assert_eq!(
store
.get_quality_fallback_behavior()
.expect("get quality fallback"),
"ask"
);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn reset_all_preserves_quality_fallback_behavior() {
let (dir, store) = fresh_store("reset-preserves-quality");
store
.set_quality_fallback_behavior("always_skip")
.expect("set quality fallback");
store.set_output_device(Some("hw:4,0")).expect("set device");
store.set_dac_passthrough(true).expect("set dac");
let reset = store.reset_all().expect("reset settings");
let settings = store.get_settings().expect("get settings");
assert_eq!(reset.quality_fallback_behavior, "always_skip");
assert_eq!(settings.quality_fallback_behavior, "always_skip");
assert_eq!(settings.output_device, None);
assert!(!settings.dac_passthrough);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn settings_persist_and_reopen_all_new_fields() {
let dir = unique_test_dir("persist-new-fields");
{
let store = AudioSettingsStore::new_at(&dir).expect("open store");
store
.set_sync_audio_on_startup(true)
.expect("set sync flag");
store
.set_quality_fallback_behavior("always_fallback")
.expect("set quality fallback");
store
.set_reserve_dac_while_running(true)
.expect("set reserve flag");
store
.set_allow_quality_fallback(true)
.expect("set allow fallback");
store
.set_skip_sink_switch(true)
.expect("set skip sink switch");
}
let reopened = AudioSettingsStore::new_at(&dir).expect("reopen store");
let settings = reopened.get_settings().expect("get settings");
assert!(settings.sync_audio_on_startup);
assert_eq!(settings.quality_fallback_behavior, "always_fallback");
assert!(settings.reserve_dac_while_running);
assert!(settings.allow_quality_fallback);
assert!(settings.skip_sink_switch);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn deserializes_legacy_json_without_reserve_dac_field() {
let legacy = r#"{
"output_device": null,
"exclusive_mode": false,
"dac_passthrough": false,
"preferred_sample_rate": null,
"backend_type": null,
"alsa_plugin": null,
"alsa_hardware_volume": false,
"stream_first_track": false,
"stream_buffer_seconds": 3,
"streaming_only": false,
"limit_quality_to_device": false,
"device_max_sample_rate": null,
"normalization_enabled": false,
"normalization_target_lufs": -14.0,
"gapless_enabled": true,
"pw_force_bitperfect": false,
"sync_audio_on_startup": false,
"quality_fallback_behavior": "ask",
"skip_sink_switch": false,
"allow_quality_fallback": false
}"#;
let settings: AudioSettings =
serde_json::from_str(legacy).expect("legacy JSON should deserialize");
assert!(!settings.reserve_dac_while_running);
}
}
+6 -1
View File
@@ -216,7 +216,12 @@ impl Drop for ChromecastHandle {
}
}
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(25);
// Google Cast drops a control connection that goes roughly 10s without a
// heartbeat PING. Ping every 5s — the cadence the protocol expects — so a
// cast connection left idle between connect and load (e.g. while a track
// downloads) is not closed by the receiver. A 25s interval left idle
// connections dead, surfacing as EPIPE on the next LOAD (issue #439).
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
/// Main loop for the Chromecast thread
fn chromecast_thread_main(receiver: Receiver<CastCommand>) {
+41 -7
View File
@@ -53,26 +53,60 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
/// Initialize the core
///
/// This should be called once at startup to set up all subsystems.
/// This extracts Qobuz bundle tokens - user must still call login() to authenticate.
/// Best-effort: if bundle token extraction fails (e.g. no network), the
/// core still finishes initialization so queue manager and player remain
/// usable for offline/local playback. API calls will then return
/// `CoreError::NotInitialized` until the client is rebuilt with tokens.
pub async fn init(&self) -> Result<(), CoreError> {
let mut initialized = self.initialized.write().await;
if *initialized {
return Ok(());
}
// Initialize Qobuz client
let client = QobuzClient::new().map_err(|e| CoreError::Internal(e.to_string()))?;
// Extract bundle tokens (required before any API calls)
match client.init().await {
Ok(_) => {
*self.client.write().await = Some(client);
log::info!("QbzCore initialized with bundle tokens");
}
Err(e) => {
log::warn!(
"QbzCore: bundle token extraction failed ({}). Starting in offline-tolerant mode; API calls will be unavailable until next online start.",
e
);
}
}
*initialized = true;
Ok(())
}
/// Whether the Qobuz API client is initialized (bundle tokens extracted).
/// Returns false when the core is running in offline-tolerant mode after
/// a failed bundle extraction at startup.
pub async fn is_api_initialized(&self) -> bool {
self.client.read().await.is_some()
}
/// Best-effort attempt to rebuild the Qobuz API client. Useful when the
/// initial `init()` ran offline and the host has since regained network.
/// No-op when the client is already initialized.
pub async fn try_init_api(&self) -> Result<(), CoreError> {
{
let guard = self.client.read().await;
if guard.is_some() {
return Ok(());
}
}
let client = QobuzClient::new().map_err(|e| CoreError::Internal(e.to_string()))?;
client
.init()
.await
.map_err(|e| CoreError::Internal(format!("Failed to extract bundle tokens: {}", e)))?;
*self.client.write().await = Some(client);
*initialized = true;
log::info!("QbzCore initialized with bundle tokens");
log::info!("QbzCore: API client initialized lazily");
Ok(())
}
+454 -1
View File
@@ -1789,7 +1789,17 @@ impl LibraryDatabase {
WITH grouped AS (
SELECT
{group_key} AS group_key,
COALESCE(album_group_title, album, 'Unknown Album') AS title,
-- Prefer `album` (metadata tag) over
-- `album_group_title` (scan-time snapshot, which
-- falls back to folder name if metadata is
-- missing). Fixes #411 — when album metadata is
-- valid, the folder name was winning because
-- COALESCE returned `album_group_title` first.
COALESCE(
NULLIF(NULLIF(TRIM(album), ''), 'Unknown Album'),
album_group_title,
'Unknown Album'
) AS title,
COALESCE(album_artist, artist, 'Unknown Artist') AS artist,
year,
catalog_number,
@@ -1879,6 +1889,449 @@ impl LibraryDatabase {
Ok(albums)
}
/// Paginated, sort/filter-aware slice of metadata-grouped local
/// albums. Designed to back the chunked-store + recycling-grid pool
/// on the frontend: caller asks for `[offset, offset+limit)` and
/// receives those rows plus the total count of rows matching the
/// same filter (via `COUNT(*) OVER ()`).
///
/// Sort: one of `"artist"` (default), `"title"`, `"year"`, paired
/// with direction `"asc"` (default) or `"desc"`. Unknown values
/// fall back to artist-ascending. Albums with no `year` always sink
/// to the bottom for the year sort.
///
/// Search: a non-empty `search` becomes a `LIKE '%pattern%'` match
/// applied after aggregation against the album's title or artist
/// (mirrors the legacy in-memory `matchesAlbumSearchFast`).
///
/// Source consolidation: when `plex_cache_path` is provided and
/// points to an existing file, the function `ATTACH`es that
/// database and unions plex_cache_tracks (aggregated by album_key)
/// with the local aggregation. Sort, filter, and pagination apply
/// to the union as a single result set, so a Plex-dominant library
/// behaves identically to a local-dominant one.
pub fn get_albums_metadata_page(
&self,
offset: u64,
limit: u64,
search: Option<&str>,
sort_by: &str,
sort_dir: &str,
include_qobuz_downloads: bool,
exclude_network_folders: bool,
plex_cache_path: Option<&std::path::Path>,
) -> Result<crate::models::AlbumsMetadataPage, LibraryError> {
// Best-effort ATTACH of the Plex cache so the union below can
// see `plex_cache.plex_cache_tracks`. DETACH first defensively
// so a stale attachment from a previous call (or another
// connection user) doesn't fail the new one. Failure to
// attach is non-fatal — we fall back to local-only.
let plex_attached = if let Some(path) = plex_cache_path {
if path.exists() {
let _ = self.conn.execute("DETACH DATABASE plex_cache", []);
let path_str = path.to_string_lossy().replace('\'', "''");
self.conn
.execute(
&format!("ATTACH DATABASE '{}' AS plex_cache", path_str),
[],
)
.is_ok()
} else {
false
}
} else {
false
};
let result = self.get_albums_metadata_page_inner(
offset,
limit,
search,
sort_by,
sort_dir,
include_qobuz_downloads,
exclude_network_folders,
plex_attached,
);
if plex_attached {
let _ = self.conn.execute("DETACH DATABASE plex_cache", []);
}
result
}
fn get_albums_metadata_page_inner(
&self,
offset: u64,
limit: u64,
search: Option<&str>,
sort_by: &str,
sort_dir: &str,
include_qobuz_downloads: bool,
exclude_network_folders: bool,
plex_attached: bool,
) -> Result<crate::models::AlbumsMetadataPage, LibraryError> {
let source_filter = if include_qobuz_downloads {
""
} else {
"AND (source IS NULL OR source != 'qobuz_download')"
};
let network_filter = if exclude_network_folders {
"AND NOT EXISTS (
SELECT 1 FROM library_folders nf
WHERE nf.is_network = 1
AND local_tracks.file_path LIKE nf.path || '%'
)"
} else {
""
};
// ORDER BY clause is built from a validated allowlist so user
// input never reaches the SQL string directly. The unknown-album
// sentinel always sorts last regardless of mode.
let order_clause = match (sort_by, sort_dir) {
("title", "asc") => "(group_key = '__unknown_album__'), title COLLATE NOCASE",
("title", "desc") => "(group_key = '__unknown_album__'), title COLLATE NOCASE DESC",
("year", "asc") => "(group_key = '__unknown_album__'), year IS NULL, year ASC, title COLLATE NOCASE",
("year", "desc") => "(group_key = '__unknown_album__'), year IS NULL, year DESC, title COLLATE NOCASE",
("artist", "desc") => "(group_key = '__unknown_album__'), artist COLLATE NOCASE DESC, title COLLATE NOCASE",
// Default = artist asc
_ => "(group_key = '__unknown_album__'), artist COLLATE NOCASE, title COLLATE NOCASE",
};
let group_key_expr = crate::album_grouping::metadata_group_key_sql_expression();
let search_pattern = search.unwrap_or("").trim();
let has_search: i64 = if search_pattern.is_empty() { 0 } else { 1 };
let search_like = format!("%{}%", search_pattern);
// When Plex is attached, the plex_aggregated CTE is appended
// and the filtered set is built from the UNION of local + plex.
// Both CTEs produce the same column shape so the UNION ALL is
// straightforward; types are normalised via CAST in the plex
// arm (plex stores duration_ms / sampling_rate_hz as INTEGER
// while local uses REAL for sample_rate and seconds-INTEGER
// for duration).
let plex_cte = if plex_attached {
r#",
plex_aggregated AS (
SELECT
-- `album_key` is populated by plex/mod.rs::plex_album_key()
-- which already returns `"plex:<hash>"`. Only the
-- rating_key fallback needs the prefix added.
COALESCE(album_key, 'plex:' || rating_key) AS group_key,
COALESCE(album, 'Unknown Album') AS title,
CASE WHEN COUNT(DISTINCT artist) > 1
THEN 'Various Artists'
ELSE COALESCE(MIN(artist), 'Unknown Artist')
END AS artist,
GROUP_CONCAT(DISTINCT artist) AS all_artists,
MIN(year) AS year,
CAST(NULL AS TEXT) AS catalog_number,
MAX(CASE WHEN artwork_path IS NOT NULL THEN artwork_path END) AS artwork,
COUNT(*) AS track_count,
CAST(SUM(COALESCE(duration_ms, 0)) / 1000 AS INTEGER) AS total_duration,
-- Plex stream-level `codec` is often missing when the
-- server hasn't fully analyzed a track. The `container`
-- field is populated for the same media and usually
-- carries the same value ("flac", "mp3", etc.), so it
-- works as a fallback. Without it, any album where
-- Plex didn't expose codec on every track ends up
-- labeled "Unknown" in the UI even though the file
-- format is known via container. Local CTE is not
-- affected — local indexing always writes a non-null
-- format string.
COALESCE(MAX(codec), MAX(container)) AS format,
-- Plex frequently omits bitDepth from its Media/Stream
-- XML for older releases; the aggregated row inherits
-- the gap as NULL. When the format ends up lossless
-- and sample rate sits at CD range (<= 48 kHz),
-- default to 16 — that's the universal CD-Audio /
-- redbook assumption that virtually every lossless
-- album at that rate matches. Higher rates leave the
-- field NULL (could be 24, could be 32) and the UI
-- falls back to its "--" placeholder; the per-track
-- view shows the real value when the user clicks in.
COALESCE(
MAX(bit_depth),
CASE
WHEN LOWER(COALESCE(MAX(codec), MAX(container))) IN
('flac', 'alac', 'wav', 'aiff', 'ape')
AND MAX(sampling_rate_hz) <= 48000
THEN 16
ELSE NULL
END
) AS bit_depth,
CAST(MAX(sampling_rate_hz) AS REAL) AS sample_rate,
CAST(NULL AS TEXT) AS source_folders,
'plex' AS source
FROM plex_cache.plex_cache_tracks
GROUP BY COALESCE(album_key, 'plex:' || rating_key)
)"#
} else {
""
};
let unioned_clause = if plex_attached {
"SELECT * FROM aggregated UNION ALL SELECT * FROM plex_aggregated"
} else {
"SELECT * FROM aggregated"
};
let query = format!(
r#"
WITH grouped AS (
SELECT
{group_key} AS group_key,
-- Prefer `album` (metadata tag) over
-- `album_group_title` (scan-time snapshot, which
-- falls back to folder name if metadata is
-- missing). Fixes #411 — when album metadata is
-- valid, the folder name was winning because
-- COALESCE returned `album_group_title` first.
COALESCE(
NULLIF(NULLIF(TRIM(album), ''), 'Unknown Album'),
album_group_title,
'Unknown Album'
) AS title,
COALESCE(album_artist, artist, 'Unknown Artist') AS artist,
year,
catalog_number,
artwork_path,
duration_secs,
format,
bit_depth,
sample_rate,
album_group_key AS source_folder,
COALESCE(source, 'user') AS source,
artist AS track_artist
FROM local_tracks
WHERE 1=1 {source_filter} {network_filter}
),
aggregated AS (
SELECT
group_key,
CASE WHEN group_key = '__unknown_album__'
THEN 'Unknown Album'
ELSE MIN(title)
END AS title,
CASE WHEN COUNT(DISTINCT track_artist) > 1
THEN 'Various Artists'
ELSE MIN(artist)
END AS artist,
GROUP_CONCAT(DISTINCT track_artist) AS all_artists,
MIN(year) AS year,
MIN(catalog_number) AS catalog_number,
MAX(CASE WHEN artwork_path IS NOT NULL THEN artwork_path END) AS artwork,
COUNT(*) AS track_count,
SUM(duration_secs) AS total_duration,
MAX(format) AS format,
MAX(bit_depth) AS bit_depth,
MAX(sample_rate) AS sample_rate,
GROUP_CONCAT(DISTINCT source_folder) AS source_folders,
MAX(source) AS source
FROM grouped
GROUP BY group_key
){plex_cte},
filtered AS (
SELECT * FROM ({unioned_clause})
WHERE ?1 = 0 OR (title LIKE ?2 OR artist LIKE ?2)
)
SELECT
group_key, title, artist, all_artists, year, catalog_number,
artwork, track_count, total_duration, format, bit_depth,
sample_rate, source_folders, source,
COUNT(*) OVER () AS total
FROM filtered
ORDER BY {order_clause}
LIMIT ?3 OFFSET ?4
"#,
group_key = group_key_expr,
source_filter = source_filter,
network_filter = network_filter,
plex_cte = plex_cte,
unioned_clause = unioned_clause,
order_clause = order_clause,
);
let mut stmt = self
.conn
.prepare(&query)
.map_err(|e| LibraryError::Database(e.to_string()))?;
let rows = stmt
.query_map(
rusqlite::params![has_search, search_like, limit as i64, offset as i64],
|row| {
let group_key: String = row.get(0)?;
let title: String = row.get(1)?;
let artist: String = row.get(2)?;
let all_artists: String =
row.get::<_, Option<String>>(3)?.unwrap_or_default();
let artwork_path: Option<String> = row.get(6)?;
let source_folders: Option<String> = row.get(12)?;
let total: u64 = row.get::<_, i64>(14)? as u64;
Ok((
LocalAlbum {
id: group_key,
title,
artist,
all_artists,
year: row.get(4)?,
catalog_number: row.get(5)?,
artwork_path,
track_count: row.get(7)?,
total_duration_secs: row.get(8)?,
format: Self::parse_format(
&row.get::<_, Option<String>>(9)?.unwrap_or_default(),
),
bit_depth: row.get(10)?,
sample_rate: row.get::<_, Option<f64>>(11)?.unwrap_or(44100.0),
directory_path: String::new(),
source_folders,
source: row
.get::<_, Option<String>>(13)?
.unwrap_or_else(|| "user".to_string()),
},
total,
))
},
)
.map_err(|e| LibraryError::Database(e.to_string()))?;
let mut albums = Vec::new();
let mut total: u64 = 0;
for row_result in rows {
let (album, t) = row_result.map_err(|e| LibraryError::Database(e.to_string()))?;
total = t;
albums.push(album);
}
// Empty page (offset past the end or filter matches nothing).
// The window-function trick gives us total only on returned
// rows, so when there are none we have to ask separately.
if albums.is_empty() {
total = self.count_albums_metadata_for_page(
search,
include_qobuz_downloads,
exclude_network_folders,
plex_attached,
)?;
}
Ok(crate::models::AlbumsMetadataPage { albums, total })
}
/// Companion to `get_albums_metadata_page` — total count of albums
/// matching the same filter. Used when the page is empty (so the
/// window-function-derived total isn't available) or when the
/// frontend wants to know the count before requesting any page.
/// Honours the same Plex attachment state as the caller used.
fn count_albums_metadata_for_page(
&self,
search: Option<&str>,
include_qobuz_downloads: bool,
exclude_network_folders: bool,
plex_attached: bool,
) -> Result<u64, LibraryError> {
let source_filter = if include_qobuz_downloads {
""
} else {
"AND (source IS NULL OR source != 'qobuz_download')"
};
let network_filter = if exclude_network_folders {
"AND NOT EXISTS (
SELECT 1 FROM library_folders nf
WHERE nf.is_network = 1
AND local_tracks.file_path LIKE nf.path || '%'
)"
} else {
""
};
let group_key_expr = crate::album_grouping::metadata_group_key_sql_expression();
let search_pattern = search.unwrap_or("").trim();
let has_search: i64 = if search_pattern.is_empty() { 0 } else { 1 };
let search_like = format!("%{}%", search_pattern);
let plex_cte = if plex_attached {
r#",
plex_aggregated AS (
SELECT
-- `album_key` is populated by plex/mod.rs::plex_album_key()
-- which already returns `"plex:<hash>"`. Only the
-- rating_key fallback needs the prefix added.
COALESCE(album_key, 'plex:' || rating_key) AS group_key,
COALESCE(album, 'Unknown Album') AS title,
COALESCE(MIN(artist), 'Unknown Artist') AS artist
FROM plex_cache.plex_cache_tracks
GROUP BY COALESCE(album_key, 'plex:' || rating_key)
)"#
} else {
""
};
let unioned_clause = if plex_attached {
"SELECT * FROM aggregated UNION ALL SELECT * FROM plex_aggregated"
} else {
"SELECT * FROM aggregated"
};
let query = format!(
r#"
WITH grouped AS (
SELECT
{group_key} AS group_key,
-- Prefer `album` (metadata tag) over
-- `album_group_title` (scan-time snapshot, which
-- falls back to folder name if metadata is
-- missing). Fixes #411 — when album metadata is
-- valid, the folder name was winning because
-- COALESCE returned `album_group_title` first.
COALESCE(
NULLIF(NULLIF(TRIM(album), ''), 'Unknown Album'),
album_group_title,
'Unknown Album'
) AS title,
COALESCE(album_artist, artist, 'Unknown Artist') AS artist,
artist AS track_artist
FROM local_tracks
WHERE 1=1 {source_filter} {network_filter}
),
aggregated AS (
SELECT
group_key,
CASE WHEN group_key = '__unknown_album__'
THEN 'Unknown Album'
ELSE MIN(title)
END AS title,
CASE WHEN COUNT(DISTINCT track_artist) > 1
THEN 'Various Artists'
ELSE MIN(artist)
END AS artist
FROM grouped
GROUP BY group_key
){plex_cte}
SELECT COUNT(*)
FROM ({unioned_clause})
WHERE ?1 = 0 OR (title LIKE ?2 OR artist LIKE ?2)
"#,
group_key = group_key_expr,
source_filter = source_filter,
network_filter = network_filter,
plex_cte = plex_cte,
unioned_clause = unioned_clause,
);
let total: i64 = self
.conn
.query_row(
&query,
rusqlite::params![has_search, search_like],
|row| row.get(0),
)
.map_err(|e| LibraryError::Database(e.to_string()))?;
Ok(total as u64)
}
/// Get tracks for a metadata-grouped album. The `metadata_key`
/// matches what [`Self::get_albums_metadata_grouped`] returns for
/// the album's `id` field.
+9
View File
@@ -131,6 +131,15 @@ pub struct PlaylistLocalTrack {
pub playlist_position: i32,
}
/// One page of metadata-grouped local albums plus the total count of
/// albums matching the same filter (for scrollbar pre-allocation on the
/// frontend). Returned by `Database::get_albums_metadata_page`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlbumsMetadataPage {
pub albums: Vec<LocalAlbum>,
pub total: u64,
}
/// An album aggregated from local tracks
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalAlbum {
+8
View File
@@ -251,6 +251,14 @@ pub struct Album {
#[serde(default)]
pub image: ImageSet,
pub release_date_original: Option<String>,
/// Date the album becomes available for streaming (ISO YYYY-MM-DD).
/// When in the future, the album is upcoming and cannot be fetched
/// via `get_album` yet — Release Watch uses this to gate clicks.
pub release_date_stream: Option<String>,
/// Whether the album is currently streamable. False for upcoming
/// releases, regional restrictions, or label takedowns.
#[serde(default)]
pub streamable: Option<bool>,
pub label: Option<Label>,
pub genre: Option<Genre>,
pub tracks_count: Option<u32>,
+539 -129
View File
@@ -68,6 +68,13 @@ enum AudioCommand {
sample_rate: u32,
channels: u16,
duration_secs: u64,
/// Resume offset in seconds (#315). When > 0, the audio thread
/// waits for enough buffer to cover the offset and pre-skips
/// decoder output up to that point before engaging audio.
start_position_secs: u64,
/// Total content length in bytes. Combined with `duration_secs`
/// to estimate bytes-per-second when sizing the resume buffer.
content_length: u64,
},
/// Pause playback
Pause,
@@ -504,6 +511,16 @@ impl StreamType {
_ => false,
}
}
/// Actual output stream rate. On macOS shared mode this is the rate that
/// must match CoreAudio's current nominal device rate; decoded track rates
/// may differ and are resampled by Rodio.
#[cfg(target_os = "macos")]
fn output_sample_rate(&self) -> u32 {
match self {
StreamType::Rodio { sink, .. } => sink.config().sample_rate().get(),
}
}
}
fn apply_engine_volume(
@@ -524,6 +541,209 @@ fn apply_engine_volume(
engine.set_volume(volume);
}
#[cfg(target_os = "macos")]
fn uses_coreaudio_system_default(settings: &AudioSettings) -> bool {
settings
.backend_type
.unwrap_or(AudioBackendType::SystemDefault)
== AudioBackendType::SystemDefault
}
/// `evaluate_stream_recreate` runs every track change on the audio thread,
/// and `coreaudio_nominal_rate` issues two CoreAudio HAL queries per call
/// (`resolve_output_device_id` + `get_nominal_sample_rate`). A short-lived
/// cache absorbs the bulk of those repeats — long enough to skip duplicate
/// queries within an album, short enough to notice when the user changes the
/// system audio rate.
#[cfg(target_os = "macos")]
const COREAUDIO_RATE_CACHE_TTL: std::time::Duration = std::time::Duration::from_millis(750);
#[cfg(target_os = "macos")]
struct CachedNominalRate {
cached_at: std::time::Instant,
device_name: Option<String>,
rate: Option<u32>,
}
#[cfg(target_os = "macos")]
std::thread_local! {
static COREAUDIO_NOMINAL_RATE_CACHE: std::cell::RefCell<Option<CachedNominalRate>> =
const { std::cell::RefCell::new(None) };
}
#[cfg(target_os = "macos")]
fn query_macos_nominal_rate(device_name: Option<&str>) -> Option<u32> {
let device_id = qbz_audio::coreaudio_direct::resolve_output_device_id(device_name)
.inspect_err(|e| {
log::warn!(
"[CoreAudio] Failed to resolve output device for rate check: {}",
e
)
})
.ok()?;
qbz_audio::coreaudio_direct::get_nominal_sample_rate(device_id)
.inspect_err(|e| log::warn!("[CoreAudio] Failed to query nominal rate: {}", e))
.ok()
}
#[cfg(target_os = "macos")]
fn coreaudio_nominal_rate(settings: &AudioSettings) -> Option<u32> {
if !uses_coreaudio_system_default(settings) {
return None;
}
let device_name = settings.output_device.clone();
let now = std::time::Instant::now();
COREAUDIO_NOMINAL_RATE_CACHE.with(|cell| {
let mut cell = cell.borrow_mut();
if let Some(cached) = cell.as_ref() {
if cached.device_name == device_name
&& now.duration_since(cached.cached_at) < COREAUDIO_RATE_CACHE_TTL
{
return cached.rate;
}
}
let rate = query_macos_nominal_rate(device_name.as_deref());
*cell = Some(CachedNominalRate {
cached_at: now,
device_name,
rate,
});
rate
})
}
#[cfg(target_os = "macos")]
fn coreaudio_shared_rate_mismatch(
settings: &AudioSettings,
stream_opt: &Option<StreamType>,
) -> Option<(u32, u32)> {
if settings.exclusive_mode || !uses_coreaudio_system_default(settings) {
return None;
}
let stream_rate = stream_opt.as_ref().map(StreamType::output_sample_rate)?;
let nominal_rate = coreaudio_nominal_rate(settings)?;
(stream_rate != nominal_rate).then_some((stream_rate, nominal_rate))
}
/// Inputs needed by both `Play` and `Stream` handlers to decide whether the
/// current output stream must be torn down and recreated.
struct StreamRecreateDecision {
needs_new_stream: bool,
format_changed: bool,
dac_passthrough: bool,
using_alsa_direct: bool,
using_coreaudio_exclusive: bool,
coreaudio_shared_rate_mismatch: Option<(u32, u32)>,
}
/// Read settings once and evaluate every condition that forces a stream
/// rebuild: format change on backends that demand bit-perfect output (DAC
/// passthrough, ALSA Direct, CoreAudio Exclusive) and CoreAudio shared-mode
/// rate drift (CPAL caches the device rate at open time, so when the OS
/// nominal rate moves we must rebuild or playback runs at the wrong speed).
///
/// `current_track_sample_rate` / `current_track_channels` describe the last
/// *decoded source* format and are compared to the incoming `sample_rate` /
/// `channels` — never to the output stream's hardware rate, which on macOS
/// shared mode may differ and is tracked separately via Rodio's sink config.
fn evaluate_stream_recreate(
thread_settings: &Arc<Mutex<AudioSettings>>,
stream_opt: &Option<StreamType>,
current_track_sample_rate: Option<u32>,
current_track_channels: Option<u16>,
sample_rate: u32,
channels: u16,
context: &str,
) -> StreamRecreateDecision {
let format_changed =
current_track_sample_rate != Some(sample_rate) || current_track_channels != Some(channels);
let settings_guard = thread_settings.lock().ok();
let dac_passthrough = settings_guard
.as_ref()
.map(|s| cfg!(target_os = "linux") && s.dac_passthrough)
.unwrap_or(false);
let using_alsa_direct = settings_guard
.as_ref()
.and_then(|s| s.backend_type)
.map(|b| b == AudioBackendType::Alsa)
.unwrap_or(false);
let using_coreaudio_exclusive = settings_guard
.as_ref()
.map(|s| {
cfg!(target_os = "macos")
&& s.backend_type.unwrap_or(AudioBackendType::SystemDefault)
== AudioBackendType::SystemDefault
&& s.exclusive_mode
})
.unwrap_or(false);
#[cfg(target_os = "macos")]
let coreaudio_shared_rate_mismatch = settings_guard
.as_ref()
.and_then(|s| coreaudio_shared_rate_mismatch(s, stream_opt))
.inspect(|(stream_rate, nominal_rate)| {
log::warn!(
"[CoreAudio] {} shared-mode output rate changed: stream {}Hz, device nominal {}Hz. Recreating stream to avoid wrong-speed playback.",
context,
stream_rate,
nominal_rate
);
});
#[cfg(not(target_os = "macos"))]
let coreaudio_shared_rate_mismatch: Option<(u32, u32)> = {
let _ = (stream_opt, context);
None
};
drop(settings_guard);
let needs_new_stream = compute_needs_new_stream(
stream_opt.is_some(),
format_changed,
dac_passthrough,
using_alsa_direct,
using_coreaudio_exclusive,
coreaudio_shared_rate_mismatch.is_some(),
);
StreamRecreateDecision {
needs_new_stream,
format_changed,
dac_passthrough,
using_alsa_direct,
using_coreaudio_exclusive,
coreaudio_shared_rate_mismatch,
}
}
/// Pure decision rule for whether the output stream must be rebuilt.
///
/// Split out so the truth table can be unit-tested without faking a real
/// `MixerDeviceSink` or `AudioSettings` mutex.
fn compute_needs_new_stream(
has_stream: bool,
format_changed: bool,
dac_passthrough: bool,
using_alsa_direct: bool,
using_coreaudio_exclusive: bool,
coreaudio_shared_rate_mismatch: bool,
) -> bool {
!has_stream
|| (dac_passthrough && format_changed)
|| (using_alsa_direct && format_changed)
|| (using_coreaudio_exclusive && format_changed)
|| coreaudio_shared_rate_mismatch
}
/// Try to create output stream using the backend system (if configured)
/// Returns None if backend system is not configured (backend_type = None)
///
@@ -608,10 +828,12 @@ fn try_init_stream_with_backend(
// Fallback to regular rodio stream (PipeWire, Pulse, ALSA via CPAL)
match backend.create_output_stream_with_exclusive_guard(&config) {
Ok((mixer_sink, _exclusive_guard)) => {
let output_sample_rate = mixer_sink.config().sample_rate().get();
log::info!(
"Stream created via {:?} backend at {}Hz",
"Stream created via {:?} backend (requested {}Hz, output {}Hz)",
backend_type,
sample_rate
sample_rate,
output_sample_rate
);
state.set_bit_perfect_mode(Some(BitPerfectMode::Disabled));
#[cfg(target_os = "macos")]
@@ -689,6 +911,10 @@ pub struct SharedState {
current_device: Arc<std::sync::RwLock<Option<String>>>,
/// Stream error flag (set when ALSA/audio errors are detected)
stream_error: Arc<AtomicBool>,
/// Optional user-readable explanation paired with `stream_error`.
/// Drained by the Tauri polling loop to emit a frontend toast and then
/// cleared, so the UI fires the notification exactly once per error.
stream_error_message: Arc<std::sync::RwLock<Option<String>>>,
/// Actual sample rate of the current stream (Hz)
sample_rate: Arc<AtomicU32>,
/// Actual bit depth of the current stream
@@ -726,6 +952,7 @@ impl SharedState {
position_at_start: Arc::new(AtomicU64::new(0)),
current_device: Arc::new(std::sync::RwLock::new(None)),
stream_error: Arc::new(AtomicBool::new(false)),
stream_error_message: Arc::new(std::sync::RwLock::new(None)),
sample_rate: Arc::new(AtomicU32::new(0)),
bit_depth: Arc::new(AtomicU32::new(0)),
normalization_gain: Arc::new(AtomicU32::new(0)),
@@ -736,14 +963,45 @@ impl SharedState {
}
}
/// Clearing (`error = false`) also drops any pending
/// `stream_error_message`. This is intentional: if init recovers before
/// the Tauri polling loop drains the message, we'd rather swallow the
/// toast than surface a notification for a transient failure the user
/// never perceived. The trade-off is that a fast record→clear→drain
/// sequence loses the message — accepted because a recovered error is
/// not a user-actionable event.
pub fn set_stream_error(&self, error: bool) {
self.stream_error.store(error, Ordering::SeqCst);
if !error {
if let Ok(mut m) = self.stream_error_message.write() {
*m = None;
}
}
}
pub fn has_stream_error(&self) -> bool {
self.stream_error.load(Ordering::SeqCst)
}
/// Record a user-readable error explanation alongside `stream_error=true`.
/// The message is drained once via `take_stream_error_message` so the UI
/// fires the toast exactly once per error.
pub fn record_stream_error(&self, message: impl Into<String>) {
self.stream_error.store(true, Ordering::SeqCst);
if let Ok(mut m) = self.stream_error_message.write() {
*m = Some(message.into());
}
}
/// Atomically take the pending stream-error message (if any). Returns
/// `None` when no message is pending or has already been read.
pub fn take_stream_error_message(&self) -> Option<String> {
self.stream_error_message
.write()
.ok()
.and_then(|mut m| m.take())
}
pub fn set_stream_quality(&self, sample_rate: u32, bit_depth: u32) {
self.sample_rate.store(sample_rate, Ordering::SeqCst);
self.bit_depth.store(bit_depth, Ordering::SeqCst);
@@ -1068,19 +1326,24 @@ impl Player {
}
Some(Err(e)) => {
// On macOS, the backend path is the only one that
// honors Exclusive Mode (CoreAudio Hog Mode). Falling
// through to legacy CPAL here would silently create a
// shared-mode stream while the UI still shows
// Exclusive — surface the failure instead so the
// user sees that audio is unavailable rather than
// unknowingly playing in shared mode.
// understands CoreAudio ownership and nominal-rate
// validation. Falling through to legacy CPAL can
// create a stream at a stale/source rate; in
// shared mode that can produce wrong-speed audio,
// and in Exclusive Mode it would silently drop Hog
// Mode. Surface the failure instead.
#[cfg(target_os = "macos")]
if settings.exclusive_mode {
if settings
.backend_type
.unwrap_or(AudioBackendType::SystemDefault)
== AudioBackendType::SystemDefault
{
log::error!(
"macOS Exclusive Mode init failed: {} — refusing to fall back to shared CPAL",
"Could not start macOS audio output — {}. Not falling back to the legacy CPAL path because it would either play at the wrong speed (shared mode) or silently drop Exclusive Mode.",
e
);
state.set_current_device(None);
state.record_stream_error(e.clone());
return None;
}
log::warn!(
@@ -1168,8 +1431,8 @@ impl Player {
// Initialize audio device lazily on first playback to avoid idle CPU usage.
let mut current_device_name = device_name.clone();
let mut stream_opt: Option<StreamType> = None;
let mut current_sample_rate: Option<u32> = None;
let mut current_channels: Option<u16> = None;
let mut current_track_sample_rate: Option<u32> = None;
let mut current_track_channels: Option<u16> = None;
#[allow(dead_code)]
const MAX_INIT_RETRIES: u32 = 5;
@@ -1209,8 +1472,8 @@ impl Player {
current_device_name: &mut Option<String>,
consecutive_sink_failures: &mut u32,
pause_suspend_deadline: &mut Option<Instant>,
current_sample_rate: &mut Option<u32>,
current_channels: &mut Option<u16>,
current_track_sample_rate: &mut Option<u32>,
current_track_channels: &mut Option<u16>,
current_normalization_gain: &mut Option<f32>,
current_gain_atomic: &mut Option<Arc<AtomicU32>>,
gapless_pending: &mut Option<GaplessPending>,
@@ -1236,46 +1499,32 @@ impl Player {
thread_state.set_gapless_ready(false);
thread_state.set_gapless_next_track_id(0);
// Get DAC passthrough setting
let dac_passthrough = thread_settings
.lock()
.ok()
.map(|s| cfg!(target_os = "linux") && s.dac_passthrough)
.unwrap_or(false);
// Check if we need to recreate the stream
// Recreate on format change if DAC passthrough OR ALSA Direct is enabled (both require bit-perfect)
let format_changed = *current_sample_rate != Some(sample_rate)
|| *current_channels != Some(channels);
// Check if using ALSA Direct backend
let using_alsa_direct = thread_settings
.lock()
.ok()
.and_then(|s| s.backend_type)
.map(|b| b == AudioBackendType::Alsa)
.unwrap_or(false);
let using_coreaudio_exclusive = thread_settings
.lock()
.ok()
.map(|s| {
cfg!(target_os = "macos")
&& s.backend_type.unwrap_or(AudioBackendType::SystemDefault)
== AudioBackendType::SystemDefault
&& s.exclusive_mode
})
.unwrap_or(false);
let needs_new_stream = stream_opt.is_none()
|| (dac_passthrough && format_changed)
|| (using_alsa_direct && format_changed)
|| (using_coreaudio_exclusive && format_changed);
let StreamRecreateDecision {
needs_new_stream,
format_changed,
dac_passthrough,
using_alsa_direct,
using_coreaudio_exclusive,
coreaudio_shared_rate_mismatch,
} = evaluate_stream_recreate(
&thread_settings,
stream_opt,
*current_track_sample_rate,
*current_track_channels,
sample_rate,
channels,
"Play",
);
if needs_new_stream {
if stream_opt.is_some() {
if (dac_passthrough
|| using_alsa_direct
|| using_coreaudio_exclusive)
// CoreAudio rate-mismatch case already
// logged at warn level by
// `evaluate_stream_recreate`.
if coreaudio_shared_rate_mismatch.is_none()
&& (dac_passthrough
|| using_alsa_direct
|| using_coreaudio_exclusive)
&& format_changed
{
let mode = if using_coreaudio_exclusive {
@@ -1287,14 +1536,12 @@ impl Player {
};
log::info!(
"Sample rate/channels changed from {:?}Hz/{:?}ch to {}Hz/{}ch - recreating audio stream ({})",
*current_sample_rate,
*current_channels,
*current_track_sample_rate,
*current_track_channels,
sample_rate,
channels,
mode
);
} else {
log::info!("Creating initial audio stream");
}
// Stop engine FIRST so its writer thread releases its
// Arc<AlsaDirectStream> reference before we drop the stream.
@@ -1455,8 +1702,6 @@ impl Player {
match stream_result {
Ok(stream) => {
*stream_opt = Some(stream);
*current_sample_rate = Some(sample_rate);
*current_channels = Some(channels);
thread_state.set_stream_error(false);
// Set current device name from settings (for backend system)
@@ -1502,13 +1747,20 @@ impl Player {
// Format changed but DAC passthrough is disabled - reuse existing stream
log::info!(
"Audio format changed from {:?}Hz/{:?}ch to {}Hz/{}ch - reusing audio stream (DAC passthrough disabled, gapless enabled)",
*current_sample_rate,
*current_channels,
*current_track_sample_rate,
*current_track_channels,
sample_rate,
channels
);
}
// Track the decoded source format separately from
// the OS output stream rate. In shared mode the
// stream can stay at the CoreAudio nominal rate
// while each track has its own decoded rate.
*current_track_sample_rate = Some(sample_rate);
*current_track_channels = Some(channels);
let Some(ref stream) = *stream_opt else {
log::error!("Audio thread: no audio device available");
return;
@@ -1558,8 +1810,8 @@ impl Player {
std::thread::sleep(Duration::from_millis(200));
// Use last known sample rate/channels to maintain DAC passthrough
let sr = current_sample_rate.unwrap_or(48000);
let ch = current_channels.unwrap_or(2);
let sr = current_track_sample_rate.unwrap_or(48000);
let ch = current_track_channels.unwrap_or(2);
*stream_opt = init_device(
current_device_name,
&thread_state,
@@ -1698,13 +1950,16 @@ impl Player {
sample_rate,
channels,
duration_secs,
start_position_secs,
content_length,
} => {
log::info!(
"Audio thread: starting streaming playback for track {} ({}Hz, {} channels, {}s)",
"Audio thread: starting streaming playback for track {} ({}Hz, {} channels, {}s, start={}s)",
track_id,
sample_rate,
channels,
duration_secs
duration_secs,
start_position_secs
);
*pause_suspend_deadline = None;
@@ -1714,44 +1969,32 @@ impl Player {
*current_audio_data = None; // Clear regular audio data
thread_state.set_loaded_audio(true);
// Get DAC passthrough setting
let dac_passthrough = thread_settings
.lock()
.ok()
.map(|s| cfg!(target_os = "linux") && s.dac_passthrough)
.unwrap_or(false);
// Check if we need to recreate the stream
let format_changed = *current_sample_rate != Some(sample_rate)
|| *current_channels != Some(channels);
let using_alsa_direct = thread_settings
.lock()
.ok()
.and_then(|s| s.backend_type)
.map(|b| b == AudioBackendType::Alsa)
.unwrap_or(false);
let using_coreaudio_exclusive = thread_settings
.lock()
.ok()
.map(|s| {
cfg!(target_os = "macos")
&& s.backend_type.unwrap_or(AudioBackendType::SystemDefault)
== AudioBackendType::SystemDefault
&& s.exclusive_mode
})
.unwrap_or(false);
let needs_new_stream = stream_opt.is_none()
|| (dac_passthrough && format_changed)
|| (using_alsa_direct && format_changed)
|| (using_coreaudio_exclusive && format_changed);
let StreamRecreateDecision {
needs_new_stream,
format_changed,
dac_passthrough,
using_alsa_direct,
using_coreaudio_exclusive,
coreaudio_shared_rate_mismatch,
} = evaluate_stream_recreate(
&thread_settings,
stream_opt,
*current_track_sample_rate,
*current_track_channels,
sample_rate,
channels,
"Streaming",
);
if needs_new_stream {
if stream_opt.is_some() {
if (dac_passthrough
|| using_alsa_direct
|| using_coreaudio_exclusive)
// CoreAudio rate-mismatch case already
// logged at warn level by
// `evaluate_stream_recreate`.
if coreaudio_shared_rate_mismatch.is_none()
&& (dac_passthrough
|| using_alsa_direct
|| using_coreaudio_exclusive)
&& format_changed
{
let mode = if using_coreaudio_exclusive {
@@ -1863,8 +2106,6 @@ impl Player {
match stream_result {
Ok(stream) => {
*stream_opt = Some(stream);
*current_sample_rate = Some(sample_rate);
*current_channels = Some(channels);
thread_state.set_stream_error(false);
log::info!(
"Streaming audio stream ready at {}Hz",
@@ -1884,6 +2125,11 @@ impl Player {
}
}
// Keep decoded source format current even when
// macOS shared mode reuses the same output stream.
*current_track_sample_rate = Some(sample_rate);
*current_track_channels = Some(channels);
let Some(ref stream) = *stream_opt else {
log::error!(
"Audio thread: no audio device available for streaming"
@@ -1935,12 +2181,44 @@ impl Player {
let volume = thread_state.volume.load(Ordering::SeqCst) as f32 / 100.0;
apply_engine_volume(&stream_opt, &engine, volume);
// Wait for minimum buffer before starting playback
// Wait for minimum buffer before starting playback.
// When start_position_secs > 0 (session resume),
// also wait for enough buffer to cover the resume
// offset plus an 8s headroom — the eager pre-skip
// below decodes-and-discards up to the offset and
// needs the bytes available without blocking the
// audio device on the first pull.
log::info!("Streaming: waiting for initial buffer...");
let start_wait = Instant::now();
let max_wait = Duration::from_secs(30);
let max_wait = Duration::from_secs(60);
while !source.has_min_buffer() && start_wait.elapsed() < max_wait {
let bytes_per_sec_estimate: u64 = if duration_secs > 0
&& content_length > 0
{
content_length / duration_secs
} else {
200_000
};
let resume_buffer_target: u64 = if start_position_secs > 0 {
bytes_per_sec_estimate
.saturating_mul(start_position_secs.saturating_add(8))
} else {
0
};
let buffer_sufficient = |src: &Arc<BufferedMediaSource>| -> bool {
if !src.has_min_buffer() {
return false;
}
if resume_buffer_target == 0 {
return true;
}
(src.buffer_size() as u64) >= resume_buffer_target
};
while !buffer_sufficient(&source)
&& start_wait.elapsed() < max_wait
{
std::thread::sleep(Duration::from_millis(50));
}
@@ -1948,11 +2226,20 @@ impl Player {
log::error!("Streaming: timeout waiting for initial buffer");
return;
}
if resume_buffer_target > 0
&& (source.buffer_size() as u64) < resume_buffer_target
{
log::warn!(
"Streaming: timed out waiting for resume buffer (got {} bytes, wanted {}); pre-skip may underrun briefly",
source.buffer_size(),
resume_buffer_target
);
}
let buffer_wait_ms = start_wait.elapsed().as_millis();
log::info!(
"Streaming: initial buffer ready in {}ms, creating incremental decoder...",
buffer_wait_ms
"Streaming: buffer ready in {}ms ({} bytes, target {}), creating incremental decoder...",
buffer_wait_ms, source.buffer_size(), resume_buffer_target
);
// Create incremental streaming source - this starts playback IMMEDIATELY
@@ -2030,8 +2317,40 @@ impl Player {
thread_state.set_normalization_gain(normalization);
// Box the incremental source to match the expected type
let source_to_play: Box<dyn Source<Item = f32> + Send> =
let mut source_to_play: Box<dyn Source<Item = f32> + Send> =
Box::new(incremental_source);
// Eager pre-skip for session resume. Decode and
// discard samples here so the engine's first pull
// doesn't have to do the work synchronously, which
// would underrun the audio device for multi-second
// offsets. The buffer wait above guarantees
// there's enough downloaded data to feed this loop.
if start_position_secs > 0 {
let target_samples: u64 = (start_position_secs)
.saturating_mul(actual_sr as u64)
.saturating_mul(actual_ch as u64);
let skip_start = Instant::now();
let mut skipped: u64 = 0;
while skipped < target_samples {
if source_to_play.next().is_none() {
log::warn!(
"Resume: source ended before reaching {}s (pre-skipped {} samples)",
start_position_secs,
skipped
);
break;
}
skipped += 1;
}
log::info!(
"Resume: pre-skipped {} samples ({}s) in {}ms",
skipped,
start_position_secs,
skip_start.elapsed().as_millis()
);
}
// Wrap source with diagnostic, normalization, and visualizer
let source_to_play = wrap_source(
source_to_play,
@@ -2046,16 +2365,19 @@ impl Player {
}
thread_state.is_playing.store(true, Ordering::SeqCst);
thread_state.position.store(0, Ordering::SeqCst);
thread_state
.position
.store(start_position_secs, Ordering::SeqCst);
thread_state
.current_track_id
.store(track_id, Ordering::SeqCst);
thread_state.start_playback_timer(0);
thread_state.start_playback_timer(start_position_secs);
*current_engine = Some(engine);
log::info!(
"Audio thread: streaming playback STARTED in {}ms (incremental decode active)",
start_wait.elapsed().as_millis()
"Audio thread: streaming playback STARTED in {}ms at {}s (incremental decode active)",
start_wait.elapsed().as_millis(),
start_position_secs
);
}
AudioCommand::Pause => {
@@ -2109,8 +2431,8 @@ impl Player {
if stream_opt.is_none() {
// Use last known sample rate/channels to maintain DAC passthrough
let sr = current_sample_rate.unwrap_or(48000);
let ch = current_channels.unwrap_or(2);
let sr = current_track_sample_rate.unwrap_or(48000);
let ch = current_track_channels.unwrap_or(2);
log::info!(
"Resume: reinitializing stream at {}Hz/{}ch",
sr,
@@ -2496,8 +2818,8 @@ impl Player {
*current_device_name = new_device;
// Use last known sample rate/channels to maintain DAC passthrough
let sr = current_sample_rate.unwrap_or(48000);
let ch = current_channels.unwrap_or(2);
let sr = current_track_sample_rate.unwrap_or(48000);
let ch = current_track_channels.unwrap_or(2);
log::info!("ReinitDevice: reinitializing at {}Hz/{}ch", sr, ch);
*stream_opt = init_device(current_device_name, &thread_state, sr, ch);
@@ -2537,7 +2859,7 @@ impl Player {
// Verify format compatibility (same sample rate and channels)
if let (Some(cur_sr), Some(cur_ch)) =
(*current_sample_rate, *current_channels)
(*current_track_sample_rate, *current_track_channels)
{
if sample_rate != cur_sr || channels != cur_ch {
log::info!(
@@ -2653,8 +2975,8 @@ impl Player {
&mut current_device_name,
&mut consecutive_sink_failures,
&mut pause_suspend_deadline,
&mut current_sample_rate,
&mut current_channels,
&mut current_track_sample_rate,
&mut current_track_channels,
&mut current_normalization_gain,
&mut current_gain_atomic,
&mut gapless_pending,
@@ -2899,8 +3221,8 @@ impl Player {
&mut current_device_name,
&mut consecutive_sink_failures,
&mut pause_suspend_deadline,
&mut current_sample_rate,
&mut current_channels,
&mut current_track_sample_rate,
&mut current_track_channels,
&mut current_normalization_gain,
&mut current_gain_atomic,
&mut gapless_pending,
@@ -2927,8 +3249,8 @@ impl Player {
&mut current_device_name,
&mut consecutive_sink_failures,
&mut pause_suspend_deadline,
&mut current_sample_rate,
&mut current_channels,
&mut current_track_sample_rate,
&mut current_track_channels,
&mut current_normalization_gain,
&mut current_gain_atomic,
&mut gapless_pending,
@@ -3065,8 +3387,11 @@ impl Player {
})
}
/// Play from streaming source (starts playback before full download)
/// Returns the BufferWriter so caller can push data as it downloads
/// Play from streaming source (starts playback before full download).
/// Returns the BufferWriter so caller can push data as it downloads.
/// `start_position_secs` > 0 turns this into a session-resume play
/// (#315): the audio thread waits for enough buffer to cover the
/// offset and pre-skips decoder output up to that point.
pub fn play_streaming(
&self,
track_id: u64,
@@ -3075,14 +3400,16 @@ impl Player {
content_length: u64,
buffer_seconds: u8,
duration_secs: u64,
start_position_secs: u64,
) -> Result<BufferWriter, String> {
log::info!(
"Player: Starting streaming playback for track {} ({}Hz, {}ch, {} bytes total, {}s)",
"Player: Starting streaming playback for track {} ({}Hz, {}ch, {} bytes total, {}s, start={}s)",
track_id,
sample_rate,
channels,
content_length,
duration_secs
duration_secs,
start_position_secs
);
// Use StreamingConfig::from_seconds for proper buffer sizing
@@ -3098,6 +3425,8 @@ impl Player {
sample_rate,
channels,
duration_secs,
start_position_secs,
content_length,
})
.map_err(|e| {
log::error!("Player: Failed to send streaming command: {}", e);
@@ -3108,8 +3437,8 @@ impl Player {
Ok(writer)
}
/// Play from streaming source with dynamic buffer based on measured speed
/// Returns the BufferWriter so caller can push data as it downloads
/// Play from streaming source with dynamic buffer based on measured speed.
/// `start_position_secs` > 0 signals session resume (see `play_streaming`).
pub fn play_streaming_dynamic(
&self,
track_id: u64,
@@ -3119,16 +3448,18 @@ impl Player {
content_length: u64,
speed_mbps: f64,
duration_secs: u64,
start_position_secs: u64,
) -> Result<BufferWriter, String> {
log::info!(
"Player: Starting dynamic streaming for track {} ({}Hz, {}ch, {}-bit, {:.2} MB, {:.1} MB/s, {}s)",
"Player: Starting dynamic streaming for track {} ({}Hz, {}ch, {}-bit, {:.2} MB, {:.1} MB/s, {}s, start={}s)",
track_id,
sample_rate,
channels,
bit_depth,
content_length as f64 / (1024.0 * 1024.0),
speed_mbps,
duration_secs
duration_secs,
start_position_secs
);
// Update shared state with actual stream quality
@@ -3147,6 +3478,8 @@ impl Player {
sample_rate,
channels,
duration_secs,
start_position_secs,
content_length,
})
.map_err(|e| {
log::error!("Player: Failed to send streaming command: {}", e);
@@ -3311,3 +3644,80 @@ pub struct PlaybackState {
pub track_id: u64,
pub volume: f32,
}
#[cfg(test)]
mod tests {
use super::compute_needs_new_stream;
#[test]
fn no_stream_always_needs_new() {
// Without an existing stream there is nothing to reuse — every
// other flag is irrelevant.
assert!(compute_needs_new_stream(
false, false, false, false, false, false
));
}
#[test]
fn unchanged_format_on_default_backend_reuses_stream() {
// Default rodio backend resamples internally, so an unchanged
// decoded format on an existing stream needs no rebuild.
assert!(!compute_needs_new_stream(
true, false, false, false, false, false
));
}
#[test]
fn format_change_alone_does_not_force_rebuild_on_default_backend() {
// Rodio handles sample-rate conversion; a format change without a
// bit-perfect backend does not require a new stream.
assert!(!compute_needs_new_stream(
true, true, false, false, false, false
));
}
#[test]
fn format_change_with_dac_passthrough_rebuilds() {
assert!(compute_needs_new_stream(
true, true, true, false, false, false
));
}
#[test]
fn format_change_with_alsa_direct_rebuilds() {
assert!(compute_needs_new_stream(
true, true, false, true, false, false
));
}
#[test]
fn format_change_with_coreaudio_exclusive_rebuilds() {
assert!(compute_needs_new_stream(
true, true, false, false, true, false
));
}
#[test]
fn bit_perfect_backends_without_format_change_reuse_stream() {
// Bit-perfect flags only force a rebuild *together with* a format
// change. On their own they should not.
assert!(!compute_needs_new_stream(
true, false, true, false, false, false
));
assert!(!compute_needs_new_stream(
true, false, false, true, false, false
));
assert!(!compute_needs_new_stream(
true, false, false, false, true, false
));
}
#[test]
fn coreaudio_shared_rate_mismatch_rebuilds_regardless_of_format_change() {
// The CoreAudio shared-mode rate-drift case has nothing to do with
// track format; it must rebuild whenever detected.
assert!(compute_needs_new_stream(
true, false, false, false, false, true
));
}
}
+30 -6
View File
@@ -1284,13 +1284,37 @@ impl QobuzClient {
Ok(result)
}
/// Fetch full Track objects for a batch of track IDs (max 50 per call).
/// Uses the `track/getList` endpoint.
///
/// Tries multiple API call strategies:
/// POST to track/getList with JSON body {"tracks_id": [...]}
/// Returns full Track objects for the given IDs (max 50 per call).
/// Fetch full Track objects for a batch of track IDs.
/// Uses the `track/getList` endpoint, which caps at 50 IDs per call,
/// so larger inputs are split into 50-ID windows and fetched serially.
/// Input order is preserved in the returned vector.
pub async fn get_tracks_batch(&self, track_ids: &[u64]) -> Result<Vec<Track>> {
const MAX_PER_CALL: usize = 50;
if track_ids.is_empty() {
return Ok(Vec::new());
}
if track_ids.len() <= MAX_PER_CALL {
return self.get_tracks_batch_chunk(track_ids).await;
}
log::debug!(
"[API] get_tracks_batch chunking {} IDs into {}-windows",
track_ids.len(),
MAX_PER_CALL
);
let mut all = Vec::with_capacity(track_ids.len());
for chunk in track_ids.chunks(MAX_PER_CALL) {
let mut tracks = self.get_tracks_batch_chunk(chunk).await?;
all.append(&mut tracks);
}
Ok(all)
}
/// Single `track/getList` POST. Caller is responsible for keeping
/// `track_ids.len() <= 50` — `get_tracks_batch` handles that.
async fn get_tracks_batch_chunk(&self, track_ids: &[u64]) -> Result<Vec<Track>> {
let url = endpoints::build_url(paths::TRACK_GET_LIST);
let headers = self.api_headers().await?;
let timestamp = get_timestamp();
+256
View File
@@ -0,0 +1,256 @@
c747c72e chore(release): bump version to 1.2.13 "400+ stars"
bfcd7690 fix(cast): heartbeat the Chromecast every 5s to keep the connection alive
bf734546 Merge bugfix/pre-1.2.13: graphics recommendation cleanup, faster track resume
3abb7f66 fix(playback): gate within-track resume on cache, drop slow seek path
3d29cf13 fix(graphics): make recommendation advisory-only, GPU-aware DMA-BUF help
29b766ae fix(graphics): recommend CPU for NVIDIA-only, allow DMA-BUF on hybrid
f02e1925 Merge branch 'pre-release' into bugfix/pre-1.2.13
420fd4d2 Merge refactor/qbz-app-boot-watchdog: boot watchdog
1a5dbe07 refactor(qbz-app): extract boot watchdog decision
fbf3a5a1 Merge refactor/qbz-app-graphics-autoconfig: graphics autoconfig
3c963cda refactor(qbz-app): extract graphics autoconfig
f457401d Merge refactor/qbz-app-graphics-detection: graphics detection
dceeacb5 refactor(tauri): use qbz-app graphics detection
2fc9270c refactor(qbz-app): add graphics detection
2beb83cb Merge refactor/qbz-app-user-data-paths: user data paths
b65feee5 refactor(tauri): use qbz-app user data paths
a497d61b refactor(qbz-app): add user data paths
e4ec5cd3 Merge refactor/qbz-app-developer-settings: developer settings store
be500956 refactor(qbz-app): add developer settings store
1fe0ac35 Merge refactor/qbz-audio-settings-foundation: audio settings store
87e426ab refactor(tauri): use qbz-audio settings
f85df353 refactor(qbz-audio): align audio settings store
c4a0a52a Merge refactor/qbz-app-graphics-settings: graphics settings store
441bcc5f refactor(tauri): use qbz-app graphics settings
bcdbc699 refactor(qbz-app): add graphics settings store
88432500 Merge refactor/qbz-app-image-cache-settings: image cache settings store
4d1feec7 refactor(tauri): use qbz-app image cache settings
f8f8bb88 refactor(qbz-app): add image cache settings store
62cd6ae1 Merge refactor/qbz-app-download-settings: download settings store
10b0e259 refactor(tauri): use qbz-app download settings
26012e98 refactor(qbz-app): add download settings store
74ce1c32 Merge refactor/qbz-app-remote-control-settings: remote control settings store
faca8376 chore(tauri): update qbz-app lock dependencies
f527186c refactor(tauri): use qbz-app remote control settings
2e438318 refactor(qbz-app): add remote control settings store
ebe03193 Merge refactor/qbz-app-ui-preferences-foundation: qbz-app UI preference stores
7f8e3533 Merge bugfix/pre-1.2.13: release watch filter, album tooltip, copyable settings text
0b83c5a0 fix(css): keep pre, code, kbd, samp selectable globally
29e70de5 feat(discovery-v2): tooltip with full album title on AlbumCardLite
3903086f fix(release-watch): drop upcoming and non-streamable albums
04d5b451 docs(qbz-app): keep app comments frontend agnostic
2d7639c6 refactor(tauri): use qbz-app ui preference stores
d1082317 refactor(qbz-app): add ui preference stores
2b7b2312 Merge bugfix/pre-1.2.13: offline-tolerant CoreBridge + Continue Listening hydration
db390724 fix(discovery-v2): hydrate Continue Listening tracks with full metadata and context
a40b1b6c fix(core): make QbzCore::init tolerant to offline startup
7f38dbcc refactor(tauri): use qbz-app preference stores
61d5715a refactor(qbz-app): add preference stores
d48a1019 chore(deps): bump svelte to 5.55.7
917b2a8b fix(ui): avoid t shadow in mixtape detail
dac1b3a7 refactor(tauri): use qbz-app settings stores
800447d3 refactor(qbz-app): add settings stores
dc74db64 test(qbz-app): cover runtime state transitions
0877f4a5 refactor(tauri): use qbz-app runtime types
d16ef003 refactor(qbz-app): move runtime core
4f008cd2 refactor(qbz-app): scaffold app crate
1bb8d53d feat(graphics): NVIDIA Wayland compat toggle + watchdog fallback to CPU
db45051c chore(security): patch file-type 16.5.4 ASF infinite-loop (GHSA, alert 56)
2832213d Merge pull request #424 from vicrodh/dependabot/npm_and_yarn/pre-release/types/node-25.7.0
8b176a18 chore(deps): bump discord-rich-presence 0.2.5 → 1.1.0 with API adjustment
47c9c618 Merge pull request #432 from vicrodh/dependabot/cargo/src-tauri/pre-release/tower-http-0.6.8
875ad452 chore(deps-dev): bump @types/node from 22.19.18 to 25.7.0
7ee53599 Merge pull request #429 from vicrodh/dependabot/npm_and_yarn/pre-release/vite-8.0.12
b165ddb0 Merge pull request #430 from vicrodh/dependabot/npm_and_yarn/pre-release/sveltejs/vite-plugin-svelte-7.1.2
69fbb1df Merge pull request #426 from vicrodh/dependabot/npm_and_yarn/pre-release/tauri-apps/cli-2.11.1
11854a99 Merge pull request #427 from vicrodh/dependabot/npm_and_yarn/pre-release/tauri-apps/plugin-opener-2.5.4
0eae6a56 Merge pull request #425 from vicrodh/dependabot/cargo/src-tauri/pre-release/tokio-1.52.3
dc6725b1 Merge pull request #431 from vicrodh/dependabot/cargo/src-tauri/pre-release/filetime-0.2.29
91bfb056 Merge pull request #433 from vicrodh/dependabot/cargo/src-tauri/pre-release/tauri-plugin-single-instance-2.4.2
921f8e1a fix(session): resume Qobuz streams at saved position without losing seek (#315)
b2960e80 feat(graphics): smart GPU preference with boot watchdog and crash recovery
77754e16 feat(discovery): redesign Radio Station Lite cards to match iOS reference
cd035a34 feat(graphics): detect hybrid NVIDIA+iGPU and surface GPU + DE in Diagnostics
58807448 chore(deps): bump tauri-plugin-single-instance in /src-tauri
7ce112ba chore(deps): bump tower-http from 0.5.2 to 0.6.8 in /src-tauri
620a393b chore(deps): bump filetime from 0.2.27 to 0.2.29 in /src-tauri
b33ce39f chore(deps-dev): bump @sveltejs/vite-plugin-svelte from 7.0.0 to 7.1.2
9c83e116 chore(deps-dev): bump vite from 8.0.10 to 8.0.12
d53844c2 chore(deps): bump @tauri-apps/plugin-opener from 2.5.3 to 2.5.4
7e7f3e05 chore(deps-dev): bump @tauri-apps/cli from 2.10.1 to 2.11.1
060766e1 chore(deps): bump tokio from 1.52.2 to 1.52.3 in /src-tauri
b06580af feat(local-library): unify album quality badge + add source icon across surfaces
b5918e1c fix(library): recover Plex album format + bit-depth when codec is missing
0f5bff3a refactor(immersive): extract shared ImmersiveSongCard from 9 panel copies
5bc5122d fix(ephemeral): replace 1 << 48 with 2 ** 48 for ephemeral track id floor
7bb10472 fix(linebed): size artwork off the live measured column height
c715475d fix(linebed): use align-self: stretch on artwork instead of percentage height
783f7e41 fix(linebed): scale artwork to match track-meta column height
cb21653a fix(quality-badge): tighten framed-badge right gutter from 8px to 4px
72a1cbab fix(quality-badge): match framed-badge right gutter to Hi-Res SVG transparent space
978d5d68 feat(discovery): unified quality badge on album cards, Qobuz-style layout
d0349119 feat(settings): add dismiss + manual re-detect to the graphics recommendation banner
700d5ef2 feat(settings): surface graphics recommendation banner in the Graphics tab
11721594 refactor(settings): split Graphics out of Appearance into its own section
dc96c5ac feat(graphics): flip DMA-BUF default to opt-in
3061de21 chore(scripts): add dev-igpu-mode.sh for iGPU-only paint path testing
a2ab509f fix(toast): keep error text selectable for debug copy-paste
48b83d14 Merge pull request #423 from afonsojramos/rework-titlebar-drag-region
032021de feat(lyrics): drag region on sidebar header and selectable lyrics panel
e54c5de0 feat(views): drag regions on top-bars across remaining views
47f3a9f3 feat(views): drag regions on home, library, and favorites top-bars
897f6ac8 feat(detail-views): drag regions and selectable metadata blocks
eb57c70e feat(modals): mark info modals and legal notice selectable
418bf371 refactor(styles): drop now-redundant local user-select: none
a34e4185 feat(sidebar): drag window from aside top padding band on macos
69d7ee02 refactor(layout): remove macos overlay drag and main-content padding
5610861e refactor(titlebar): drag on spacer divs not full bar
c1c151ee refactor(css): user-select none on body with .selectable opt-in
f12b4e29 Merge branch 'feature/queue-persistence-315' into pre-release
d4745f84 feat(session): restore local on startup, let QConnect overwrite when it lands (#315)
267440f5 feat(session): round-trip streamable, parental_warning, source_item_id_hint (#315)
7284568c feat(session): persist queue on every mutation, not just track-change (#315)
6f8f6f43 Merge branch 'bugfix/qconnect-batch-pagination-419' into pre-release
6f05fd30 fix(qobuz): chunk get_tracks_batch into 50-ID windows (#419)
93f51d26 Merge pull request #422 from afonsojramos/document-stream-error-clear
8445f845 Merge pull request #421 from afonsojramos/close-other-filters-on-open
5865c166 docs(player): explain set_stream_error(false) message swallow
0daca399 Merge pull request #420 from afonsojramos/refactor/internal/macos-shared-mode-followup
9231ea2b fix(mixtapes): replace click-swallowing backdrop with document click-outside
b3b0fce5 fix(purchases): replace click-swallowing backdrop with document click-outside
3382a36e fix(mixtape-collection): replace click-swallowing backdrop with document click-outside
29598747 fix(collections): replace click-swallowing backdrop with document click-outside
ff11dea8 fix(label): drop tracks context menu backdrop in favor of click-outside listener
ddf0ed41 fix(artist-detail): consolidate section sort menus and drop tracks context backdrop
126dff12 fix(library): consolidate toolbar dropdowns into single openMenu state
7fccf9bd fix(favorites): consolidate toolbar and section dropdowns into single openMenu state
fc56fb4b feat(genre-filter): make isOpen bindable for parent dropdown coordination
65320134 perf(lyrics): drop karaoke gradient in non-immersive CPU mode
427e1b5b Merge branch 'feature/multiselect-completion-381' into pre-release
58a80b57 feat(favorites): select-all checkbox for track multiselect
2e2c7549 feat(local-library): finalize #381 multiselect with cross-context apagador
000f7c10 Merge pull request #418 from afonsojramos/fix-macos-lyrics-text-color
84dcd7eb perf(+page): guard lyrics state writes against subscriber churn
640dead3 fix(lyricsStore): claim rAF handle pre-tick and notify on every change
6166584e perf(playerStore): extrapolate audio time between backend position events
64511c4f feat(lyrics): segment active-line gradient by visual line via pretext
57f83079 feat(ui): toast on audio:init-failed event
a2518490 i18n(toast): add audioInitFailed translation key
6d91ec0e feat(audio): emit audio:init-failed Tauri event on backend init failure
04e17cc4 feat(player): expose user-readable stream_error_message channel
a7b40bf6 perf(player): cache CoreAudio nominal rate query for 750ms
83fb109b Merge pull request #416 from afonsojramos/debug-local-update-issue
da3c1e86 Merge pull request #410 from Vudgekek/fix/internal/macos-shared-mode
f5e00e0a Merge branch 'feature/cpu-mode-lite-panels' into pre-release
4b290308 feat(immersive): CPU-mode lite path + per-panel toggles + warning modal + badge unification
f528129f Merge branch 'feature/412-track-list-album-covers' into pre-release
145831fe feat(local-library): opt-in album artwork in tracks/folders list (#412)
9abc82b8 Merge bugfix/411-album-title-prefer-metadata into pre-release
bdf6ca30 fix(local-library): prefer album metadata over folder-derived title (#411)
cd799211 Merge branch 'feature/discovery-v2-scaffold' into pre-release
185d5abd fix(search): card width + quick-menu portal
5045096e fix(album-card-lite): contain: paint on cover-wrap forces clip
de30ede4 fix(album-card-lite): bound cover img to parent + delete dead legacy views
234af9d6 fix(search): align-items: flex-start + fixed wrapper width
36b6a2ee feat(purchases): migrate AlbumCard → AlbumCardLibraryLite
d5d8d29d feat(favorites): migrate AlbumCard → AlbumCardLite
d02e5d38 feat(views): migrate Award + AlbumDetail + ArtistsByLocation to AlbumCardLite
79cee870 feat(label): migrate AlbumCard → AlbumCardLite
550d75bb feat(search): migrate AlbumCard → AlbumCardLite
10e32aa0 feat(artist-detail): migrate AlbumCard → AlbumCardLite
447344c8 fix(local-library): plex id double-prefix + scrim z-index over buttons
9f417999 perf(plex): request 220x220 transcoded artwork for thumbnail contexts
4bc1b0fa fix(local-library): hover ring above img, scrollbar height + style match
87d16926 fix(local-library): visible scrollbars + list as default view
1aea33cf feat(local-library): Plex consolidation in chunked albums page query
2e58c889 fix(library-card): hover indicator uses inset box-shadow (not clipped)
9c2ce894 style(legacy-grid): match Discovery 32px gap in VirtualizedAlbumList
478582e2 style(library-card): hover outline + 220px card width to match Home
82014205 style(grid-pool): bump card gaps to 32px to match Home visual rhythm
1a5131ba style(library-card): drop solid theme buttons for white outline + shadow
55a139da style(library-card): align action buttons at bottom 44px (matches Home Lite)
694423c7 perf(library-card): drop hover entirely, theme-coloured persistent buttons
36ce669c perf(grid-pool): revert transform + contain, drop bufferRows 2→1
9003d93a perf(grid-pool): drop bufferRows 3→2 + skeleton placeholder
f494b1e6 perf(library-card): drop full-cover hover scrim, keep floating buttons
7d26522f perf(grid-pool): contain: layout paint on each slot
a3f1426c perf(grid-pool): position slots with transform instead of top/left
7508c3cd fix(local-library): fall back to legacy path when Plex is enabled
8f2cd965 feat(local-library): server-paginated chunked store for Albums grid
810a41b4 feat(local-library): recycling-pool virtualizer for Albums grid
3eb62a50 feat(discovery-v2): AlbumCardLibraryLite + migrate LocalLibrary grid
b2e88c47 refactor(settings): hard-disable chrome toggle without GPU, terser note
d1ad0fc8 feat(window): disable transparent chrome when hardware acceleration is off
884da638 chore(scripts): add dev-cpu-mode.sh for forced software rendering
b5db0241 fix(LyricsLines): make line-text block to expand background-clip paint area
f9a9d103 fix(LyricsLines): let EMA accept rAF-rate block measurements
c68b1e28 refactor(modals): migrate all 22 shared-modal callers to ModalLite
f4f8fcf9 fix(LyricsLines): use activeProgress as karaoke floor, lookahead as bonus
4de657e2 feat(LyricsLines): JS-driven karaoke with measured block-interval and lookahead
dcb13568 feat(discovery-v2): ModalLite — add withScrim + footerAlign + overlay backdrop
d729c68d feat(discovery-v2): spotlight rewrite + artist follow + loading skeleton
6d251001 refactor(LyricsLines): single-div template with class-toggle transitions
4d426a80 fix(lyrics): snap line progress to 1 when at least 99% through
959f6568 refactor(lyrics): notify listeners on every progress change
13d1d691 perf(lyrics): drive active-line updates via requestAnimationFrame
a5100a32 perf(page): memoize lyrics sidebar lines projection
9aacf1d7 feat(lyrics): parse LRC gap markers and use endMs for line bounds
556a345c feat(discovery-v2): RadioCardLite + Spotlight radio + Mixes resize + i18n fixes
5a8d3a20 feat(discovery-v2): For-You-exclusive sections (Spotlight, Rediscover, etc.)
fdd8de48 feat(discovery-v2): per-tab section preferences (Home / Editor's / For You)
704392d3 feat(runtime): conditional flicker fix gated by HW-accel state
da1b29b2 feat(graphics): default hardware_acceleration to 1 (was 0)
e9504efa perf(modals): gate 8 always-mounted modals with {#if}, drop blur shadow
e3cdc97a feat(discovery-v2): ModalLite — opacity-only animation, no scale transform
7696b1ee fix(discovery-v2): prioritize Album of the Week over other ribbons
86195fcb feat(discovery-v2): rephrase queue + offline actions, reorder
35520e93 feat(discovery-v2): playlist hover overlay + quick menu (refactored, not ported)
87423e92 feat(discovery-v2): ribbon to bottom, meta to top-left, actions raised
fdeb544c fix(lyrics): correct active-line color on macOS WebKit
2baa2e28 feat(discovery-v2): responsive Top Albums grid + Hi-Res only badge + meta tweak
9285a380 feat(discovery-v2): Top Albums rank counter + hover-play + kebab menu
b4e08ac5 feat(discovery-v2): wire add-to-playlist + songlink share to album quick menu
5d33db2d feat(discovery-v2): wire kebab to minimal action quick-menu
d9c9ec11 feat(discovery-v2): genre/year meta + slide-up actions + bottom positioning
871e550d feat(discovery-v2): Qobuz-style hover overlay with 3 action buttons
c1510201 feat(discovery-v2): cheap hover overlay on album cards
f9479ac8 feat(discovery-v2): Popular albums as compact 4×3 grid
3e4e04a4 feat(discovery-v2): playlist cover with object-fit:contain + dominant color bg
0c8cde75 feat(discovery-v2): compact track-row grid for "Continue Listening"
42f59033 feat(discovery-v2): quality badges + press / award ribbons on album cards
b9a902a8 chore(discovery-v2): per-section item count 6 → 18
29a6f386 perf(zoom): only register wheel listener while Ctrl/Meta is held
67118b21 perf(back-to-top): keep button mounted, swap mount/unmount for opacity
e477d0ce perf(modal): drop backdrop-filter blur on overlay
3fb9e554 feat(discovery-v2): customize modal + gear icon + bigger gaps
efa7b2b1 feat(discovery-v2): section preferences (default 7 enabled)
5b608e1d feat(discovery-v2): bump card size 180px → 220px (matches image.small)
fb987366 feat(discovery-v2): wire ideal-discography section
5ae5087f feat(discovery-v2): drag-to-paginate, fade transition, bump to 20 items
fa78f6af feat(discovery-v2): route artwork through cachedSrc
1ad08f2d feat(discovery-v2): pagination by DOM slice (no scroll containers)
368f0b42 Revert "feat(discovery-v2): horizontal-scroll sections (Cider pattern)"
612384ae feat(discovery-v2): horizontal-scroll sections (Cider pattern)
f6ac95ec feat(discovery-v2): highlight currently-playing track in continue listening
fcdd6021 feat(discovery-v2): wire genre filter button into toolbar
5c30ced8 feat(discovery-v2): gate scroll-area by active tab
e4040044 feat(discovery-v2): wire personalized + playlist + artist sections
aa526fb8 feat(discovery-v2): wire editorial sections from discover-index
b6d2c121 feat(discovery-v2): first section — new releases
373018a1 feat(discovery-v2): scaffold clean-room rebuild of home view
03af983a chore(scripts): add measure-cpu-window for structured perf sampling
3c6f5f90 perf(now-playing-bar): drop invisible backdrop-filter
efcdad55 perf(cached-image): drop forced compositing layer on every artwork
82b3217c perf(playlist-card): drop blur(30px) on artwork backdrop
9aa10e95 perf(album-card): drop hover marquee, decorative blurs, add content-visibility
6029e93f Revert "Merge pull request #407 from afonsojramos/fix-glued-top-animation"
43a35647 Merge branch 'bugfix/unavailable-track-row-dim' into pre-release
03d1142f fix(ui): dim unavailable tracks across all track list views
4fd85b5b fix(ci): suffix macOS .app.tar.gz bundles with arch to fix auto-updater
1edde8a2 fix(audio): rephrase CoreAudio rate-mismatch errors for end users
9e632386 fix(audio): retry CoreAudio shared-mode open on rate mismatch
c408ea73 test(player): cover compute_needs_new_stream decision rule
ac60113c refactor(player): extract StreamRecreateDecision helper
3b34a734 refactor(player): rename current_sample_rate to current_track_sample_rate
7bad704e fix(audio): fix macOS Shared Mode playback from changing speed
d44ee7c0 build(flake): bump qbz to v1.2.12
+53
View File
@@ -0,0 +1,53 @@
# 1.2.13 — 400+ stars
qbz just passed **400 stars on GitHub**. Thanks to everyone who has tried the player, filed an issue, or sent a patch — this release is named for that milestone.
There are no headline features this time. 1.2.13 is a polish and stability release: most of the work went into **macOS**, where @afonsojramos and @Vudgekek tightened audio playback, the title bar, and the updater.
The rest is a steady round of bug fixes, a new graphics auto-config helper for Linux, and groundwork under the hood.
---
## macOS
- **Shared Mode playback** — fixed a speed drift, retries CoreAudio on a sample-rate mismatch, and rate-mismatch errors are now phrased for end users (@Vudgekek PR #410, @afonsojramos PR #420)
- **Title bar & window dragging** — drag regions reworked across the sidebar and view top-bars; the legacy macOS overlay drag region is gone (@afonsojramos PR #423)
- **Synced lyrics** — active-line color corrected under macOS WebKit
- **Auto-updater** — `.app.tar.gz` bundles are suffixed with the architecture so the updater picks the matching build
---
## Session restore
- **Full queue** — the entire play queue is saved on every change and restored on next launch, not just the current track (#315)
- **Qobuz Connect priority** — when a local session and a Qobuz Connect session both exist at startup, the Qobuz Connect state takes over
---
## Linux graphics
- **Graphics auto-config** — a new helper detects your GPU and recommends rendering settings; an advisory banner in Settings → Graphics shows what it suggests
- **Boot watchdog** — if a risky graphics setting crashes the app before it paints, the next launch reverts it on its own
- **GPU selection** — WebKit can be pinned to a specific GPU, which matters on hybrid NVIDIA + integrated laptops
---
## Fixes & polish
- **Chromecast** — fixed casts that connected and chimed but never played; the keep-alive heartbeat now matches Google Cast timing (#439)
- **Favorites on Qobuz tracks** — the favorite and add-to-playlist buttons are no longer wrongly disabled on streaming tracks
- **Menus & dropdowns** — a click outside a menu always closes it, toolbar dropdowns no longer swallow that click, and opening one filter closes the others (@afonsojramos PR #421)
- **Plex** — album format and bit-depth are recovered when the codec tag is missing
- **Track lists** — unavailable tracks are dimmed consistently across every list view
- **Local library** — optional album artwork in track and folder lists (#412), and smoother scrolling on large Albums grids
---
## Under the hood
- **Dependency bumps** — Tauri 2.11, tokio, vite 8, and the usual dependabot round
- **App-core groundwork** — playback, audio, and settings logic continues moving into framework-agnostic crates, with no change in behavior
---
Full changelog: https://github.com/vicrodh/qbz/compare/v1.2.12...v1.2.13
+1 -1
View File
@@ -15,7 +15,7 @@
# VERSION BUMP: update version, rev, and hashes
# when tagging a new release.
# ──────────────────────────────────────────────
qbzVersion = "1.2.12";
qbzVersion = "1.2.13";
qbzRev = "v${qbzVersion}";
srcHash = "sha256-SuEmBxwwEkINmH/cswV9Ed6FtBKP6IRrjQ7sIYMPOTg=";
npmHash = "sha256-WjZg3xaJFOdCA0XaIFwWhd4kglUjkWei6Cw3+NiP6zs=";
+907 -160
View File
File diff suppressed because it is too large. Load diff
+8 -5
View File
@@ -1,9 +1,10 @@
{
"name": "qbz-nix",
"version": "1.2.12",
"version": "1.2.13",
"description": "Native high-fidelity client for Qobuz™ subscribers on Linux with Hi-Fi audio support (uses the Qobuz API; not certified by Qobuz)",
"type": "module",
"scripts": {
"postinstall": "patch-package",
"dev": "svelte-kit sync && vite dev",
"dev:clean-runtime": "bash ./scripts/clean-dev-runtime.sh",
"dev:ghost-doctor": "bash ./scripts/dev-ghost-doctor.sh",
@@ -26,6 +27,7 @@
},
"license": "MIT",
"dependencies": {
"@chenglou/pretext": "^0.0.7",
"@icons-pack/svelte-simple-icons": "^7.2.0",
"@kawarp/core": "^1.1.1",
"@tauri-apps/api": "^2.11.0",
@@ -42,15 +44,16 @@
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.59.1",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tauri-apps/cli": "^2",
"@testing-library/svelte": "^5.3.1",
"@types/node": "^22.19.18",
"@types/node": "^25.7.0",
"jsdom": "^29.1.1",
"svelte": "^5.55.5",
"patch-package": "^8.0.1",
"svelte": "^5.55.7",
"svelte-check": "^4.4.8",
"typescript": "~6.0.3",
"vite": "^8.0.10",
"vite": "^8.0.12",
"vitest": "^4.1.5"
},
"overrides": {
@@ -69,6 +69,19 @@
</screenshot>
</screenshots>
<releases>
<release version="1.2.13" date="2026-05-16">
<description>
<p>400+ stars: a polish and stability release led by macOS fixes, with the usual round of bug fixes and groundwork under the hood.</p>
<ul>
<li>macOS Shared Mode: playback no longer drifts in speed, CoreAudio retries on a sample-rate mismatch, and rate-mismatch errors are rephrased for end users (PRs by @Vudgekek and @afonsojramos)</li>
<li>macOS title bar and window drag regions reworked; lyrics active-line color corrected on WebKit; auto-updater bundle naming fixed</li>
<li>Session restore now brings back the full queue, not just the current track</li>
<li>New Linux graphics auto-config: detects the GPU and recommends safe rendering settings</li>
<li>Favorite and add-to-playlist buttons are no longer wrongly disabled on Qobuz tracks</li>
<li>Assorted bug fixes, dropdown and menu behavior cleanup, and dependency bumps</li>
</ul>
</description>
</release>
<release version="1.2.12" date="2026-05-09">
<description>
<p>Hotfix: rolls back the broken titlebar-mode-selector that left v1.2.11 with no visible window chrome on Linux. Restores v1.2.10 title bar behavior (System / Custom / Hidden). All other v1.2.11 features remain.</p>
+24
View File
@@ -0,0 +1,24 @@
diff --git a/node_modules/file-type/core.js b/node_modules/file-type/core.js
index d653e66..baf9fa4 100644
--- a/node_modules/file-type/core.js
+++ b/node_modules/file-type/core.js
@@ -1074,6 +1074,7 @@ async function _fromTokenizer(tokenizer) {
await tokenizer.ignore(30);
// Search for header should be in first 1KB of file.
while (tokenizer.position + 24 < tokenizer.fileInfo.size) {
+ const previousPosition = tokenizer.position;
const header = await readHeader();
let payload = header.size - 24;
if (_check(header.id, [0x91, 0x07, 0xDC, 0xB7, 0xB7, 0xA9, 0xCF, 0x11, 0x8E, 0xE6, 0x00, 0xC0, 0x0C, 0x20, 0x53, 0x65])) {
@@ -1101,6 +1102,11 @@ async function _fromTokenizer(tokenizer) {
}
await tokenizer.ignore(payload);
+
+ // Safeguard against malformed files: break if the position did not advance.
+ if (tokenizer.position <= previousPosition) {
+ break;
+ }
}
// Default to ASF generic extension
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# Force QBZ to run under software/CPU rendering across every layer that
# can sneak the GPU back in. Useful for testing the degraded paint path
# (modal mount cost, Discovery V2 perf, etc.) without touching the
# persisted graphics_settings DB. Sister script of dev-ultra-restore.sh.
#
# Layers forced off / to SW:
# QBZ_HARDWARE_ACCEL=0 — QBZ's own nuclear opt-out
# (overrides graphics_settings DB)
# WEBKIT_DISABLE_COMPOSITING_MODE=1 — WebKit redraws via SW compositor
# WEBKIT_DISABLE_DMABUF_RENDERER=1 — no DMA-BUF GPU texture sharing
# GSK_RENDERER=cairo — GTK4 picks the cairo SW renderer
# (Vulkan / GL paths disabled)
# LIBGL_ALWAYS_SOFTWARE=1 — Mesa GL falls back to llvmpipe
#
# Usage:
# bash ./scripts/dev-cpu-mode.sh # kill stale + start tauri dev
# bash ./scripts/dev-cpu-mode.sh --no-kill # skip process cleanup
# bash ./scripts/dev-cpu-mode.sh --print # print the env vars and exit
# (for sourcing into another shell)
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
KILL_STALE=true
PRINT_ONLY=false
for arg in "$@"; do
case "$arg" in
--no-kill)
KILL_STALE=false
;;
--print)
PRINT_ONLY=true
;;
*)
echo "[qbz] Unknown option: ${arg}"
echo "Usage: bash ./scripts/dev-cpu-mode.sh [--no-kill] [--print]"
exit 1
;;
esac
done
export QBZ_HARDWARE_ACCEL=0
export WEBKIT_DISABLE_COMPOSITING_MODE=1
export WEBKIT_DISABLE_DMABUF_RENDERER=1
export GSK_RENDERER=cairo
export LIBGL_ALWAYS_SOFTWARE=1
echo "[qbz] dev-cpu-mode: software-rendering env applied"
echo "[qbz] QBZ_HARDWARE_ACCEL = ${QBZ_HARDWARE_ACCEL}"
echo "[qbz] WEBKIT_DISABLE_COMPOSITING_MODE = ${WEBKIT_DISABLE_COMPOSITING_MODE}"
echo "[qbz] WEBKIT_DISABLE_DMABUF_RENDERER = ${WEBKIT_DISABLE_DMABUF_RENDERER}"
echo "[qbz] GSK_RENDERER = ${GSK_RENDERER}"
echo "[qbz] LIBGL_ALWAYS_SOFTWARE = ${LIBGL_ALWAYS_SOFTWARE}"
if [[ "${PRINT_ONLY}" == "true" ]]; then
exit 0
fi
if [[ "${KILL_STALE}" == "true" ]]; then
echo "[qbz] dev-cpu-mode: stopping stale dev processes..."
pkill -f '[n]ode .*vite dev' || true
pkill -f '[n]ode .*tauri dev' || true
pkill -f '[c]argo run' || true
pkill -f '[t]arget/debug/qbz' || true
pkill -f '[t]arget/debug/qbz-nix' || true
for port in 1420 1421; do
pids="$(lsof -ti :"${port}" -sTCP:LISTEN 2>/dev/null || true)"
if [[ -n "${pids}" ]]; then
kill ${pids} 2>/dev/null || true
sleep 0.5
still_alive="$(lsof -ti :"${port}" -sTCP:LISTEN 2>/dev/null || true)"
if [[ -n "${still_alive}" ]]; then
kill -9 ${still_alive} 2>/dev/null || true
fi
fi
done
fi
echo "[qbz] dev-cpu-mode: starting tauri dev in foreground..."
exec npm run tauri dev
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# Force QBZ to run on the integrated GPU (Intel/AMD), bypassing the
# discrete NVIDIA GPU. Unlike dev-cpu-mode.sh (which forces pure
# software rendering), this keeps WebKit GPU compositing enabled so
# we can observe how QBZ behaves with modest-but-real HW accel.
#
# Why this matters: the current `hardware_accel_enabled` detector is
# binary (on / off). A user with an iGPU shouldn't be punished with
# the lite path designed for true software rendering — but right now
# the only way to test that intermediate tier is to actually constrain
# the runtime to the iGPU. This script is the manual harness for that.
#
# Layers forced to iGPU / Mesa:
# __EGL_VENDOR_LIBRARY_FILENAMES — pin EGL to Mesa only, skipping
# /usr/share/glvnd/egl_vendor.d/10_nvidia.json.
# This is the critical knob —
# WebKitGTK prefers EGL and the
# NVIDIA EGL ICD wins by priority
# unless we point it elsewhere.
# __GLX_VENDOR_LIBRARY_NAME=mesa — same idea for GLX (mostly belt
# and braces; default already
# resolves to Mesa+Intel on this box).
# DRI_PRIME=0 — explicit primary GPU selection;
# no PRIME render offload.
# __NV_PRIME_RENDER_OFFLOAD unset — make sure we are not asking
# nvidia-prime to bring the dGPU
# back via offload.
# QBZ_HARDWARE_ACCEL unset — let QBZ honor its persisted
# graphics_settings (HW on by
# default). We want the iGPU
# active, not software rendering.
# GSK_RENDERER unset — let GTK4 pick its best renderer
# under the constrained GL/EGL.
#
# Layers explicitly NOT set (the opposite of dev-cpu-mode.sh):
# WEBKIT_DISABLE_COMPOSITING_MODE — leave at default so WebKit
# keeps GPU compositing.
# WEBKIT_DISABLE_DMABUF_RENDERER — leave at default so DMA-BUF
# texture sharing works on the
# Intel/Mesa side.
# LIBGL_ALWAYS_SOFTWARE — leave unset so Mesa runs on the
# real Intel HW, not llvmpipe.
#
# Verification after launch (in a separate shell):
# nvidia-smi --query-compute-apps=pid,used_memory --format=csv
# → QBZ pid must NOT appear in the list.
# nvtop / intel_gpu_top
# → Activity on Intel iGPU when QBZ paints; idle on NVIDIA.
#
# Usage:
# bash ./scripts/dev-igpu-mode.sh # kill stale + start tauri dev
# bash ./scripts/dev-igpu-mode.sh --no-kill # skip process cleanup
# bash ./scripts/dev-igpu-mode.sh --print # print env and exit
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
KILL_STALE=true
PRINT_ONLY=false
for arg in "$@"; do
case "$arg" in
--no-kill)
KILL_STALE=false
;;
--print)
PRINT_ONLY=true
;;
*)
echo "[qbz] Unknown option: ${arg}"
echo "Usage: bash ./scripts/dev-igpu-mode.sh [--no-kill] [--print]"
exit 1
;;
esac
done
MESA_EGL_JSON="/usr/share/glvnd/egl_vendor.d/50_mesa.json"
if [[ ! -f "${MESA_EGL_JSON}" ]]; then
echo "[qbz] dev-igpu-mode: ${MESA_EGL_JSON} not found — your distro packages Mesa EGL elsewhere."
echo "[qbz] dev-igpu-mode: list the candidates with: ls /usr/share/glvnd/egl_vendor.d/"
exit 1
fi
export __EGL_VENDOR_LIBRARY_FILENAMES="${MESA_EGL_JSON}"
export __GLX_VENDOR_LIBRARY_NAME=mesa
export DRI_PRIME=0
unset __NV_PRIME_RENDER_OFFLOAD
unset QBZ_HARDWARE_ACCEL
unset GSK_RENDERER
unset WEBKIT_DISABLE_COMPOSITING_MODE
unset WEBKIT_DISABLE_DMABUF_RENDERER
unset LIBGL_ALWAYS_SOFTWARE
echo "[qbz] dev-igpu-mode: iGPU-only env applied"
echo "[qbz] __EGL_VENDOR_LIBRARY_FILENAMES = ${__EGL_VENDOR_LIBRARY_FILENAMES}"
echo "[qbz] __GLX_VENDOR_LIBRARY_NAME = ${__GLX_VENDOR_LIBRARY_NAME}"
echo "[qbz] DRI_PRIME = ${DRI_PRIME}"
echo "[qbz] QBZ_HARDWARE_ACCEL = (unset → DB default, HW on)"
echo "[qbz] compositing / DMA-BUF / llvmpipe = NOT forced (real iGPU paint path)"
if [[ "${PRINT_ONLY}" == "true" ]]; then
exit 0
fi
if [[ "${KILL_STALE}" == "true" ]]; then
echo "[qbz] dev-igpu-mode: stopping stale dev processes..."
pkill -f '[n]ode .*vite dev' || true
pkill -f '[n]ode .*tauri dev' || true
pkill -f '[c]argo run' || true
pkill -f '[t]arget/debug/qbz' || true
pkill -f '[t]arget/debug/qbz-nix' || true
for port in 1420 1421; do
pids="$(lsof -ti :"${port}" -sTCP:LISTEN 2>/dev/null || true)"
if [[ -n "${pids}" ]]; then
kill ${pids} 2>/dev/null || true
sleep 0.5
still_alive="$(lsof -ti :"${port}" -sTCP:LISTEN 2>/dev/null || true)"
if [[ -n "${still_alive}" ]]; then
kill -9 ${still_alive} 2>/dev/null || true
fi
fi
done
fi
echo "[qbz] dev-igpu-mode: starting tauri dev in foreground..."
exec npm run tauri dev
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env bash
# measure-cpu-window.sh — sample CPU% of QBZ + WebKit children over a window
#
# Usage:
# ./scripts/measure-cpu-window.sh [label] [duration_seconds]
#
# Examples:
# ./scripts/measure-cpu-window.sh blur-on # 30s sample, labelled "blur-on"
# ./scripts/measure-cpu-window.sh blur-off 60 # 60s sample, labelled "blur-off"
# ./scripts/measure-cpu-window.sh # auto-label with timestamp
#
# Reads /proc/PID/stat directly (no pidstat dependency). Samples per second.
# CPU% is normalized per-core (100% = one fully busy core), matching the
# convention used in `top` and in the bug report at issue #414/#415.
#
# Output:
# - Live one-line update per second (qbz + webkit + total)
# - At end: mean, p50, p95, max for the whole window
# - CSV row appended to /tmp/qbz-cpu-measurements.csv for cross-run comparison
#
# To compare runs:
# 1. Pick a stable window state (e.g. 4K full-screen, home view, track playing)
# 2. Run script, do NOT touch the window during the sample
# 3. Change the variable (e.g. resize, toggle backdrop-filter), repeat
# 4. cat /tmp/qbz-cpu-measurements.csv
set -euo pipefail
LABEL="${1:-run-$(date +%H%M%S)}"
DURATION="${2:-30}"
LOG_CSV="/tmp/qbz-cpu-measurements.csv"
CLK_TCK="$(getconf CLK_TCK)"
if [[ ! "$DURATION" =~ ^[0-9]+$ ]] || (( DURATION < 5 )); then
echo "error: duration must be a positive integer >= 5" >&2
exit 2
fi
# Find QBZ main process — match both legacy binary name (qbz-nix, dev builds)
# and the production binary (qbz). Prefer the largest-RSS match to dodge
# wrapper shells.
find_qbz_pid() {
local candidates pid best_rss=0 best_pid=""
candidates="$(pgrep -f 'target/(debug|release)/qbz(-nix)?$|/usr/(local/)?bin/qbz$|^qbz$' 2>/dev/null || true)"
if [[ -z "$candidates" ]]; then
candidates="$(pgrep -x qbz 2>/dev/null || true)"
fi
if [[ -z "$candidates" ]]; then
candidates="$(pgrep -f 'qbz-nix' 2>/dev/null || true)"
fi
for pid in $candidates; do
[[ -r "/proc/$pid/status" ]] || continue
local rss
rss="$(awk '/^VmRSS:/ {print $2}' "/proc/$pid/status" 2>/dev/null || echo 0)"
if (( rss > best_rss )); then
best_rss=$rss
best_pid=$pid
fi
done
[[ -n "$best_pid" ]] && echo "$best_pid"
}
QBZ_PID="$(find_qbz_pid || true)"
if [[ -z "${QBZ_PID:-}" ]]; then
echo "error: no qbz process found. Is the app running?" >&2
exit 1
fi
# All WebKit child processes. WebKitWebProcess is the one that does CSS
# layout/paint, so its CPU% is the most relevant for backdrop-filter cost.
# WebKitNetworkProcess does I/O — usually low CPU.
find_webkit_pids() {
pgrep -f 'WebKit(Web|Network|GPU)Process' 2>/dev/null || true
}
WEBKIT_PIDS=()
mapfile -t WEBKIT_PIDS < <(find_webkit_pids)
read_jiffies() {
# Field 14 (utime) + field 15 (stime) of /proc/PID/stat. Pre-paren tokens
# in field 2 (comm) can contain spaces, so split off comm carefully.
local pid="$1" stat content rest
[[ -r "/proc/$pid/stat" ]] || { echo 0; return; }
content="$(cat "/proc/$pid/stat" 2>/dev/null || echo "")"
[[ -z "$content" ]] && { echo 0; return; }
rest="${content#* (}"
rest="${rest#*) }"
read -r _state _ppid _pgrp _sid _tty _tpgid _flags _minflt _cminflt _majflt _cmajflt utime stime _rest <<< "$rest"
echo $(( utime + stime ))
}
read_window_size() {
# Best-effort: read the persisted window size from the qbz log. Not used in
# math, just printed as context in the CSV.
local logf="$HOME/.local/share/qbz/qbz.log"
if [[ -r "$logf" ]]; then
grep -oE 'size=[0-9]+x[0-9]+' "$logf" 2>/dev/null | tail -1 | cut -d= -f2 || echo "unknown"
else
echo "unknown"
fi
}
printf '%s\n' "=== qbz CPU sampler ==="
printf 'label: %s\n' "$LABEL"
printf 'qbz pid: %s\n' "$QBZ_PID"
if (( ${#WEBKIT_PIDS[@]} > 0 )); then
printf 'webkit: %s\n' "${WEBKIT_PIDS[*]}"
else
printf 'webkit: (none found — is the GUI window open?)\n'
fi
printf 'duration: %ss\n' "$DURATION"
printf 'window: %s (persisted)\n' "$(read_window_size)"
printf 'log: %s\n' "$LOG_CSV"
printf '\nSampling — leave the window UNTOUCHED for the full window.\n\n'
# Take initial snapshot
prev_qbz="$(read_jiffies "$QBZ_PID")"
declare -A prev_wk
for pid in "${WEBKIT_PIDS[@]:-}"; do
[[ -z "$pid" ]] && continue
prev_wk[$pid]="$(read_jiffies "$pid")"
done
prev_ts="$(date +%s.%N)"
samples_qbz=()
samples_wk_total=()
samples_total=()
printf '%4s %8s %8s %8s\n' "t/s" "qbz%" "webkit%" "total%"
printf '%4s %8s %8s %8s\n' "---" "----" "-------" "------"
for (( t=1; t<=DURATION; t++ )); do
sleep 1
now_ts="$(date +%s.%N)"
dt="$(awk -v a="$now_ts" -v b="$prev_ts" 'BEGIN{printf "%.4f", a-b}')"
prev_ts="$now_ts"
cur_qbz="$(read_jiffies "$QBZ_PID")"
d_qbz=$(( cur_qbz - prev_qbz ))
prev_qbz="$cur_qbz"
pct_qbz="$(awk -v j="$d_qbz" -v dt="$dt" -v hz="$CLK_TCK" 'BEGIN{ if (dt<=0) print 0; else printf "%.1f", (j/hz)/dt*100 }')"
wk_total=0
for pid in "${WEBKIT_PIDS[@]:-}"; do
[[ -z "$pid" ]] && continue
if [[ ! -r "/proc/$pid/stat" ]]; then
continue
fi
cur="$(read_jiffies "$pid")"
d=$(( cur - ${prev_wk[$pid]:-$cur} ))
prev_wk[$pid]="$cur"
(( d < 0 )) && d=0
wk_total=$(( wk_total + d ))
done
pct_wk="$(awk -v j="$wk_total" -v dt="$dt" -v hz="$CLK_TCK" 'BEGIN{ if (dt<=0) print 0; else printf "%.1f", (j/hz)/dt*100 }')"
pct_total="$(awk -v a="$pct_qbz" -v b="$pct_wk" 'BEGIN{ printf "%.1f", a+b }')"
samples_qbz+=("$pct_qbz")
samples_wk_total+=("$pct_wk")
samples_total+=("$pct_total")
printf '%4d %8s %8s %8s\n' "$t" "$pct_qbz" "$pct_wk" "$pct_total"
done
# Aggregate
summarize() {
local name="$1"
shift
local n="$#"
local sorted
sorted="$(printf '%s\n' "$@" | sort -n)"
local mean p50 p95 max
mean="$(printf '%s\n' "$@" | awk '{s+=$1} END{ if (NR>0) printf "%.1f", s/NR; else print 0 }')"
p50="$(printf '%s\n' "$sorted" | awk -v n="$n" 'NR==int(n*0.50)+1 || (NR==1 && n==1) {print; exit}')"
p95="$(printf '%s\n' "$sorted" | awk -v n="$n" 'NR==int(n*0.95)+1 || (NR==n) {print; exit}')"
max="$(printf '%s\n' "$sorted" | tail -1)"
printf ' %-10s mean=%5s%% p50=%5s%% p95=%5s%% max=%5s%%\n' "$name" "$mean" "$p50" "$p95" "$max"
echo "$mean,$p50,$p95,$max"
}
echo ""
echo "=== Summary ==="
summary_qbz="$(summarize "qbz" "${samples_qbz[@]}" | tail -1)"
summarize "qbz" "${samples_qbz[@]}" >/dev/null
read -r mean_qbz p50_qbz p95_qbz max_qbz <<< "$(echo "$summary_qbz" | tr ',' ' ')"
mean_wk=0 p50_wk=0 p95_wk=0 max_wk=0
if (( ${#samples_wk_total[@]} > 0 )); then
summary_wk="$(summarize "webkit" "${samples_wk_total[@]}" | tail -1)"
read -r mean_wk p50_wk p95_wk max_wk <<< "$(echo "$summary_wk" | tr ',' ' ')"
fi
summary_total="$(summarize "total" "${samples_total[@]}" | tail -1)"
read -r mean_total p50_total p95_total max_total <<< "$(echo "$summary_total" | tr ',' ' ')"
# Re-print summaries to stdout (the summarize calls above piped them out)
{
printf ' %-10s mean=%5s%% p50=%5s%% p95=%5s%% max=%5s%%\n' "qbz" "$mean_qbz" "$p50_qbz" "$p95_qbz" "$max_qbz"
printf ' %-10s mean=%5s%% p50=%5s%% p95=%5s%% max=%5s%%\n' "webkit" "$mean_wk" "$p50_wk" "$p95_wk" "$max_wk"
printf ' %-10s mean=%5s%% p50=%5s%% p95=%5s%% max=%5s%%\n' "total" "$mean_total" "$p50_total" "$p95_total" "$max_total"
}
# CSV header if first run
if [[ ! -f "$LOG_CSV" ]]; then
echo "timestamp,label,duration_s,window_size,qbz_mean,qbz_p50,qbz_p95,qbz_max,webkit_mean,webkit_p50,webkit_p95,webkit_max,total_mean,total_p50,total_p95,total_max" > "$LOG_CSV"
fi
WINDOW="$(read_window_size)"
echo "$(date -Iseconds),$LABEL,$DURATION,$WINDOW,$mean_qbz,$p50_qbz,$p95_qbz,$max_qbz,$mean_wk,$p50_wk,$p95_wk,$max_wk,$mean_total,$p50_total,$p95_total,$max_total" >> "$LOG_CSV"
echo ""
echo "Appended to $LOG_CSV"
echo "Compare runs: cat $LOG_CSV | column -t -s,"
+2 -2
View File
@@ -3,7 +3,7 @@
name: qbz-player
title: "QBZ — Native Qobuz Client for Linux"
version: '1.2.12'
version: '1.2.13'
summary: Native Qobuz player for Linux with bit-perfect audio and DAC control
description: |
QBZ is a native Linux desktop client for Qobuz™, designed for high-fidelity playback.
@@ -97,7 +97,7 @@ parts:
qbz:
plugin: rust
source: https://github.com/vicrodh/qbz.git
source-tag: v1.2.12
source-tag: v1.2.13
build-packages:
- libwebkit2gtk-4.1-dev
- libgtk-3-dev
+25 -22
View File
@@ -1536,14 +1536,16 @@ dependencies = [
[[package]]
name = "discord-rich-presence"
version = "0.2.5"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75db747ecd252c01bfecaf709b07fcb4c634adf0edb5fed47bc9c3052e7076b"
checksum = "90c55d69cab17c19677ce3a5f8face993a9e6eaf847fecac3547f3a3ff4a2494"
dependencies = [
"log",
"serde",
"serde_derive",
"serde_json",
"serde_repr",
"thiserror 2.0.18",
"uuid 0.8.2",
]
@@ -5021,6 +5023,22 @@ version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
[[package]]
name = "qbz-app"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"chrono",
"dirs",
"log",
"md-5",
"rand 0.10.1",
"rusqlite",
"serde",
"serde_json",
"tokio",
]
[[package]]
name = "qbz-audio"
version = "0.1.0"
@@ -5148,7 +5166,7 @@ dependencies = [
[[package]]
name = "qbz-nix"
version = "1.2.12"
version = "1.2.13"
dependencies = [
"aes-gcm",
"anyhow",
@@ -5177,6 +5195,7 @@ dependencies = [
"mupdf",
"notify-rust",
"open",
"qbz-app",
"qbz-audio",
"qbz-cache",
"qbz-cast",
@@ -5220,7 +5239,7 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tower-http 0.5.2",
"tower-http",
"urlencoding",
"uuid 1.23.1",
"walkdir",
@@ -5724,7 +5743,7 @@ dependencies = [
"tokio-rustls",
"tokio-util",
"tower",
"tower-http 0.6.8",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
@@ -5764,7 +5783,7 @@ dependencies = [
"tokio-rustls",
"tokio-util",
"tower",
"tower-http 0.6.8",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
@@ -7834,22 +7853,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "tower-http"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
dependencies = [
"bitflags 2.11.0",
"bytes",
"http",
"http-body",
"http-body-util",
"pin-project-lite",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-http"
version = "0.6.8"
+4 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "qbz-nix"
version = "1.2.12"
version = "1.2.13"
description = "Native high-fidelity client for Qobuz™ subscribers on Linux with Hi-Fi audio support (uses the Qobuz API; not certified by Qobuz)"
authors = ["blitzkriegfc"]
edition = "2021"
@@ -25,6 +25,7 @@ updater = ["dep:tauri-plugin-updater", "dep:tauri-plugin-process"]
[dependencies]
# QBZ Crates (extracted core functionality)
qbz-app = { path = "../crates/qbz-app" }
qbz-models = { path = "../crates/qbz-models" }
qbz-audio = { path = "../crates/qbz-audio" }
qbz-player = { path = "../crates/qbz-player" }
@@ -66,7 +67,7 @@ tokio-stream = { version = "0.1", features = ["sync"] }
axum = { version = "0.8", features = ["ws"] }
axum-server = { version = "0.8", features = ["tls-rustls"] }
rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs"] }
tower-http = { version = "0.5", features = ["cors"] }
tower-http = { version = "0.6", features = ["cors"] }
qrcode-generator = "5.0"
rcgen = "0.14"
@@ -98,7 +99,7 @@ rand = "0.10"
strsim = "0.11"
# Discord Rich Presence (#336)
discord-rich-presence = "0.2.4"
discord-rich-presence = "1.1.0"
# Audio playback
rodio = { version = "0.22", features = ["symphonia-all"] }
+3
View File
@@ -652,6 +652,7 @@ async fn play_queue_track(ctx: &ApiContext, track: QueueTrack) -> Result<QueueTr
None,
None,
Some(track.duration_secs),
None, // start_position_secs: remote-driven play always starts at 0
bridge,
offline_cache,
audio_settings,
@@ -884,6 +885,7 @@ async fn play_queue_index(
None,
None,
Some(t.duration_secs),
None, // start_position_secs: remote-driven play always starts at 0
bridge,
offline_cache,
audio_settings,
@@ -1059,6 +1061,7 @@ async fn play_album(
None,
None,
Some(first_track.duration_secs),
None, // start_position_secs: remote-driven play always starts at 0
bridge,
offline_cache,
audio_settings,
+49 -314
View File
@@ -1,34 +1,15 @@
//! Graphics auto-configuration tool
//! Graphics auto-configuration shell adapter.
//!
//! Detects the current GPU, display server, desktop environment, and compositor,
//! then recommends and applies optimal graphics settings.
//!
//! Usage: `qbz --autoconfig-graphics`
//! Recommendation and host detection live in `qbz-app`; this module keeps the
//! Tauri/WebKit-specific surfaces that prompt the CLI user and persist the
//! selected recommendation into current settings stores.
use crate::config::graphics_settings::GraphicsSettingsStore;
use qbz_app::graphics_autoconfig::{
compute_recommendation, detect_environment, Environment, Recommendation,
};
use std::io::{self, BufRead, Write};
/// Detected environment information
struct Environment {
display_server: String,
gpu_nvidia: bool,
gpu_amd: bool,
gpu_intel: bool,
gpu_name: String,
desktop: String,
is_vm: bool,
}
/// Recommended configuration
struct Recommendation {
hardware_acceleration: bool,
force_x11: bool,
gsk_renderer: Option<String>,
disable_dmabuf: bool,
disable_blur_background: bool,
rationale: Vec<String>,
}
/// Run the autoconfig-graphics CLI tool.
pub fn run() {
eprintln!("[QBZ AutoConfig] Detecting environment...");
@@ -57,139 +38,6 @@ pub fn run() {
}
}
fn detect_environment() -> Environment {
let display_server = detect_display_server();
let gpu_nvidia = is_nvidia_gpu();
let gpu_amd = is_amd_gpu();
let gpu_intel = is_intel_gpu();
let gpu_name = detect_gpu_name(gpu_nvidia, gpu_amd, gpu_intel);
let desktop = detect_desktop();
let is_vm = is_virtual_machine();
Environment {
display_server,
gpu_nvidia,
gpu_amd,
gpu_intel,
gpu_name,
desktop,
is_vm,
}
}
fn detect_display_server() -> String {
let is_wayland = std::env::var_os("WAYLAND_DISPLAY").is_some()
|| std::env::var("XDG_SESSION_TYPE").as_deref() == Ok("wayland");
if is_wayland {
"Wayland".to_string()
} else {
"X11".to_string()
}
}
fn is_nvidia_gpu() -> bool {
std::path::Path::new("/proc/driver/nvidia/version").exists()
|| std::fs::read_to_string("/proc/modules")
.map(|m| m.lines().any(|l| l.starts_with("nvidia")))
.unwrap_or(false)
}
fn is_amd_gpu() -> bool {
std::path::Path::new("/sys/module/amdgpu").exists()
|| std::fs::read_to_string("/proc/modules")
.map(|m| m.lines().any(|l| l.starts_with("amdgpu")))
.unwrap_or(false)
}
fn is_intel_gpu() -> bool {
std::path::Path::new("/sys/module/i915").exists()
|| std::fs::read_to_string("/proc/modules")
.map(|m| m.lines().any(|l| l.starts_with("i915")))
.unwrap_or(false)
}
fn is_virtual_machine() -> bool {
if let Ok(product) = std::fs::read_to_string("/sys/class/dmi/id/product_name") {
let p = product.trim().to_lowercase();
if p.contains("virtualbox")
|| p.contains("vmware")
|| p.contains("qemu")
|| p.contains("bochs")
|| p.contains("hyper-v")
{
return true;
}
}
if let Ok(vendor) = std::fs::read_to_string("/sys/class/dmi/id/sys_vendor") {
let v = vendor.trim().to_lowercase();
if v.contains("innotek")
|| v.contains("vmware")
|| v.contains("qemu")
|| v.contains("xen")
|| v.contains("parallels")
{
return true;
}
}
if let Ok(h) = std::fs::read_to_string("/sys/hypervisor/type") {
if !h.trim().is_empty() {
return true;
}
}
false
}
fn detect_gpu_name(nvidia: bool, amd: bool, intel: bool) -> String {
// Try to get a human-readable GPU name
if nvidia {
if let Ok(version) = std::fs::read_to_string("/proc/driver/nvidia/version") {
if let Some(line) = version.lines().next() {
return format!("NVIDIA ({})", line.trim());
}
}
return "NVIDIA (driver loaded)".to_string();
}
if amd {
// Try to read GPU model from DRM
if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("card") && !name.contains('-') {
let model_path = entry.path().join("device/product_name");
if let Ok(model) = std::fs::read_to_string(&model_path) {
let model = model.trim();
if !model.is_empty() {
return format!("AMD {}", model);
}
}
}
}
}
return "AMD (amdgpu driver loaded)".to_string();
}
if intel {
return "Intel (i915 driver loaded)".to_string();
}
"Unknown / None detected".to_string()
}
fn detect_desktop() -> String {
let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
let session = std::env::var("XDG_SESSION_DESKTOP").unwrap_or_default();
let de = std::env::var("DESKTOP_SESSION").unwrap_or_default();
if !desktop.is_empty() {
desktop
} else if !session.is_empty() {
session
} else if !de.is_empty() {
de
} else {
"Unknown".to_string()
}
}
fn print_environment(env: &Environment) {
eprintln!(" Display server : {}", env.display_server);
eprintln!(" GPU : {}", env.gpu_name);
@@ -200,130 +48,6 @@ fn print_environment(env: &Environment) {
eprintln!();
}
fn compute_recommendation(env: &Environment) -> Recommendation {
let mut rationale = Vec::new();
// VM: software rendering, no blur
if env.is_vm {
rationale.push("Virtual machine detected: using software rendering".to_string());
return Recommendation {
hardware_acceleration: false,
force_x11: false,
gsk_renderer: Some("cairo".to_string()),
disable_dmabuf: true,
disable_blur_background: true,
rationale,
};
}
let is_wayland = env.display_server == "Wayland";
let desktop_lower = env.desktop.to_lowercase();
let is_gnome = desktop_lower.contains("gnome");
// NVIDIA + Wayland + GNOME: known stutter combo
if env.gpu_nvidia && is_wayland && is_gnome {
rationale.push("NVIDIA + Wayland + GNOME: using GL renderer, DMA-BUF off".to_string());
rationale.push("This combination has known compositing issues".to_string());
return Recommendation {
hardware_acceleration: true,
force_x11: false,
gsk_renderer: Some("gl".to_string()),
disable_dmabuf: true,
disable_blur_background: false,
rationale,
};
}
// NVIDIA + Wayland (non-GNOME)
if env.gpu_nvidia && is_wayland {
rationale.push("NVIDIA + Wayland: using GL renderer, DMA-BUF off".to_string());
return Recommendation {
hardware_acceleration: true,
force_x11: false,
gsk_renderer: Some("gl".to_string()),
disable_dmabuf: true,
disable_blur_background: false,
rationale,
};
}
// NVIDIA + X11
if env.gpu_nvidia {
rationale.push("NVIDIA + X11: full hardware acceleration, DMA-BUF off".to_string());
return Recommendation {
hardware_acceleration: true,
force_x11: false,
gsk_renderer: Some("gl".to_string()),
disable_dmabuf: true,
disable_blur_background: false,
rationale,
};
}
// AMD + Wayland
if env.gpu_amd && is_wayland {
rationale.push("AMD + Wayland: NGL renderer with DMA-BUF".to_string());
return Recommendation {
hardware_acceleration: true,
force_x11: false,
gsk_renderer: Some("ngl".to_string()),
disable_dmabuf: false,
disable_blur_background: false,
rationale,
};
}
// AMD + X11
if env.gpu_amd {
rationale.push("AMD + X11: full hardware acceleration".to_string());
return Recommendation {
hardware_acceleration: true,
force_x11: false,
gsk_renderer: None,
disable_dmabuf: false,
disable_blur_background: false,
rationale,
};
}
// Intel + Wayland
if env.gpu_intel && is_wayland {
rationale.push("Intel + Wayland: NGL renderer with DMA-BUF".to_string());
return Recommendation {
hardware_acceleration: true,
force_x11: false,
gsk_renderer: Some("ngl".to_string()),
disable_dmabuf: false,
disable_blur_background: false,
rationale,
};
}
// Intel + X11
if env.gpu_intel {
rationale.push("Intel + X11: full hardware acceleration".to_string());
return Recommendation {
hardware_acceleration: true,
force_x11: false,
gsk_renderer: None,
disable_dmabuf: false,
disable_blur_background: false,
rationale,
};
}
// Unknown GPU: safe defaults
rationale.push("No known GPU detected: using safe defaults".to_string());
Recommendation {
hardware_acceleration: true,
force_x11: false,
gsk_renderer: None,
disable_dmabuf: false,
disable_blur_background: false,
rationale,
}
}
fn print_recommendation(rec: &Recommendation) {
eprintln!("[QBZ AutoConfig] Recommended configuration:");
eprintln!(
@@ -361,9 +85,40 @@ fn print_recommendation(rec: &Recommendation) {
}
fn apply_recommendation(rec: &Recommendation) {
match write_recommendation(rec) {
Ok(()) => {
if rec.disable_blur_background {
eprintln!(
"[QBZ AutoConfig] Note: blur background will be disabled. You can toggle this in Settings > Appearance."
);
}
eprintln!();
eprintln!("[QBZ AutoConfig] Configuration applied successfully.");
eprintln!("[QBZ AutoConfig] Restart QBZ to take effect.");
}
Err(errors) => {
eprintln!();
eprintln!("[QBZ AutoConfig] Some settings could not be applied:");
for e in &errors {
eprintln!(" - {}", e);
}
}
}
}
/// Apply the recommendation to the persistence layer. Used by the CLI prompt
/// (`apply_recommendation`); the Settings UI is advisory-only and no longer
/// writes the recommendation. Returns the list of write errors so the caller
/// can surface them on stderr.
///
/// DMA-BUF semantics after the 1.2.13 opt-in flip:
/// - `rec.disable_dmabuf = true` -> force_dmabuf = false (matches default;
/// runtime keeps DMA-BUF off).
/// - `rec.disable_dmabuf = false` -> force_dmabuf = true (user opts in via
/// this recommendation; runtime turns DMA-BUF on).
fn write_recommendation(rec: &Recommendation) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
// Apply graphics settings to DB
match GraphicsSettingsStore::new() {
Ok(store) => {
if let Err(e) = store.set_hardware_acceleration(rec.hardware_acceleration) {
@@ -381,41 +136,21 @@ fn apply_recommendation(rec: &Recommendation) {
}
}
// Apply developer settings (force_dmabuf)
if rec.disable_dmabuf {
// Note: we don't set force_dmabuf=true here because the default behavior
// already disables DMA-BUF for NVIDIA+Wayland. We only reset it to ensure
// it's not forced ON.
match crate::config::developer_settings::DeveloperSettingsStore::new() {
Ok(store) => {
if let Err(e) = store.set_force_dmabuf(false) {
errors.push(format!("force_dmabuf: {}", e));
}
}
Err(e) => {
errors.push(format!("developer settings store: {}", e));
let desired_force_dmabuf = !rec.disable_dmabuf;
match crate::config::developer_settings::DeveloperSettingsStore::new() {
Ok(store) => {
if let Err(e) = store.set_force_dmabuf(desired_force_dmabuf) {
errors.push(format!("force_dmabuf: {}", e));
}
}
}
// Apply blur background setting to localStorage config file
// Note: This is stored in localStorage by the frontend, so we write a hint
// that the frontend will read on next startup
if rec.disable_blur_background {
eprintln!(
"[QBZ AutoConfig] Note: blur background will be disabled. You can toggle this in Settings > Appearance."
);
Err(e) => {
errors.push(format!("developer settings store: {}", e));
}
}
if errors.is_empty() {
eprintln!();
eprintln!("[QBZ AutoConfig] Configuration applied successfully.");
eprintln!("[QBZ AutoConfig] Restart QBZ to take effect.");
Ok(())
} else {
eprintln!();
eprintln!("[QBZ AutoConfig] Some settings could not be applied:");
for e in &errors {
eprintln!(" - {}", e);
}
Err(errors)
}
}
+162
View File
@@ -0,0 +1,162 @@
//! Boot watchdog for graphics settings that can crash WebKit before it
//! paints a single frame (HW acceleration, DMA-BUF, preferred-GPU
//! selection that picks an unreachable stack).
//!
//! Flow:
//! 1. `before_webkit_init()` is called at startup with the risky
//! settings of the current boot. It writes a `pending.json` marker
//! to `<data_dir>/qbz/boot_state/` recording which settings were
//! attempted.
//! 2. After WebKit boots and the frontend signals first-paint (via
//! `v2_mark_boot_succeeded`), `mark_boot_succeeded()` removes the
//! pending marker and writes `last-good.json` carrying the
//! successful settings.
//! 3. On the NEXT boot, `before_webkit_init` first calls
//! `check_previous_boot()`. If a pending marker exists without a
//! matching success, the previous boot crashed during graphics
//! init — we auto-revert the offending setting(s), record a
//! crash recovery flag, and only THEN write the new pending
//! marker for the current boot.
//!
//! Two consecutive crashes with the same setting promote to "lockout"
//! state: the setting stays disabled until the user explicitly clears
//! the flag from Settings. Prevents an infinite retry loop.
use qbz_app::boot_watchdog::{
clear_crash_flag_value, mark_boot_success_flags, resolve_boot_attempt,
};
pub use qbz_app::boot_watchdog::{BootAttempt, CrashFlags, WatchdogResolution};
use std::fs;
use std::path::PathBuf;
const STATE_DIR: &str = "boot_state";
const PENDING_FILE: &str = "pending.json";
const LAST_GOOD_FILE: &str = "last-good.json";
const CRASH_FLAGS_FILE: &str = "crash-flags.json";
fn state_dir() -> Option<PathBuf> {
let base = dirs::data_dir()?.join("qbz").join(STATE_DIR);
fs::create_dir_all(&base).ok()?;
Some(base)
}
fn read_pending() -> Option<BootAttempt> {
let path = state_dir()?.join(PENDING_FILE);
let text = fs::read_to_string(&path).ok()?;
serde_json::from_str(&text).ok()
}
fn write_pending(attempt: &BootAttempt) {
let Some(dir) = state_dir() else {
return;
};
if let Ok(text) = serde_json::to_string_pretty(attempt) {
let _ = fs::write(dir.join(PENDING_FILE), text);
}
}
fn clear_pending() {
if let Some(dir) = state_dir() {
let _ = fs::remove_file(dir.join(PENDING_FILE));
}
}
fn write_last_good(attempt: &BootAttempt) {
let Some(dir) = state_dir() else {
return;
};
if let Ok(text) = serde_json::to_string_pretty(attempt) {
let _ = fs::write(dir.join(LAST_GOOD_FILE), text);
}
}
fn read_crash_flags() -> CrashFlags {
let Some(dir) = state_dir() else {
return CrashFlags::default();
};
let path = dir.join(CRASH_FLAGS_FILE);
let Ok(text) = fs::read_to_string(&path) else {
return CrashFlags::default();
};
serde_json::from_str(&text).unwrap_or_default()
}
fn write_crash_flags(flags: &CrashFlags) {
let Some(dir) = state_dir() else {
return;
};
if let Ok(text) = serde_json::to_string_pretty(flags) {
let _ = fs::write(dir.join(CRASH_FLAGS_FILE), text);
}
}
/// Called at startup with the settings the current boot WOULD apply if
/// nothing crashed. Returns the resolved settings to actually use —
/// usually the same input, but with risky knobs reverted if the
/// previous boot left a pending marker.
///
/// Also writes the new pending marker for THIS boot so a crash before
/// the frontend signals success is detectable on the next launch.
pub fn before_webkit_init(intended: BootAttempt) -> WatchdogResolution {
let decision = resolve_boot_attempt(intended.clone(), read_pending(), read_crash_flags());
if decision.should_persist_reverts {
persist_reverts_to_db(&decision.resolved_attempt, &intended);
}
write_crash_flags(&decision.crash_flags);
write_pending(&decision.resolved_attempt);
decision.resolution()
}
/// Called from a Tauri command after the frontend signals first paint.
/// Removes the pending marker so the next launch doesn't think this
/// boot crashed; writes last-good as the new baseline; clears
/// consecutive-failure counters for whatever settings were active.
pub fn mark_boot_succeeded() {
// Read what we attempted in this boot — the pending file is the
// source of truth for "what was active when WebKit first paint
// happened".
let attempt = read_pending().unwrap_or_default();
clear_pending();
write_last_good(&attempt);
let flags = mark_boot_success_flags(&attempt, read_crash_flags());
write_crash_flags(&flags);
}
/// Read-only view of the crash flags for the Settings UI.
pub fn get_crash_flags() -> CrashFlags {
read_crash_flags()
}
/// Clear a single crash recovery flag by name. Frontend uses this when
/// the user clicks "Try again" / "I want to re-enable this" on the
/// recovery banner. Unknown names are no-ops.
pub fn clear_crash_flag(flag: &str) {
if let Some(flags) = clear_crash_flag_value(read_crash_flags(), flag) {
write_crash_flags(&flags);
}
}
/// Best-effort write of reverted values back to graphics_settings.db
/// and developer_settings.db. Failure is non-fatal — the runtime
/// values for THIS boot are still the reverted ones (we pass them
/// back to main.rs via WatchdogResolution), and the next boot's read
/// will see the reverted DB rows too.
fn persist_reverts_to_db(resolved: &BootAttempt, intended: &BootAttempt) {
if resolved.hardware_acceleration != intended.hardware_acceleration {
if let Ok(store) = crate::config::graphics_settings::GraphicsSettingsStore::new() {
let _ = store.set_hardware_acceleration(resolved.hardware_acceleration);
}
}
if resolved.force_dmabuf != intended.force_dmabuf {
if let Ok(store) = crate::config::developer_settings::DeveloperSettingsStore::new() {
let _ = store.set_force_dmabuf(resolved.force_dmabuf);
}
}
if resolved.preferred_gpu != intended.preferred_gpu {
if let Ok(store) = crate::config::graphics_settings::GraphicsSettingsStore::new() {
let _ = store.set_preferred_gpu(&resolved.preferred_gpu);
}
}
}
+37 -4
View File
@@ -299,18 +299,51 @@ pub async fn v2_get_release_watch(
results
.items
.retain(|album| !blacklist_state.is_blacklisted(album.artist.id));
let filtered_count = original_count - results.items.len();
if filtered_count > 0 {
let blacklist_filtered = original_count - results.items.len();
if blacklist_filtered > 0 {
log::debug!(
"[V2/Blacklist] Filtered {} albums from release watch",
filtered_count
blacklist_filtered
);
results.total = results.total.saturating_sub(filtered_count as u32);
}
let before_unreleased = results.items.len();
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
results.items.retain(|album| is_album_available(album, &today));
let unreleased_filtered = before_unreleased - results.items.len();
if unreleased_filtered > 0 {
log::debug!(
"[V2/ReleaseWatch] Filtered {} unreleased/unstreamable albums",
unreleased_filtered
);
}
let total_filtered = blacklist_filtered + unreleased_filtered;
if total_filtered > 0 {
results.total = results.total.saturating_sub(total_filtered as u32);
}
Ok(results)
}
/// Whether an album from Release Watch is actually available to fetch.
///
/// Drops albums that are either marked non-streamable (regional / takedown)
/// or whose stream availability date is in the future. Upcoming releases
/// 404 on `get_album`, so they should never reach the UI grid in the first
/// place.
fn is_album_available(album: &Album, today_iso: &str) -> bool {
if matches!(album.streamable, Some(false)) {
return false;
}
if let Some(stream_date) = album.release_date_stream.as_deref() {
if stream_date > today_iso {
return false;
}
}
true
}
/// Get artist page (V2 - uses QbzCore)
#[tauri::command]
#[allow(non_snake_case)]
+14
View File
@@ -69,6 +69,11 @@ pub struct RuntimeDiagnostics {
pub runtime_is_vm: bool,
pub runtime_hw_accel_enabled: bool,
pub runtime_force_x11_active: bool,
/// Human-readable GPU model name (driver-reported on Linux).
/// For hybrid laptops joins both vendors: "NVIDIA (...) + Intel (...)".
pub runtime_gpu_name: String,
/// Desktop environment string ($XDG_CURRENT_DESKTOP or fallbacks).
pub runtime_desktop_environment: String,
// Developer settings
pub dev_force_dmabuf: bool,
@@ -152,6 +157,15 @@ pub fn v2_get_runtime_diagnostics(
runtime_is_vm: gfx_status.is_vm,
runtime_hw_accel_enabled: gfx_status.hardware_accel_enabled,
runtime_force_x11_active: gfx_status.force_x11_active,
runtime_gpu_name: qbz_app::graphics_autoconfig::detect_gpu_name(
gfx_status.has_nvidia,
gfx_status.has_amd,
gfx_status.has_intel,
),
runtime_desktop_environment: std::env::var("XDG_CURRENT_DESKTOP")
.or_else(|_| std::env::var("XDG_SESSION_DESKTOP"))
.or_else(|_| std::env::var("DESKTOP_SESSION"))
.unwrap_or_else(|_| "Unknown".to_string()),
dev_force_dmabuf: dev.force_dmabuf,
@@ -0,0 +1,91 @@
//! V2 commands that expose the graphics auto-config recommendation to the
//! Settings UI. The detection + decision logic lives in
//! `autoconfig_graphics.rs` (shared with the `qbz --autoconfig-graphics` CLI
//! tool); this module is just the Tauri surface that the Graphics tab uses
//! to render the "Detected / Recommended" banner.
use qbz_app::graphics_autoconfig::{
compute_recommendation, detect_environment, Environment, Recommendation,
};
use serde::Serialize;
/// Payload returned to the Settings UI. Splits environment from
/// recommendation so the frontend can show a human-readable detection
/// line and then compare the recommendation to the user's current
/// persisted settings to decide whether to surface the banner at all.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct GraphicsRecommendationPayload {
pub environment: Environment,
pub recommendation: Recommendation,
}
/// Compute the recommendation for the current host. Cheap — runs the
/// same detection that the CLI tool runs and synthesizes the matrix
/// decision. The Settings UI calls this on Graphics-tab mount.
#[tauri::command]
pub fn v2_get_graphics_recommendation() -> GraphicsRecommendationPayload {
let environment = detect_environment();
let recommendation = compute_recommendation(&environment);
GraphicsRecommendationPayload {
environment,
recommendation,
}
}
/// Payload for the rendering-GPU dropdown in Settings > Graphics.
/// `gpus` is the full list (physical PCI devices intersected with
/// available EGL vendor stacks, plus the Software fallback).
/// `auto_resolved_id` says which entry, if any, "Auto" currently pins
/// to on this system — frontend uses it to label the Auto option (e.g.
/// "Automatic (Intel Graphics)") so the user can see what default they
/// are getting without picking explicitly. `None` means Auto is a
/// no-op on this system (single GPU or no GPU detected).
#[derive(serde::Serialize)]
pub struct GpuList {
pub gpus: Vec<crate::graphics_detection::DetectedGpu>,
pub auto_resolved_id: Option<String>,
}
/// Enumerate detected GPUs for the rendering-GPU dropdown in
/// Settings > Graphics. Returns physical PCI devices intersected with
/// available EGL vendor stacks — entries with `is_usable = false`
/// should be hidden / disabled by the UI. Always includes a Software
/// fallback so the list is never empty.
#[tauri::command]
pub fn v2_enumerate_gpus() -> GpuList {
let gpus = crate::graphics_detection::enumerate_gpus();
let auto_resolved_id =
crate::graphics_detection::auto_resolves_to(&gpus).map(|g| g.id.clone());
GpuList {
gpus,
auto_resolved_id,
}
}
/// Called by the frontend after first paint to clear the boot
/// watchdog's pending marker. If this is never called (because WebKit
/// crashed before painting), the next launch sees the marker and
/// auto-reverts the offending risky setting.
#[tauri::command]
pub fn v2_mark_boot_succeeded() {
crate::boot_watchdog::mark_boot_succeeded();
}
/// Read the sticky crash flags so Settings > Graphics can render
/// the recovery banner. The flags say which risky setting was
/// auto-reverted in this boot, the consecutive-failure counter, and
/// whether the knob is in lockout state (2+ failures → user must
/// explicitly clear before it can be re-enabled).
#[tauri::command]
pub fn v2_get_crash_flags() -> crate::boot_watchdog::CrashFlags {
crate::boot_watchdog::get_crash_flags()
}
/// User-driven clear of a single crash recovery flag. Called when the
/// user clicks "Try again" / "Re-enable" on the banner. Valid flag
/// names: "hardware_acceleration", "force_dmabuf", "preferred_gpu".
#[tauri::command]
pub fn v2_clear_crash_flag(flag: String) {
crate::boot_watchdog::clear_crash_flag(&flag);
}
+1 -21
View File
@@ -98,27 +98,7 @@ pub struct V2SearchAllResults {
/// Used by runtime_bootstrap (once at startup) and v2_reinit_audio_device
/// to ensure the Player has fresh settings from the database.
pub(crate) fn convert_to_qbz_audio_settings(settings: &AudioSettings) -> qbz_audio::AudioSettings {
qbz_audio::AudioSettings {
output_device: settings.output_device.clone(),
exclusive_mode: settings.exclusive_mode,
dac_passthrough: settings.dac_passthrough,
preferred_sample_rate: settings.preferred_sample_rate,
limit_quality_to_device: settings.limit_quality_to_device,
device_max_sample_rate: settings.device_max_sample_rate,
device_sample_rate_limits: settings.device_sample_rate_limits.clone(),
backend_type: settings.backend_type.clone(),
alsa_plugin: settings.alsa_plugin.clone(),
alsa_hardware_volume: settings.alsa_hardware_volume,
stream_first_track: settings.stream_first_track,
stream_buffer_seconds: settings.stream_buffer_seconds,
streaming_only: settings.streaming_only,
normalization_enabled: settings.normalization_enabled,
normalization_target_lufs: settings.normalization_target_lufs,
gapless_enabled: settings.gapless_enabled,
pw_force_bitperfect: settings.pw_force_bitperfect,
skip_sink_switch: settings.skip_sink_switch,
allow_quality_fallback: settings.allow_quality_fallback,
}
settings.clone()
}
/// Reload audio settings from the per-user store into the CoreBridge player.
+57
View File
@@ -2580,6 +2580,63 @@ pub async fn v2_library_get_albums_metadata(
.map_err(|e| e.to_string())
}
/// V2 paginated metadata-grouped albums for Local Library. Backs the
/// recycling-grid pool + chunked store on the frontend: caller asks for
/// `[offset, offset+limit)` and receives that page plus the total count
/// of albums matching the same filter (drives scrollbar pre-allocation).
///
/// When `include_plex` is true and the `plex_cache.db` cache file
/// exists in the user's data dir, the result is the UNION of local and
/// plex sources — sort, filter, and pagination apply to both at once.
#[tauri::command]
pub async fn v2_library_get_albums_page(
offset: u64,
limit: u64,
search: Option<String>,
sort_by: Option<String>,
sort_dir: Option<String>,
exclude_network_folders: Option<bool>,
include_plex: Option<bool>,
state: State<'_, LibraryState>,
download_settings_state: State<'_, DownloadSettingsState>,
) -> Result<qbz_library::AlbumsMetadataPage, String> {
let include_qobuz = download_settings_state
.lock()
.map_err(|e| format!("Failed to lock download settings: {}", e))?
.as_ref()
.and_then(|s| s.get_settings().ok())
.map(|s| s.show_in_library)
.unwrap_or(false);
// Resolve the plex_cache.db path the same way `plex/mod.rs`
// does (`dirs::data_dir() + qbz/plex_cache.db`). The qbz-library
// crate doesn't know about Plex — passing `None` here keeps the
// local-only behavior; passing the path lets the SQL ATTACH + UNION
// light up.
let plex_path: Option<std::path::PathBuf> = if include_plex.unwrap_or(false) {
dirs::data_dir().map(|d| d.join("qbz").join("plex_cache.db"))
} else {
None
};
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.get_albums_metadata_page(
offset,
limit,
search.as_deref(),
sort_by.as_deref().unwrap_or("artist"),
sort_dir.as_deref().unwrap_or("asc"),
include_qobuz,
exclude_network_folders.unwrap_or(false),
plex_path.as_deref(),
)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_library_get_stats(
state: State<'_, LibraryState>,
@@ -815,6 +815,36 @@ pub fn v2_set_gsk_renderer(
store.set_gsk_renderer(value)
}
#[tauri::command]
pub fn v2_set_preferred_gpu(
value: String,
state: State<'_, GraphicsSettingsState>,
) -> Result<(), String> {
let guard = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard
.as_ref()
.ok_or("Graphics settings store not initialized")?;
store.set_preferred_gpu(&value)
}
#[tauri::command]
pub fn v2_set_nvidia_compat_mode(
enabled: bool,
state: State<'_, GraphicsSettingsState>,
) -> Result<(), String> {
let guard = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard
.as_ref()
.ok_or("Graphics settings store not initialized")?;
store.set_nvidia_compat_mode(enabled)
}
#[tauri::command]
pub fn v2_clear_cache(state: State<'_, AppState>) -> Result<(), String> {
state.audio_cache.clear();
+3
View File
@@ -23,6 +23,9 @@ pub struct V2PlaylistSuggestionsInput {
pub(crate) mod helpers;
pub use helpers::*;
mod graphics_recommendation;
pub use graphics_recommendation::*;
mod runtime;
pub use runtime::*;
+9
View File
@@ -887,12 +887,18 @@ pub struct V2PlayTrackResult {
/// 3. Downloads audio
/// 4. Plays via CoreBridge.player() (qbz-player crate)
/// 5. Caches for future playback
// `start_position_secs` (#315): when > 0, the streaming path waits for
// enough buffer to cover this offset and pre-skips decoder output up to
// that point, then engages audio. Avoids the play-then-seek race that
// silently dropped the saved position on first-track session restore.
// 0 / None = start from beginning.
#[tauri::command]
pub async fn v2_play_track(
track_id: u64,
quality: Option<String>,
force_lowest_quality: Option<bool>,
duration_secs: Option<u64>,
start_position_secs: Option<u64>,
bridge: State<'_, CoreBridgeState>,
offline_cache: State<'_, OfflineCacheState>,
audio_settings: State<'_, AudioSettingsState>,
@@ -901,6 +907,7 @@ pub async fn v2_play_track(
app_handle: tauri::AppHandle,
runtime: State<'_, RuntimeManagerState>,
) -> Result<V2PlayTrackResult, RuntimeError> {
let start_position_secs = start_position_secs.unwrap_or(0);
// Runtime contract: require CoreBridge auth for V2 playback
runtime
.manager()
@@ -1294,6 +1301,7 @@ pub async fn v2_play_track(
total_flac_size,
speed_mbps,
duration_secs.unwrap_or(0),
start_position_secs,
)
.map_err(RuntimeError::Internal)?;
@@ -1528,6 +1536,7 @@ pub async fn v2_play_track(
stream_info.content_length,
stream_info.speed_mbps,
duration_secs.unwrap_or(0),
start_position_secs,
)
.map_err(RuntimeError::Internal)?;
+5 -747
View File
@@ -1,754 +1,10 @@
//! Audio settings persistence
//!
//! Stores user preferences for audio output device, exclusive mode, and DAC passthrough.
//! Tauri command adapters for the canonical qbz-audio settings store.
use crate::audio::{AlsaPlugin, AudioBackendType};
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioSettings {
pub output_device: Option<String>, // None = system default
pub exclusive_mode: bool,
pub dac_passthrough: bool,
pub preferred_sample_rate: Option<u32>, // None = auto
pub backend_type: Option<AudioBackendType>, // None = auto-detect
pub alsa_plugin: Option<AlsaPlugin>, // Only used when backend is ALSA
pub alsa_hardware_volume: bool, // Use ALSA mixer for volume (only with hw: devices)
/// When true, uncached tracks start playing via streaming instead of waiting for full download
pub stream_first_track: bool,
/// Initial buffer size in seconds before starting streaming playback (1-10, default 3)
pub stream_buffer_seconds: u8,
/// When true, skip L1+L2 cache writes (streaming-only mode). Offline cache still works.
pub streaming_only: bool,
/// When true, limit streaming quality to device's max supported sample rate.
/// This ensures bit-perfect playback by avoiding tracks that exceed device capabilities.
/// Default: true (recommended for bit-perfect setups)
pub limit_quality_to_device: bool,
/// Cached max sample rate of the selected device (set when device is selected)
/// Used when limit_quality_to_device is true
pub device_max_sample_rate: Option<u32>,
/// Per-device sample rate limits: device_id -> max_sample_rate
/// Allows different DACs to have independent max sample rate configurations
#[serde(default)]
pub device_sample_rate_limits: HashMap<String, u32>,
/// When true, apply volume normalization using ReplayGain metadata.
/// When false (default), the audio pipeline is 100% bit-perfect — no sample modification.
pub normalization_enabled: bool,
/// Target loudness in LUFS for normalization.
/// Common values: -14.0 (Spotify/YouTube), -18.0 (audiophile), -23.0 (EBU broadcast)
pub normalization_target_lufs: f32,
/// When true, consecutive same-format tracks play without gap.
/// Works on Rodio (PipeWire/Pulse) and ALSA Direct backends. Requires cached tracks.
pub gapless_enabled: bool,
/// When true, force PipeWire clock.force-quantum alongside clock.force-rate for bit-perfect.
/// Reset both to 0 on stop. PipeWire-only, requires dac_passthrough.
pub pw_force_bitperfect: bool,
/// When true, reload audio settings from DB into the player on app startup.
/// Useful when Player::new() may hold stale settings (e.g., after Flatpak updates).
/// Default: false (most users don't need this).
pub sync_audio_on_startup: bool,
/// User preference for what happens when all quality retries fail.
/// Values: "ask" (default), "always_fallback", "always_skip"
/// Protected by ADR-003: must survive reset_all() and migrations.
pub quality_fallback_behavior: String,
/// When true, skip `pactl set-default-sink` on stream creation.
/// Preserves external routing (JACK, qjackctl, Reaper).
/// Mutually exclusive with dac_passthrough.
pub skip_sink_switch: bool,
/// When true, automatically try lower quality tiers if the requested one fails.
/// When false (default), playback or download fails if the exact quality is unavailable.
pub allow_quality_fallback: bool,
/// When true, hold a per-process ALSA device reservation (Lifetime B) for the
/// configured output device while QBZ is running, so other PulseAudio/PipeWire
/// clients won't grab the DAC and break exclusive playback. Off by default.
/// See `qbz-nix-docs/specs/2026-05-07-alsa-exclusive-hardening-design.md`.
#[serde(default)]
pub reserve_dac_while_running: bool,
}
impl Default for AudioSettings {
fn default() -> Self {
Self {
output_device: None,
exclusive_mode: false,
dac_passthrough: false,
preferred_sample_rate: None,
// Default to a per-OS-correct backend (PipeWire on Linux, SystemDefault
// elsewhere) instead of None. None used to mean "auto-detect" but it
// actually fell through to the legacy CPAL path in init_device, which
// has a known race against rodio's DeviceSink drop on resume — visible
// as "audio doesn't come back after pause" (#375). Picking a concrete
// backend here puts new installs on the proper backend init path; the
// runtime still degrades gracefully if the chosen backend isn't
// available on the host.
backend_type: Some(AudioBackendType::default()),
alsa_plugin: Some(AlsaPlugin::Hw), // Default to hw (bit-perfect)
alsa_hardware_volume: false, // Disabled by default (maximum compatibility)
stream_first_track: false, // Disabled by default — user opts in
stream_buffer_seconds: 3, // 3 seconds initial buffer
streaming_only: false, // Disabled by default (cache tracks for instant replay)
limit_quality_to_device: false, // Disabled in 1.1.9 — detection logic unreliable (#45)
device_max_sample_rate: None, // Set when device is selected
device_sample_rate_limits: HashMap::new(), // Per-device limits (empty = no limit)
normalization_enabled: false, // Off by default — preserves bit-perfect pipeline
normalization_target_lufs: -14.0, // Spotify/YouTube standard
gapless_enabled: true, // On by default — works for same-format tracks on all backends
pw_force_bitperfect: false, // Off by default — experimental PipeWire feature
sync_audio_on_startup: false, // Off by default — opt-in for stale-settings edge case
quality_fallback_behavior: "ask".to_string(),
skip_sink_switch: false, // Off by default — only for JACK/DAW routing setups
allow_quality_fallback: false, // Off by default — fail rather than silently downgrade
reserve_dac_while_running: false, // Off by default — opt-in DAC reservation (Lifetime B)
}
}
}
pub struct AudioSettingsStore {
conn: Connection,
}
impl AudioSettingsStore {
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open audio settings database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to enable WAL for audio settings database: {}", e))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS audio_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
output_device TEXT,
exclusive_mode INTEGER NOT NULL DEFAULT 0,
dac_passthrough INTEGER NOT NULL DEFAULT 0,
preferred_sample_rate INTEGER,
backend_type TEXT,
alsa_plugin TEXT,
alsa_hardware_volume INTEGER NOT NULL DEFAULT 0,
stream_first_track INTEGER NOT NULL DEFAULT 0,
stream_buffer_seconds INTEGER NOT NULL DEFAULT 3
);
INSERT OR IGNORE INTO audio_settings (id, exclusive_mode, dac_passthrough)
VALUES (1, 0, 0);",
)
.map_err(|e| format!("Failed to create audio settings table: {}", e))?;
// Migration: Add new columns if they don't exist (for existing databases)
let _ = conn.execute(
"ALTER TABLE audio_settings ADD COLUMN backend_type TEXT",
[],
);
let _ = conn.execute("ALTER TABLE audio_settings ADD COLUMN alsa_plugin TEXT", []);
let _ = conn.execute(
"ALTER TABLE audio_settings ADD COLUMN alsa_hardware_volume INTEGER DEFAULT 0",
[],
);
let _ = conn.execute(
"ALTER TABLE audio_settings ADD COLUMN stream_first_track INTEGER DEFAULT 0",
[],
);
let _ = conn.execute(
"ALTER TABLE audio_settings ADD COLUMN stream_buffer_seconds INTEGER DEFAULT 3",
[],
);
let _ = conn.execute(
"ALTER TABLE audio_settings ADD COLUMN streaming_only INTEGER DEFAULT 0",
[],
);
let _ = conn.execute(
"ALTER TABLE audio_settings ADD COLUMN limit_quality_to_device INTEGER DEFAULT 0",
[],
);
let _ = conn.execute(
"ALTER TABLE audio_settings ADD COLUMN device_max_sample_rate INTEGER",
[],
);
let _ = conn.execute(
"ALTER TABLE audio_settings ADD COLUMN normalization_enabled INTEGER DEFAULT 0",
[],
);
let _ = conn.execute(
"ALTER TABLE audio_settings ADD COLUMN normalization_target_lufs REAL DEFAULT -14.0",
[],
);
let _ = conn.execute(
"ALTER TABLE audio_settings ADD COLUMN gapless_enabled INTEGER DEFAULT 0",
[],
);
let _ = conn.execute(
"ALTER TABLE audio_settings ADD COLUMN device_sample_rate_limits TEXT",
[],
);
let _ = conn.execute(
"ALTER TABLE audio_settings ADD COLUMN pw_force_bitperfect INTEGER DEFAULT 0",
[],
);
let _ = conn.execute(
"ALTER TABLE audio_settings ADD COLUMN sync_audio_on_startup INTEGER DEFAULT 0",
[],
);
let _ = conn.execute(
"ALTER TABLE audio_settings ADD COLUMN quality_fallback_behavior TEXT DEFAULT 'ask'",
[],
);
let _ = conn.execute(
"ALTER TABLE audio_settings ADD COLUMN skip_sink_switch INTEGER DEFAULT 0",
[],
);
let _ = conn.execute(
"ALTER TABLE audio_settings ADD COLUMN allow_quality_fallback INTEGER DEFAULT 0",
[],
);
let _ = conn.execute(
"ALTER TABLE audio_settings ADD COLUMN reserve_dac_while_running INTEGER DEFAULT 0",
[],
);
// Migration (#375): existing installs persisted backend_type = NULL when
// the user never explicitly picked a backend. NULL fell through to the
// legacy CPAL path in the player, which has a race against rodio's
// DeviceSink drop on resume that produces silent streams after pause.
// Backfill NULL rows with the per-OS default so those installs move onto
// the proper backend init path. set_backend_type is the canonical writer
// (it serialises via serde_json), so we go through the same JSON shape
// here for the row read path to round-trip cleanly.
let default_backend_json = serde_json::to_string(&AudioBackendType::default())
.unwrap_or_else(|_| {
// Fallback only matters if serde_json::to_string fails for a
// unit-variant enum, which it can't — but keep a sane string in
// case the enum gains a non-trivial variant in the future.
if cfg!(target_os = "linux") {
"\"PipeWire\"".to_string()
} else {
"\"SystemDefault\"".to_string()
}
});
let _ = conn.execute(
"UPDATE audio_settings SET backend_type = ?1 WHERE id = 1 AND backend_type IS NULL",
params![default_backend_json],
);
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
Self::open_at(&data_dir, "audio_settings.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "audio_settings.db")
}
pub fn get_settings(&self) -> Result<AudioSettings, String> {
self.conn
.query_row(
"SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, sync_audio_on_startup, quality_fallback_behavior, skip_sink_switch, allow_quality_fallback, reserve_dac_while_running FROM audio_settings WHERE id = 1",
[],
|row| {
// Parse backend_type from JSON string
let backend_type: Option<AudioBackendType> = row
.get::<_, Option<String>>(4)?
.and_then(|s| serde_json::from_str(&s).ok());
// Parse alsa_plugin from JSON string
let alsa_plugin: Option<AlsaPlugin> = row
.get::<_, Option<String>>(5)?
.and_then(|s| serde_json::from_str(&s).ok());
// Parse device_sample_rate_limits from JSON string
let device_sample_rate_limits: HashMap<String, u32> = row
.get::<_, Option<String>>(15)?
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
Ok(AudioSettings {
output_device: row.get(0)?,
exclusive_mode: row.get::<_, i64>(1)? != 0,
dac_passthrough: row.get::<_, i64>(2)? != 0,
preferred_sample_rate: row.get(3)?,
backend_type,
alsa_plugin,
alsa_hardware_volume: row.get::<_, Option<i64>>(6)?.unwrap_or(0) != 0,
stream_first_track: row.get::<_, Option<i64>>(7)?.unwrap_or(0) != 0,
stream_buffer_seconds: row.get::<_, Option<i64>>(8)?.unwrap_or(3) as u8,
streaming_only: row.get::<_, Option<i64>>(9)?.unwrap_or(0) != 0,
limit_quality_to_device: row.get::<_, Option<i64>>(10)?.unwrap_or(1) != 0,
device_max_sample_rate: row.get::<_, Option<i64>>(11)?.map(|r| r as u32),
device_sample_rate_limits,
normalization_enabled: row.get::<_, Option<i64>>(12)?.unwrap_or(0) != 0,
normalization_target_lufs: row.get::<_, Option<f64>>(13)?.unwrap_or(-14.0) as f32,
gapless_enabled: row.get::<_, Option<i64>>(14)?.unwrap_or(0) != 0,
pw_force_bitperfect: row.get::<_, Option<i64>>(16)?.unwrap_or(0) != 0,
sync_audio_on_startup: row.get::<_, Option<i64>>(17)?.unwrap_or(0) != 0,
quality_fallback_behavior: row
.get::<_, Option<String>>(18)?
.unwrap_or_else(|| "ask".to_string()),
skip_sink_switch: row.get::<_, Option<i64>>(19)?.unwrap_or(0) != 0,
allow_quality_fallback: row.get::<_, Option<i64>>(20)?.unwrap_or(0) != 0,
reserve_dac_while_running: row
.get::<_, Option<i64>>(21)?
.unwrap_or(0)
!= 0,
})
},
)
.map_err(|e| format!("Failed to get audio settings: {}", e))
}
pub fn set_output_device(&self, device: Option<&str>) -> Result<(), String> {
self.conn
.execute(
"UPDATE audio_settings SET output_device = ?1 WHERE id = 1",
params![device],
)
.map_err(|e| format!("Failed to set output device: {}", e))?;
Ok(())
}
pub fn set_exclusive_mode(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE audio_settings SET exclusive_mode = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set exclusive mode: {}", e))?;
Ok(())
}
pub fn set_dac_passthrough(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE audio_settings SET dac_passthrough = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set DAC passthrough: {}", e))?;
Ok(())
}
pub fn set_sample_rate(&self, rate: Option<u32>) -> Result<(), String> {
self.conn
.execute(
"UPDATE audio_settings SET preferred_sample_rate = ?1 WHERE id = 1",
params![rate.map(|r| r as i64)],
)
.map_err(|e| format!("Failed to set sample rate: {}", e))?;
Ok(())
}
pub fn set_backend_type(&self, backend: Option<AudioBackendType>) -> Result<(), String> {
let backend_json = backend
.map(|b| serde_json::to_string(&b))
.transpose()
.map_err(|e| format!("Failed to serialize backend type: {}", e))?;
self.conn
.execute(
"UPDATE audio_settings SET backend_type = ?1 WHERE id = 1",
params![backend_json],
)
.map_err(|e| format!("Failed to set backend type: {}", e))?;
// When switching to ALSA, ensure alsa_plugin has a value (default to Hw)
if backend == Some(AudioBackendType::Alsa) {
let current_plugin: Option<String> = self
.conn
.query_row(
"SELECT alsa_plugin FROM audio_settings WHERE id = 1",
[],
|row| row.get(0),
)
.map_err(|e| format!("Failed to check alsa_plugin: {}", e))?;
if current_plugin.is_none() {
log::info!("[AudioSettings] ALSA backend selected with no plugin set, defaulting to Hw");
self.set_alsa_plugin(Some(AlsaPlugin::Hw))?;
}
}
Ok(())
}
pub fn set_alsa_plugin(&self, plugin: Option<AlsaPlugin>) -> Result<(), String> {
let plugin_json = plugin
.map(|p| serde_json::to_string(&p))
.transpose()
.map_err(|e| format!("Failed to serialize ALSA plugin: {}", e))?;
self.conn
.execute(
"UPDATE audio_settings SET alsa_plugin = ?1 WHERE id = 1",
params![plugin_json],
)
.map_err(|e| format!("Failed to set ALSA plugin: {}", e))?;
Ok(())
}
pub fn set_alsa_hardware_volume(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE audio_settings SET alsa_hardware_volume = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set ALSA hardware volume: {}", e))?;
Ok(())
}
pub fn set_stream_first_track(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE audio_settings SET stream_first_track = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set stream first track: {}", e))?;
Ok(())
}
pub fn set_stream_buffer_seconds(&self, seconds: u8) -> Result<(), String> {
// Clamp to valid range 1-10
let clamped = seconds.clamp(1, 10);
self.conn
.execute(
"UPDATE audio_settings SET stream_buffer_seconds = ?1 WHERE id = 1",
params![clamped as i64],
)
.map_err(|e| format!("Failed to set stream buffer seconds: {}", e))?;
Ok(())
}
pub fn set_streaming_only(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE audio_settings SET streaming_only = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set streaming only: {}", e))?;
Ok(())
}
pub fn set_limit_quality_to_device(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE audio_settings SET limit_quality_to_device = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set limit quality to device: {}", e))?;
Ok(())
}
pub fn set_device_max_sample_rate(&self, rate: Option<u32>) -> Result<(), String> {
self.conn
.execute(
"UPDATE audio_settings SET device_max_sample_rate = ?1 WHERE id = 1",
params![rate.map(|r| r as i64)],
)
.map_err(|e| format!("Failed to set device max sample rate: {}", e))?;
Ok(())
}
/// Set the sample rate limit for a specific device
/// If rate is None, removes the limit for that device
pub fn set_device_sample_rate_limit(
&self,
device_id: &str,
rate: Option<u32>,
) -> Result<(), String> {
// Get current limits
let mut limits = self.get_device_sample_rate_limits()?;
// Update or remove the limit for this device
if let Some(r) = rate {
limits.insert(device_id.to_string(), r);
} else {
limits.remove(device_id);
}
// Serialize and save
let json = serde_json::to_string(&limits)
.map_err(|e| format!("Failed to serialize device sample rate limits: {}", e))?;
self.conn
.execute(
"UPDATE audio_settings SET device_sample_rate_limits = ?1 WHERE id = 1",
params![json],
)
.map_err(|e| format!("Failed to set device sample rate limits: {}", e))?;
Ok(())
}
/// Get the sample rate limit for a specific device
/// Returns None if no limit is set for this device
pub fn get_device_sample_rate_limit(&self, device_id: &str) -> Result<Option<u32>, String> {
let limits = self.get_device_sample_rate_limits()?;
Ok(limits.get(device_id).copied())
}
/// Get all device sample rate limits
fn get_device_sample_rate_limits(&self) -> Result<HashMap<String, u32>, String> {
let json: Option<String> = self
.conn
.query_row(
"SELECT device_sample_rate_limits FROM audio_settings WHERE id = 1",
[],
|row| row.get(0),
)
.map_err(|e| format!("Failed to get device sample rate limits: {}", e))?;
match json {
Some(s) if !s.is_empty() => serde_json::from_str(&s)
.map_err(|e| format!("Failed to parse device sample rate limits: {}", e)),
_ => Ok(HashMap::new()),
}
}
pub fn set_normalization_enabled(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE audio_settings SET normalization_enabled = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set normalization enabled: {}", e))?;
Ok(())
}
pub fn set_gapless_enabled(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE audio_settings SET gapless_enabled = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set gapless enabled: {}", e))?;
Ok(())
}
pub fn set_allow_quality_fallback(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE audio_settings SET allow_quality_fallback = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set allow_quality_fallback: {}", e))?;
Ok(())
}
pub fn set_skip_sink_switch(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE audio_settings SET skip_sink_switch = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set skip_sink_switch: {}", e))?;
Ok(())
}
/// Persist the `reserve_dac_while_running` flag (Lifetime B from the
/// ALSA exclusive-hardening design spec). Toggling this only updates
/// the DB row; applying the change to the live `DeviceReservation`
/// guard is the caller's responsibility — see
/// `AppState::apply_dac_reservation`.
pub fn set_reserve_dac_while_running(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE audio_settings SET reserve_dac_while_running = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set reserve_dac_while_running: {}", e))?;
Ok(())
}
pub fn set_pw_force_bitperfect(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE audio_settings SET pw_force_bitperfect = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set pw_force_bitperfect: {}", e))?;
Ok(())
}
pub fn set_sync_audio_on_startup(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE audio_settings SET sync_audio_on_startup = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set sync_audio_on_startup: {}", e))?;
Ok(())
}
pub fn get_quality_fallback_behavior(&self) -> Result<String, String> {
let settings = self.get_settings()?;
let value = &settings.quality_fallback_behavior;
match value.as_str() {
"ask" | "always_fallback" | "always_skip" => Ok(value.clone()),
_ => Ok("ask".to_string()),
}
}
pub fn set_quality_fallback_behavior(&self, behavior: &str) -> Result<(), String> {
match behavior {
"ask" | "always_fallback" | "always_skip" => {}
_ => return Err(format!("Invalid quality_fallback_behavior: {}", behavior)),
}
self.conn
.execute(
"UPDATE audio_settings SET quality_fallback_behavior = ?1 WHERE id = 1",
params![behavior],
)
.map_err(|e| format!("Failed to set quality_fallback_behavior: {}", e))?;
Ok(())
}
pub fn set_normalization_target_lufs(&self, target: f32) -> Result<(), String> {
self.conn
.execute(
"UPDATE audio_settings SET normalization_target_lufs = ?1 WHERE id = 1",
params![target as f64],
)
.map_err(|e| format!("Failed to set normalization target LUFS: {}", e))?;
Ok(())
}
/// Reset all audio settings to their default values
pub fn reset_all(&self) -> Result<AudioSettings, String> {
// ADR-003: quality_fallback_behavior must survive reset_all()
let saved_fallback = self
.get_quality_fallback_behavior()
.unwrap_or_else(|_| "ask".to_string());
let defaults = AudioSettings::default();
let backend_json: Option<String> = defaults
.backend_type
.map(|b| serde_json::to_string(&b))
.transpose()
.map_err(|e| format!("Failed to serialize backend type: {}", e))?;
let plugin_json: Option<String> = defaults
.alsa_plugin
.map(|p| serde_json::to_string(&p))
.transpose()
.map_err(|e| format!("Failed to serialize ALSA plugin: {}", e))?;
// Serialize per-device limits (empty on reset)
let limits_json = serde_json::to_string(&defaults.device_sample_rate_limits)
.map_err(|e| format!("Failed to serialize device sample rate limits: {}", e))?;
self.conn
.execute(
"UPDATE audio_settings SET
output_device = ?1,
exclusive_mode = ?2,
dac_passthrough = ?3,
preferred_sample_rate = ?4,
backend_type = ?5,
alsa_plugin = ?6,
alsa_hardware_volume = ?7,
stream_first_track = ?8,
stream_buffer_seconds = ?9,
streaming_only = ?10,
limit_quality_to_device = ?11,
device_max_sample_rate = ?12,
normalization_enabled = ?13,
normalization_target_lufs = ?14,
gapless_enabled = ?15,
device_sample_rate_limits = ?16,
pw_force_bitperfect = ?17,
sync_audio_on_startup = ?18,
skip_sink_switch = ?19,
allow_quality_fallback = ?20,
reserve_dac_while_running = ?21
WHERE id = 1",
params![
defaults.output_device,
defaults.exclusive_mode as i64,
defaults.dac_passthrough as i64,
defaults.preferred_sample_rate.map(|r| r as i64),
backend_json,
plugin_json,
defaults.alsa_hardware_volume as i64,
defaults.stream_first_track as i64,
defaults.stream_buffer_seconds as i64,
defaults.streaming_only as i64,
defaults.limit_quality_to_device as i64,
defaults.device_max_sample_rate.map(|r| r as i64),
defaults.normalization_enabled as i64,
defaults.normalization_target_lufs as f64,
defaults.gapless_enabled as i64,
limits_json,
defaults.pw_force_bitperfect as i64,
defaults.sync_audio_on_startup as i64,
defaults.skip_sink_switch as i64,
defaults.allow_quality_fallback as i64,
defaults.reserve_dac_while_running as i64,
],
)
.map_err(|e| format!("Failed to reset audio settings: {}", e))?;
// ADR-003: restore quality_fallback_behavior after reset (it is not an audio config)
self.conn
.execute(
"UPDATE audio_settings SET quality_fallback_behavior = ?1 WHERE id = 1",
params![saved_fallback],
)
.map_err(|e| {
format!(
"Failed to restore quality_fallback_behavior after reset: {}",
e
)
})?;
let mut result = defaults;
result.quality_fallback_behavior = saved_fallback;
Ok(result)
}
}
/// Thread-safe wrapper
pub struct AudioSettingsState {
pub store: Arc<Mutex<Option<AudioSettingsStore>>>,
}
impl AudioSettingsState {
pub fn new() -> Result<Self, String> {
Ok(Self {
store: Arc::new(Mutex::new(Some(AudioSettingsStore::new()?))),
})
}
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
let new_store = AudioSettingsStore::new_at(base_dir)?;
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock audio settings store".to_string())?;
*guard = Some(new_store);
Ok(())
}
pub fn teardown(&self) -> Result<(), String> {
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock audio settings store".to_string())?;
*guard = None;
Ok(())
}
}
pub use qbz_audio::settings::{AudioSettings, AudioSettingsState, AudioSettingsStore};
// Tauri commands
#[tauri::command]
@@ -1014,7 +270,9 @@ pub fn set_audio_skip_sink_switch(
if enabled {
let settings = store.get_settings()?;
if settings.dac_passthrough {
return Err("Cannot enable skip sink switch while DAC passthrough is active".to_string());
return Err(
"Cannot enable skip sink switch while DAC passthrough is active".to_string(),
);
}
}
+6 -122
View File
@@ -1,127 +1,11 @@
//! Developer settings persistence
//! Tauri adapter for developer settings.
//!
//! Stores developer-mode toggles (e.g. force DMA-BUF override for NVIDIA+Wayland).
//! These settings are device-level (not per-user) and persist across sessions.
//! The portable settings store lives in `qbz-app`; this module keeps the
//! existing Tauri command surface unchanged.
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeveloperSettings {
pub force_dmabuf: bool,
}
impl Default for DeveloperSettings {
fn default() -> Self {
Self {
force_dmabuf: false,
}
}
}
pub struct DeveloperSettingsStore {
conn: Connection,
}
impl DeveloperSettingsStore {
/// Lightweight read-only open for startup (before Tauri state).
pub fn new_readonly() -> Result<Self, String> {
let db_path = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz")
.join("developer_settings.db");
if !db_path.exists() {
return Err("Developer settings DB does not exist yet".to_string());
}
let conn = Connection::open_with_flags(
&db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|e| format!("Failed to open developer settings database (readonly): {}", e))?;
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
std::fs::create_dir_all(&data_dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = data_dir.join("developer_settings.db");
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open developer settings database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| {
format!(
"Failed to enable WAL for developer settings database: {}",
e
)
})?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS developer_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
force_dmabuf INTEGER NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO developer_settings (id, force_dmabuf) VALUES (1, 0);",
)
.map_err(|e| format!("Failed to create developer settings table: {}", e))?;
Ok(Self { conn })
}
pub fn get_settings(&self) -> Result<DeveloperSettings, String> {
self.conn
.query_row(
"SELECT force_dmabuf FROM developer_settings WHERE id = 1",
[],
|row| {
Ok(DeveloperSettings {
force_dmabuf: row.get::<_, i64>(0)? != 0,
})
},
)
.map_err(|e| format!("Failed to get developer settings: {}", e))
}
pub fn set_force_dmabuf(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE developer_settings SET force_dmabuf = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set force_dmabuf: {}", e))?;
Ok(())
}
}
/// Thread-safe wrapper for Tauri state management
pub struct DeveloperSettingsState {
pub store: Arc<Mutex<Option<DeveloperSettingsStore>>>,
}
impl DeveloperSettingsState {
pub fn new() -> Result<Self, String> {
Ok(Self {
store: Arc::new(Mutex::new(Some(DeveloperSettingsStore::new()?))),
})
}
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
}
// Tauri commands
pub use qbz_app::settings::developer::{
DeveloperSettings, DeveloperSettingsState, DeveloperSettingsStore,
};
#[tauri::command]
pub fn get_developer_settings(
+9 -138
View File
@@ -1,142 +1,15 @@
//! Download settings persistence
//! Tauri commands for download settings.
//!
//! Stores user preferences for download path and library integration.
//! Persistence lives in `qbz-app`. Host filesystem validation stays here.
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadSettings {
pub download_root: String,
pub show_in_library: bool,
}
impl Default for DownloadSettings {
fn default() -> Self {
let default_root = dirs::cache_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("qbz")
.join("audio")
.to_string_lossy()
.to_string();
Self {
download_root: default_root,
// New installs surface offline-cached Qobuz tracks in the
// Local Library by default — most users want them visible.
// Existing users with a persisted value aren't affected:
// INSERT OR IGNORE in open_at() only sets the default on
// first-ever row creation.
show_in_library: true,
}
}
}
pub struct DownloadSettingsStore {
conn: Connection,
}
impl DownloadSettingsStore {
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open download settings database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to enable WAL for download settings database: {}", e))?;
let default_settings = DownloadSettings::default();
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS download_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
download_root TEXT NOT NULL,
show_in_library INTEGER NOT NULL DEFAULT 1
);",
)
.map_err(|e| format!("Failed to create download settings table: {}", e))?;
conn.execute(
"INSERT OR IGNORE INTO download_settings (id, download_root, show_in_library)
VALUES (1, ?1, ?2)",
params![
default_settings.download_root,
default_settings.show_in_library as i64,
],
)
.map_err(|e| format!("Failed to initialize download settings: {}", e))?;
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
Self::open_at(&data_dir, "download_settings.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "download_settings.db")
}
pub fn get_settings(&self) -> Result<DownloadSettings, String> {
self.conn
.query_row(
"SELECT download_root, show_in_library FROM download_settings WHERE id = 1",
[],
|row| {
Ok(DownloadSettings {
download_root: row.get(0)?,
show_in_library: row.get::<_, i64>(1)? != 0,
})
},
)
.map_err(|e| format!("Failed to get download settings: {}", e))
}
pub fn set_download_root(&self, path: &str) -> Result<(), String> {
self.conn
.execute(
"UPDATE download_settings SET download_root = ?1 WHERE id = 1",
params![path],
)
.map_err(|e| format!("Failed to set download root: {}", e))?;
Ok(())
}
pub fn set_show_in_library(&self, show: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE download_settings SET show_in_library = ?1 WHERE id = 1",
params![show as i64],
)
.map_err(|e| format!("Failed to set show_in_library: {}", e))?;
Ok(())
}
}
pub type DownloadSettingsState = Arc<Mutex<Option<DownloadSettingsStore>>>;
pub fn create_download_settings_state() -> Result<DownloadSettingsState, String> {
let store = DownloadSettingsStore::new()?;
Ok(Arc::new(Mutex::new(Some(store))))
}
pub fn create_empty_download_settings_state() -> DownloadSettingsState {
Arc::new(Mutex::new(None))
}
// Tauri commands
pub use qbz_app::settings::download::{
create_download_settings_state, create_empty_download_settings_state, DownloadSettings,
DownloadSettingsState, DownloadSettingsStore,
};
#[tauri::command]
pub fn get_download_settings(
state: tauri::State<DownloadSettingsState>,
state: tauri::State<'_, DownloadSettingsState>,
) -> Result<DownloadSettings, String> {
log::info!("Command: get_download_settings");
let guard = state.lock().map_err(|e| format!("Lock error: {}", e))?;
@@ -147,11 +20,10 @@ pub fn get_download_settings(
#[tauri::command]
pub fn set_download_root(
path: String,
state: tauri::State<DownloadSettingsState>,
state: tauri::State<'_, DownloadSettingsState>,
) -> Result<(), String> {
log::info!("Command: set_download_root to: {}", path);
// Basic validation
let path_obj = std::path::Path::new(&path);
if !path_obj.exists() {
return Err("Path does not exist".to_string());
@@ -168,7 +40,7 @@ pub fn set_download_root(
#[tauri::command]
pub fn set_show_downloads_in_library(
show: bool,
state: tauri::State<DownloadSettingsState>,
state: tauri::State<'_, DownloadSettingsState>,
) -> Result<(), String> {
log::info!("Command: set_show_downloads_in_library to: {}", show);
let guard = state.lock().map_err(|e| format!("Lock error: {}", e))?;
@@ -189,7 +61,6 @@ pub fn validate_download_root(path: String) -> Result<bool, String> {
return Err("Path exists but is not a directory".to_string());
}
// Check write permissions by trying to create a temp file
let test_file = path_obj.join(".qbz_write_test");
match std::fs::write(&test_file, b"test") {
Ok(_) => {
+5 -336
View File
@@ -1,262 +1,9 @@
use chrono::Utc;
use md5::{Digest, Md5};
use rusqlite::{params, Connection, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
//! Tauri commands for favorites preferences.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FavoritesPreferences {
pub custom_icon_path: Option<String>,
pub custom_icon_preset: Option<String>,
pub icon_background: Option<String>,
pub tab_order: Vec<String>,
}
impl Default for FavoritesPreferences {
fn default() -> Self {
Self {
custom_icon_path: None,
custom_icon_preset: Some("heart".to_string()),
icon_background: None,
tab_order: vec![
"tracks".to_string(),
"albums".to_string(),
"artists".to_string(),
"playlists".to_string(),
],
}
}
}
pub struct FavoritesPreferencesStore {
conn: Connection,
}
impl FavoritesPreferencesStore {
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open favorites preferences database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to set WAL mode on favorites preferences: {}", e))?;
conn.execute(
"CREATE TABLE IF NOT EXISTS favorites_preferences (
id INTEGER PRIMARY KEY CHECK (id = 1),
custom_icon_path TEXT,
custom_icon_preset TEXT,
tab_order TEXT NOT NULL
)",
[],
)
.map_err(|e| format!("Failed to create favorites preferences table: {}", e))?;
// Migration: Add icon_background column if it doesn't exist
let has_icon_background = conn
.prepare("SELECT icon_background FROM favorites_preferences LIMIT 1")
.is_ok();
if !has_icon_background {
conn.execute(
"ALTER TABLE favorites_preferences ADD COLUMN icon_background TEXT",
[],
)
.map_err(|e| format!("Failed to add icon_background column: {}", e))?;
}
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
Self::open_at(&data_dir, "favorites_preferences.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "favorites_preferences.db")
}
fn favorites_icon_dir() -> Result<PathBuf, String> {
let cache_dir = dirs::cache_dir()
.ok_or("Could not determine cache directory")?
.join("qbz")
.join("favorites");
fs::create_dir_all(&cache_dir)
.map_err(|e| format!("Failed to create favorites icon directory: {}", e))?;
Ok(cache_dir)
}
fn normalize_custom_icon_path(&self, path: String) -> Result<String, String> {
let trimmed = path.trim();
if trimmed.is_empty() {
return Ok(String::new());
}
let source = Path::new(trimmed);
if !source.exists() {
return Err(format!("Source icon does not exist: {}", trimmed));
}
let icon_dir = Self::favorites_icon_dir()?;
if source.starts_with(&icon_dir) {
return Ok(trimmed.to_string());
}
let extension = source.extension().and_then(|e| e.to_str()).unwrap_or("png");
let mut hasher = Md5::new();
hasher.update(trimmed.as_bytes());
let hash = format!("{:x}", hasher.finalize());
let filename = format!(
"favorites_icon_{}_{}.{}",
hash,
Utc::now().timestamp(),
extension
);
let dest_path = icon_dir.join(filename);
fs::copy(source, &dest_path)
.map_err(|e| format!("Failed to copy favorites icon: {}", e))?;
Ok(dest_path.to_string_lossy().to_string())
}
pub fn get_preferences(&self) -> Result<FavoritesPreferences, String> {
let mut stmt = self.conn.prepare("SELECT custom_icon_path, custom_icon_preset, icon_background, tab_order FROM favorites_preferences WHERE id = 1")
.map_err(|e| format!("Failed to prepare select: {}", e))?;
let result = stmt.query_row([], |row| {
let custom_icon_path: Option<String> = row.get(0)?;
let custom_icon_preset: Option<String> = row.get(1)?;
let icon_background: Option<String> = row.get(2)?;
let tab_order_str: String = row.get(3)?;
let custom_icon_path = custom_icon_path.and_then(|value| {
if value.trim().is_empty() {
None
} else {
Some(value)
}
});
let tab_order: Vec<String> =
serde_json::from_str(&tab_order_str).unwrap_or_else(|_| {
vec![
"tracks".to_string(),
"albums".to_string(),
"artists".to_string(),
"playlists".to_string(),
]
});
Ok(FavoritesPreferences {
custom_icon_path,
custom_icon_preset,
icon_background,
tab_order,
})
});
match result {
Ok(mut prefs) => {
if let Some(path) = prefs.custom_icon_path.clone() {
match self.normalize_custom_icon_path(path) {
Ok(resolved) => {
let normalized = if resolved.trim().is_empty() {
None
} else {
Some(resolved)
};
if normalized != prefs.custom_icon_path {
prefs.custom_icon_path = normalized;
let _ = self.save_preferences(prefs.clone());
}
}
Err(_) => {
prefs.custom_icon_path = None;
let _ = self.save_preferences(prefs.clone());
}
}
}
Ok(prefs)
}
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(FavoritesPreferences::default()),
Err(e) => Err(format!("Failed to query preferences: {}", e)),
}
}
pub fn save_preferences(
&self,
mut prefs: FavoritesPreferences,
) -> Result<FavoritesPreferences, String> {
if let Some(path) = prefs.custom_icon_path.clone() {
let resolved = self.normalize_custom_icon_path(path)?;
if resolved.is_empty() {
prefs.custom_icon_path = None;
} else {
prefs.custom_icon_path = Some(resolved);
}
}
let tab_order_str = serde_json::to_string(&prefs.tab_order)
.map_err(|e| format!("Failed to serialize tab_order: {}", e))?;
self.conn.execute(
"INSERT OR REPLACE INTO favorites_preferences (id, custom_icon_path, custom_icon_preset, icon_background, tab_order)
VALUES (1, ?1, ?2, ?3, ?4)",
params![prefs.custom_icon_path, prefs.custom_icon_preset, prefs.icon_background, tab_order_str],
)
.map_err(|e| format!("Failed to save preferences: {}", e))?;
Ok(prefs)
}
}
pub struct FavoritesPreferencesState {
pub store: Arc<Mutex<Option<FavoritesPreferencesStore>>>,
}
impl FavoritesPreferencesState {
pub fn new() -> Result<Self, String> {
let store = FavoritesPreferencesStore::new()?;
Ok(Self {
store: Arc::new(Mutex::new(Some(store))),
})
}
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
let new_store = FavoritesPreferencesStore::new_at(base_dir)?;
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock favorites preferences store".to_string())?;
*guard = Some(new_store);
Ok(())
}
pub fn teardown(&self) -> Result<(), String> {
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock favorites preferences store".to_string())?;
*guard = None;
Ok(())
}
}
pub use qbz_app::settings::favorites::{
create_table, load_preferences, save_preferences, FavoritesPreferences,
FavoritesPreferencesState, FavoritesPreferencesStore,
};
#[tauri::command]
pub fn get_favorites_preferences(
@@ -282,81 +29,3 @@ pub fn save_favorites_preferences(
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.save_preferences(prefs)
}
pub fn create_table(conn: &Connection) -> Result<()> {
conn.execute(
"CREATE TABLE IF NOT EXISTS favorites_preferences (
id INTEGER PRIMARY KEY CHECK (id = 1),
custom_icon_path TEXT,
custom_icon_preset TEXT,
tab_order TEXT NOT NULL
)",
[],
)?;
// Migration: Add icon_background column if it doesn't exist
let has_icon_background = conn
.prepare("SELECT icon_background FROM favorites_preferences LIMIT 1")
.is_ok();
if !has_icon_background {
conn.execute(
"ALTER TABLE favorites_preferences ADD COLUMN icon_background TEXT",
[],
)?;
}
Ok(())
}
pub fn load_preferences(conn: &Connection) -> Result<FavoritesPreferences> {
let mut stmt = conn.prepare("SELECT custom_icon_path, custom_icon_preset, icon_background, tab_order FROM favorites_preferences WHERE id = 1")?;
let result = stmt.query_row([], |row| {
let custom_icon_path: Option<String> = row.get(0)?;
let custom_icon_preset: Option<String> = row.get(1)?;
let icon_background: Option<String> = row.get(2)?;
let tab_order_str: String = row.get(3)?;
let custom_icon_path = custom_icon_path.and_then(|value| {
if value.trim().is_empty() {
None
} else {
Some(value)
}
});
let tab_order: Vec<String> = serde_json::from_str(&tab_order_str).unwrap_or_else(|_| {
vec![
"tracks".to_string(),
"albums".to_string(),
"artists".to_string(),
"playlists".to_string(),
]
});
Ok(FavoritesPreferences {
custom_icon_path,
custom_icon_preset,
icon_background,
tab_order,
})
});
match result {
Ok(prefs) => Ok(prefs),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(FavoritesPreferences::default()),
Err(e) => Err(e),
}
}
pub fn save_preferences(conn: &Connection, prefs: &FavoritesPreferences) -> Result<()> {
let tab_order_str = serde_json::to_string(&prefs.tab_order).unwrap();
conn.execute(
"INSERT OR REPLACE INTO favorites_preferences (id, custom_icon_path, custom_icon_preset, icon_background, tab_order)
VALUES (1, ?1, ?2, ?3, ?4)",
params![prefs.custom_icon_path, prefs.custom_icon_preset, prefs.icon_background, tab_order_str],
)?;
Ok(())
}
+43 -166
View File
@@ -1,19 +1,14 @@
//! Graphics settings persistence
//! Tauri graphics settings commands and startup status.
//!
//! Stores GPU/rendering preferences that take effect before WebView initialization.
//! These settings are device-level (not per-user) and persist across sessions.
//!
//! - hardware_acceleration: GPU rendering toggle (default: on). Read at startup
//! as the default value; env var QBZ_HARDWARE_ACCEL=0|1 overrides.
//! - force_x11: force X11/XWayland backend on Wayland sessions (default: off)
//! Env var QBZ_FORCE_X11=1|0 always overrides the stored value.
//! - gdk_scale / gdk_dpi_scale: display scaling overrides for XWayland
//! Portable graphics settings persistence lives in `qbz-app`. This module keeps
//! Tauri command wrappers and process-local startup status only.
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
pub use qbz_app::settings::graphics::{GraphicsSettings, GraphicsSettingsStore};
// --- Global startup state (set once in main.rs, read-only thereafter) ---
static GRAPHICS_USING_FALLBACK: AtomicBool = AtomicBool::new(false);
static GRAPHICS_IS_WAYLAND: AtomicBool = AtomicBool::new(false);
@@ -50,162 +45,6 @@ pub fn is_using_graphics_fallback() -> bool {
GRAPHICS_USING_FALLBACK.load(Ordering::SeqCst)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphicsSettings {
/// GPU rendering toggle. Read at startup as default; env var QBZ_HARDWARE_ACCEL overrides.
pub hardware_acceleration: bool,
/// Force X11 (XWayland) backend on Wayland sessions (requires restart)
pub force_x11: bool,
/// GDK_SCALE override for XWayland (None = auto). Integer values: "1", "2"
pub gdk_scale: Option<String>,
/// GDK_DPI_SCALE override for XWayland (None = auto). Float values: "0.5", "1", "1.5"
pub gdk_dpi_scale: Option<String>,
/// GSK_RENDERER override (None = auto). Values: "gl", "ngl", "vulkan", "cairo"
pub gsk_renderer: Option<String>,
}
impl Default for GraphicsSettings {
fn default() -> Self {
Self {
hardware_acceleration: true,
force_x11: false,
gdk_scale: None,
gdk_dpi_scale: None,
gsk_renderer: None,
}
}
}
pub struct GraphicsSettingsStore {
conn: Connection,
}
impl GraphicsSettingsStore {
/// Lightweight read-only open for startup (before Tauri state).
/// Opens existing DB without creating tables/running migrations.
pub fn new_readonly() -> Result<Self, String> {
let db_path = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz")
.join("graphics_settings.db");
if !db_path.exists() {
return Err("Graphics settings DB does not exist yet".to_string());
}
let conn = Connection::open_with_flags(
&db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|e| format!("Failed to open graphics settings database (readonly): {}", e))?;
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
std::fs::create_dir_all(&data_dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = data_dir.join("graphics_settings.db");
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open graphics settings database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to enable WAL for graphics settings database: {}", e))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS graphics_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
hardware_acceleration INTEGER NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO graphics_settings (id, hardware_acceleration) VALUES (1, 0);",
)
.map_err(|e| format!("Failed to create graphics settings table: {}", e))?;
// Migrations: add columns (no-op if already present)
let _ = conn.execute_batch(
"ALTER TABLE graphics_settings ADD COLUMN force_x11 INTEGER NOT NULL DEFAULT 0;",
);
let _ = conn.execute_batch("ALTER TABLE graphics_settings ADD COLUMN gdk_scale TEXT;");
let _ = conn.execute_batch("ALTER TABLE graphics_settings ADD COLUMN gdk_dpi_scale TEXT;");
let _ = conn.execute_batch("ALTER TABLE graphics_settings ADD COLUMN gsk_renderer TEXT;");
Ok(Self { conn })
}
pub fn get_settings(&self) -> Result<GraphicsSettings, String> {
self.conn
.query_row(
"SELECT hardware_acceleration, force_x11, gdk_scale, gdk_dpi_scale, gsk_renderer FROM graphics_settings WHERE id = 1",
[],
|row| {
Ok(GraphicsSettings {
hardware_acceleration: row.get::<_, i64>(0)? != 0,
force_x11: row.get::<_, i64>(1)? != 0,
gdk_scale: row.get::<_, Option<String>>(2)?,
gdk_dpi_scale: row.get::<_, Option<String>>(3)?,
gsk_renderer: row.get::<_, Option<String>>(4)?,
})
},
)
.map_err(|e| format!("Failed to get graphics settings: {}", e))
}
pub fn set_hardware_acceleration(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE graphics_settings SET hardware_acceleration = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set hardware_acceleration: {}", e))?;
Ok(())
}
pub fn set_force_x11(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE graphics_settings SET force_x11 = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set force_x11: {}", e))?;
Ok(())
}
pub fn set_gdk_scale(&self, value: Option<String>) -> Result<(), String> {
self.conn
.execute(
"UPDATE graphics_settings SET gdk_scale = ?1 WHERE id = 1",
params![value],
)
.map_err(|e| format!("Failed to set gdk_scale: {}", e))?;
Ok(())
}
pub fn set_gdk_dpi_scale(&self, value: Option<String>) -> Result<(), String> {
self.conn
.execute(
"UPDATE graphics_settings SET gdk_dpi_scale = ?1 WHERE id = 1",
params![value],
)
.map_err(|e| format!("Failed to set gdk_dpi_scale: {}", e))?;
Ok(())
}
pub fn set_gsk_renderer(&self, value: Option<String>) -> Result<(), String> {
self.conn
.execute(
"UPDATE graphics_settings SET gsk_renderer = ?1 WHERE id = 1",
params![value],
)
.map_err(|e| format!("Failed to set gsk_renderer: {}", e))?;
Ok(())
}
}
/// Thread-safe wrapper for Tauri state management
pub struct GraphicsSettingsState {
pub store: Arc<Mutex<Option<GraphicsSettingsStore>>>,
@@ -317,6 +156,44 @@ pub fn set_gdk_dpi_scale(
store.set_gdk_dpi_scale(value)
}
#[tauri::command]
pub fn set_preferred_gpu(
state: tauri::State<'_, GraphicsSettingsState>,
value: String,
) -> Result<(), String> {
log::info!(
"[GraphicsSettings] Setting preferred_gpu to {:?} (restart required)",
value
);
let guard = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard
.as_ref()
.ok_or("Graphics settings store not initialized")?;
store.set_preferred_gpu(&value)
}
#[tauri::command]
pub fn set_nvidia_compat_mode(
state: tauri::State<'_, GraphicsSettingsState>,
enabled: bool,
) -> Result<(), String> {
log::info!(
"[GraphicsSettings] Setting nvidia_compat_mode to {} (restart required)",
enabled
);
let guard = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard
.as_ref()
.ok_or("Graphics settings store not initialized")?;
store.set_nvidia_compat_mode(enabled)
}
// --- Startup Status (set once at app launch, read-only thereafter) ---
/// Tracks graphics configuration status from startup
+6 -109
View File
@@ -1,111 +1,8 @@
//! Image cache settings persistence
//! Tauri-facing re-exports for image cache settings.
//!
//! Stores user preferences for the Qobuz image cache:
//! - enabled: whether to cache images locally (default: true)
//! - max_size_mb: maximum cache size in megabytes (default: 200)
//! Persistence lives in `qbz-app`. Image cache runtime, file management,
//! stats, and clear behavior remain host-owned.
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageCacheSettings {
pub enabled: bool,
pub max_size_mb: u32,
}
impl Default for ImageCacheSettings {
fn default() -> Self {
Self {
enabled: true,
max_size_mb: 200,
}
}
}
pub struct ImageCacheSettingsStore {
conn: Connection,
}
impl ImageCacheSettingsStore {
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
std::fs::create_dir_all(&data_dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = data_dir.join("image_cache_settings.db");
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open image cache settings database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to enable WAL: {}", e))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS image_cache_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
enabled INTEGER NOT NULL DEFAULT 1,
max_size_mb INTEGER NOT NULL DEFAULT 200
);
INSERT OR IGNORE INTO image_cache_settings (id, enabled, max_size_mb)
VALUES (1, 1, 200);",
)
.map_err(|e| format!("Failed to create image cache settings table: {}", e))?;
Ok(Self { conn })
}
pub fn get_settings(&self) -> Result<ImageCacheSettings, String> {
self.conn
.query_row(
"SELECT enabled, max_size_mb FROM image_cache_settings WHERE id = 1",
[],
|row| {
Ok(ImageCacheSettings {
enabled: row.get::<_, i32>(0)? != 0,
max_size_mb: row.get::<_, u32>(1)?,
})
},
)
.map_err(|e| format!("Failed to read image cache settings: {}", e))
}
pub fn set_enabled(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE image_cache_settings SET enabled = ?1 WHERE id = 1",
params![enabled as i32],
)
.map_err(|e| format!("Failed to update image cache enabled: {}", e))?;
Ok(())
}
pub fn set_max_size_mb(&self, max_size_mb: u32) -> Result<(), String> {
self.conn
.execute(
"UPDATE image_cache_settings SET max_size_mb = ?1 WHERE id = 1",
params![max_size_mb],
)
.map_err(|e| format!("Failed to update image cache max size: {}", e))?;
Ok(())
}
}
pub struct ImageCacheSettingsState {
pub store: Arc<Mutex<Option<ImageCacheSettingsStore>>>,
}
impl ImageCacheSettingsState {
pub fn new() -> Result<Self, String> {
Ok(Self {
store: Arc::new(Mutex::new(Some(ImageCacheSettingsStore::new()?))),
})
}
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
}
pub use qbz_app::settings::image_cache::{
ImageCacheSettings, ImageCacheSettingsState, ImageCacheSettingsStore,
};
+5 -112
View File
@@ -1,116 +1,9 @@
//! Legal settings storage
//!
//! Persists user acceptance of legal terms:
//! - qobuz_tos_accepted: User has accepted Qobuz Terms of Service
//! Tauri commands for legal settings.
use log::info;
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LegalSettings {
/// User has accepted Qobuz Terms of Service
pub qobuz_tos_accepted: bool,
}
impl Default for LegalSettings {
fn default() -> Self {
Self {
qobuz_tos_accepted: false,
}
}
}
pub struct LegalSettingsStore {
conn: Connection,
}
impl LegalSettingsStore {
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open legal settings database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to enable WAL for legal settings database: {}", e))?;
// Create table
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS legal_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
qobuz_tos_accepted INTEGER NOT NULL DEFAULT 0
);",
)
.map_err(|e| format!("Failed to create legal settings table: {}", e))?;
// Insert default row if it doesn't exist
conn.execute(
"INSERT OR IGNORE INTO legal_settings (id, qobuz_tos_accepted) VALUES (1, 0)",
[],
)
.map_err(|e| format!("Failed to insert default legal settings: {}", e))?;
info!("[LegalSettings] Database initialized");
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
Self::open_at(&data_dir, "legal_settings.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "legal_settings.db")
}
pub fn get_settings(&self) -> Result<LegalSettings, String> {
self.conn
.query_row(
"SELECT qobuz_tos_accepted FROM legal_settings WHERE id = 1",
[],
|row| {
let qobuz_tos_accepted: i32 = row.get(0)?;
Ok(LegalSettings {
qobuz_tos_accepted: qobuz_tos_accepted != 0,
})
},
)
.map_err(|e| format!("Failed to get legal settings: {}", e))
}
pub fn set_qobuz_tos_accepted(&self, accepted: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE legal_settings SET qobuz_tos_accepted = ? WHERE id = 1",
params![accepted as i32],
)
.map_err(|e| format!("Failed to update ToS acceptance: {}", e))?;
info!("[LegalSettings] Qobuz ToS accepted: {}", accepted);
Ok(())
}
}
pub type LegalSettingsState = Arc<Mutex<Option<LegalSettingsStore>>>;
pub fn create_legal_settings_state() -> Result<LegalSettingsState, String> {
let store = LegalSettingsStore::new()?;
Ok(Arc::new(Mutex::new(Some(store))))
}
pub fn create_empty_legal_settings_state() -> LegalSettingsState {
Arc::new(Mutex::new(None))
}
// Tauri commands
pub use qbz_app::settings::legal::{
create_empty_legal_settings_state, create_legal_settings_state, LegalSettings,
LegalSettingsState, LegalSettingsStore,
};
#[tauri::command]
pub fn get_legal_settings(
+4 -415
View File
@@ -1,270 +1,8 @@
use rusqlite::{params, Connection, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex};
//! Tauri commands for library preferences.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FoldersViewMode {
Flat,
Tree,
}
impl Default for FoldersViewMode {
fn default() -> Self {
FoldersViewMode::Flat
}
}
impl FoldersViewMode {
pub fn as_str(&self) -> &'static str {
match self {
FoldersViewMode::Flat => "flat",
FoldersViewMode::Tree => "tree",
}
}
pub fn from_str(s: &str) -> Self {
match s {
"tree" => FoldersViewMode::Tree,
_ => FoldersViewMode::Flat,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LibraryPreferences {
pub tab_order: Vec<String>,
pub hidden_tabs: Vec<String>,
#[serde(default)]
pub folders_view_mode: FoldersViewMode,
/// Width (in CSS pixels) of the tree-mode left sidebar in the Folders
/// tab. `None` means "use the frontend default" (currently 302px).
/// Persisted on drag-end so the choice survives restarts. Bounds are
/// enforced on the frontend (min 200px, max 40% of content area).
#[serde(default)]
pub folders_tree_sidebar_width: Option<u32>,
}
impl Default for LibraryPreferences {
fn default() -> Self {
Self {
// Default order matches the current LocalLibraryView tab list,
// with the renamed 'folders' tab in place of the old 'albums'
// tab. Phase 3 adds 'albums' (the new metadata-grouped view)
// to this default.
tab_order: vec![
"tracks".to_string(),
"folders".to_string(),
"albums".to_string(),
"artists".to_string(),
],
hidden_tabs: vec![],
folders_view_mode: FoldersViewMode::Flat,
folders_tree_sidebar_width: None,
}
}
}
pub struct LibraryPreferencesStore {
conn: Connection,
}
impl LibraryPreferencesStore {
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open library preferences database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to set WAL mode on library preferences: {}", e))?;
conn.execute(
"CREATE TABLE IF NOT EXISTS library_preferences (
id INTEGER PRIMARY KEY CHECK (id = 1),
tab_order TEXT NOT NULL,
hidden_tabs TEXT NOT NULL DEFAULT '[]'
)",
[],
)
.map_err(|e| format!("Failed to create library preferences table: {}", e))?;
// Idempotent migrations for columns added after the initial schema.
// These ALTERs fail when the column already exists; that failure is
// expected and ignored.
let _ = conn.execute(
"ALTER TABLE library_preferences ADD COLUMN folders_view_mode TEXT DEFAULT 'flat'",
[],
);
let _ = conn.execute(
"ALTER TABLE library_preferences ADD COLUMN folders_tree_sidebar_width INTEGER DEFAULT NULL",
[],
);
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
Self::open_at(&data_dir, "library_preferences.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "library_preferences.db")
}
pub fn get_preferences(&self) -> Result<LibraryPreferences, String> {
let mut stmt = self
.conn
.prepare(
"SELECT tab_order, hidden_tabs, folders_view_mode, folders_tree_sidebar_width \
FROM library_preferences WHERE id = 1",
)
.map_err(|e| format!("Failed to prepare select: {}", e))?;
let result = stmt.query_row([], |row| {
let tab_order_str: String = row.get(0)?;
let hidden_tabs_str: String = row.get(1)?;
let folders_view_mode_str: Option<String> = row.get(2)?;
let folders_tree_sidebar_width: Option<i64> = row.get(3)?;
let tab_order: Vec<String> = serde_json::from_str(&tab_order_str)
.unwrap_or_else(|_| LibraryPreferences::default().tab_order);
let hidden_tabs: Vec<String> =
serde_json::from_str(&hidden_tabs_str).unwrap_or_default();
let folders_view_mode = folders_view_mode_str
.map(|s| FoldersViewMode::from_str(&s))
.unwrap_or_default();
// Stored as INTEGER in SQLite; clamp to non-negative on read.
// Frontend re-clamps against the live max anyway.
let folders_tree_sidebar_width = folders_tree_sidebar_width
.filter(|v| *v > 0)
.map(|v| v as u32);
Ok(LibraryPreferences {
tab_order,
hidden_tabs,
folders_view_mode,
folders_tree_sidebar_width,
})
});
match result {
Ok(prefs) => Ok(prefs),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(LibraryPreferences::default()),
Err(e) => Err(format!("Failed to query library preferences: {}", e)),
}
}
pub fn save_preferences(
&self,
prefs: LibraryPreferences,
) -> Result<LibraryPreferences, String> {
let tab_order_str = serde_json::to_string(&prefs.tab_order)
.map_err(|e| format!("Failed to serialize tab_order: {}", e))?;
let hidden_tabs_str = serde_json::to_string(&prefs.hidden_tabs)
.map_err(|e| format!("Failed to serialize hidden_tabs: {}", e))?;
self.conn
.execute(
"INSERT OR REPLACE INTO library_preferences \
(id, tab_order, hidden_tabs, folders_view_mode, folders_tree_sidebar_width) \
VALUES (1, ?1, ?2, ?3, ?4)",
params![
tab_order_str,
hidden_tabs_str,
prefs.folders_view_mode.as_str(),
prefs.folders_tree_sidebar_width.map(|v| v as i64),
],
)
.map_err(|e| format!("Failed to save library preferences: {}", e))?;
Ok(prefs)
}
pub fn set_folders_tree_sidebar_width(&self, width: u32) -> Result<(), String> {
// Same upsert pattern as `set_folders_view_mode`: try the cheap
// UPDATE first; if no row exists yet, fall back to inserting a
// default row carrying just the new width.
let updated = self
.conn
.execute(
"UPDATE library_preferences SET folders_tree_sidebar_width = ?1 WHERE id = 1",
params![width as i64],
)
.map_err(|e| format!("Failed to set folders_tree_sidebar_width: {}", e))?;
if updated == 0 {
let mut prefs = LibraryPreferences::default();
prefs.folders_tree_sidebar_width = Some(width);
self.save_preferences(prefs)?;
}
Ok(())
}
pub fn set_folders_view_mode(&self, mode: FoldersViewMode) -> Result<(), String> {
// Ensure the row exists before updating; if no preferences have been
// saved yet, fall back to inserting a default row carrying the new
// folders_view_mode value.
let updated = self
.conn
.execute(
"UPDATE library_preferences SET folders_view_mode = ?1 WHERE id = 1",
params![mode.as_str()],
)
.map_err(|e| format!("Failed to set folders_view_mode: {}", e))?;
if updated == 0 {
let mut prefs = LibraryPreferences::default();
prefs.folders_view_mode = mode;
self.save_preferences(prefs)?;
}
Ok(())
}
}
pub struct LibraryPreferencesState {
pub store: Arc<Mutex<Option<LibraryPreferencesStore>>>,
}
impl LibraryPreferencesState {
pub fn new() -> Result<Self, String> {
let store = LibraryPreferencesStore::new()?;
Ok(Self {
store: Arc::new(Mutex::new(Some(store))),
})
}
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
let new_store = LibraryPreferencesStore::new_at(base_dir)?;
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock library preferences store".to_string())?;
*guard = Some(new_store);
Ok(())
}
pub fn teardown(&self) -> Result<(), String> {
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock library preferences store".to_string())?;
*guard = None;
Ok(())
}
}
pub use qbz_app::settings::library::{
FoldersViewMode, LibraryPreferences, LibraryPreferencesState, LibraryPreferencesStore,
};
#[tauri::command]
pub fn get_library_preferences(
@@ -290,152 +28,3 @@ pub fn save_library_preferences(
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.save_preferences(prefs)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn fresh_store() -> (tempfile::TempDir, LibraryPreferencesStore) {
let dir = tempdir().expect("tempdir");
let store =
LibraryPreferencesStore::new_at(dir.path()).expect("open store in temp dir");
(dir, store)
}
#[test]
fn default_is_flat() {
let prefs = LibraryPreferences::default();
assert_eq!(prefs.folders_view_mode, FoldersViewMode::Flat);
}
#[test]
fn legacy_json_without_folders_view_mode_defaults_to_flat() {
let legacy = r#"{
"tab_order": ["tracks", "folders", "albums", "artists"],
"hidden_tabs": []
}"#;
let prefs: LibraryPreferences =
serde_json::from_str(legacy).expect("parse legacy json");
assert_eq!(prefs.folders_view_mode, FoldersViewMode::Flat);
}
#[test]
fn round_trip_with_tree_set() {
let mut prefs = LibraryPreferences::default();
prefs.folders_view_mode = FoldersViewMode::Tree;
let json = serde_json::to_string(&prefs).expect("serialize");
let parsed: LibraryPreferences =
serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed.folders_view_mode, FoldersViewMode::Tree);
}
#[test]
fn folders_view_mode_str_round_trip() {
assert_eq!(FoldersViewMode::Flat.as_str(), "flat");
assert_eq!(FoldersViewMode::Tree.as_str(), "tree");
assert_eq!(FoldersViewMode::from_str("flat"), FoldersViewMode::Flat);
assert_eq!(FoldersViewMode::from_str("tree"), FoldersViewMode::Tree);
assert_eq!(
FoldersViewMode::from_str("garbage"),
FoldersViewMode::Flat
);
}
#[test]
fn fresh_store_returns_default_folders_view_mode() {
let (_dir, store) = fresh_store();
let prefs = store.get_preferences().expect("get prefs");
assert_eq!(prefs.folders_view_mode, FoldersViewMode::Flat);
}
#[test]
fn set_folders_view_mode_persists_on_empty_table() {
let (_dir, store) = fresh_store();
store
.set_folders_view_mode(FoldersViewMode::Tree)
.expect("set mode");
let prefs = store.get_preferences().expect("get prefs");
assert_eq!(prefs.folders_view_mode, FoldersViewMode::Tree);
}
#[test]
fn set_folders_view_mode_updates_existing_row() {
let (_dir, store) = fresh_store();
// Seed a row with the default save path so the UPDATE branch runs.
let mut seeded = LibraryPreferences::default();
seeded.tab_order = vec!["tracks".into(), "albums".into()];
store.save_preferences(seeded).expect("save seed");
store
.set_folders_view_mode(FoldersViewMode::Tree)
.expect("set mode");
let after = store.get_preferences().expect("get prefs");
assert_eq!(after.folders_view_mode, FoldersViewMode::Tree);
// Sibling fields preserved.
assert_eq!(
after.tab_order,
vec!["tracks".to_string(), "albums".to_string()]
);
}
#[test]
fn save_preferences_persists_folders_view_mode() {
let (_dir, store) = fresh_store();
let mut prefs = LibraryPreferences::default();
prefs.folders_view_mode = FoldersViewMode::Tree;
store.save_preferences(prefs).expect("save");
let loaded = store.get_preferences().expect("load");
assert_eq!(loaded.folders_view_mode, FoldersViewMode::Tree);
}
#[test]
fn fresh_store_returns_none_folders_tree_sidebar_width() {
let (_dir, store) = fresh_store();
let prefs = store.get_preferences().expect("get prefs");
assert_eq!(prefs.folders_tree_sidebar_width, None);
}
#[test]
fn set_folders_tree_sidebar_width_persists_on_empty_table() {
let (_dir, store) = fresh_store();
store
.set_folders_tree_sidebar_width(512)
.expect("set width");
let prefs = store.get_preferences().expect("get prefs");
assert_eq!(prefs.folders_tree_sidebar_width, Some(512));
}
#[test]
fn set_folders_tree_sidebar_width_updates_existing_row() {
let (_dir, store) = fresh_store();
let mut seeded = LibraryPreferences::default();
seeded.tab_order = vec!["tracks".into(), "folders".into()];
store.save_preferences(seeded).expect("save seed");
store
.set_folders_tree_sidebar_width(640)
.expect("set width");
let after = store.get_preferences().expect("get prefs");
assert_eq!(after.folders_tree_sidebar_width, Some(640));
assert_eq!(
after.tab_order,
vec!["tracks".to_string(), "folders".to_string()]
);
}
#[test]
fn legacy_json_without_sidebar_width_defaults_to_none() {
let legacy = r#"{
"tab_order": ["tracks", "folders", "albums", "artists"],
"hidden_tabs": [],
"folders_view_mode": "tree"
}"#;
let prefs: LibraryPreferences =
serde_json::from_str(legacy).expect("parse legacy json");
assert_eq!(prefs.folders_tree_sidebar_width, None);
}
}
+9 -361
View File
@@ -1,364 +1,12 @@
//! Playback preferences
//!
//! Stores user preferences for playback behavior (autoplay mode, etc.)
//! Tauri commands for playback and playback-adjacent UI preferences.
use log::info;
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AutoplayMode {
/// Continue playing within the source (album, playlist, etc.)
#[serde(rename = "continue")]
ContinueWithinSource,
/// Play only the selected track, then stop
#[serde(rename = "track_only")]
PlayTrackOnly,
/// Create infinite radio when queue ends (based on recent tracks)
#[serde(rename = "infinite")]
InfiniteRadio,
}
impl Default for AutoplayMode {
fn default() -> Self {
Self::ContinueWithinSource
}
}
impl AutoplayMode {
fn to_db_value(&self) -> &'static str {
match self {
AutoplayMode::ContinueWithinSource => "continue",
AutoplayMode::PlayTrackOnly => "track_only",
AutoplayMode::InfiniteRadio => "infinite",
}
}
fn from_db_value(s: &str) -> Self {
match s {
"track_only" => AutoplayMode::PlayTrackOnly,
"infinite" => AutoplayMode::InfiniteRadio,
_ => AutoplayMode::ContinueWithinSource,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaybackPreferences {
pub autoplay_mode: AutoplayMode,
pub show_context_icon: bool,
pub persist_session: bool,
/// Sub-preference of `persist_session`. When true, restoring a
/// session also seeks to `current_position_secs` of the saved
/// track. When false (default), the saved track is shown paused at
/// 0:00 and the user starts the next listen fresh — the common
/// preference for "open app the next morning to a clean slate"
/// (issue #317).
pub resume_playback_position: bool,
}
impl Default for PlaybackPreferences {
fn default() -> Self {
Self {
autoplay_mode: AutoplayMode::ContinueWithinSource,
show_context_icon: false,
persist_session: false,
resume_playback_position: false,
}
}
}
pub struct PlaybackPreferencesStore {
conn: Connection,
}
impl PlaybackPreferencesStore {
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open playback preferences database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| {
format!(
"Failed to enable WAL for playback preferences database: {}",
e
)
})?;
// Step 1: Create table with old schema (for new installs) or do nothing if exists
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS playback_preferences (
id INTEGER PRIMARY KEY CHECK (id = 1),
autoplay_mode TEXT NOT NULL DEFAULT 'continue'
);",
)
.map_err(|e| format!("Failed to create playback preferences table: {}", e))?;
// Step 2: Check if show_context_icon column exists
let column_exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('playback_preferences') WHERE name='show_context_icon'",
[],
|row| {
let count: i32 = row.get(0)?;
Ok(count > 0)
}
)
.unwrap_or(false);
info!(
"[PlaybackPrefs] Column show_context_icon exists: {}",
column_exists
);
// Step 3: Add column if it doesn't exist (migration for existing users)
if !column_exists {
info!("[PlaybackPrefs] Migrating: adding show_context_icon column");
conn.execute(
"ALTER TABLE playback_preferences ADD COLUMN show_context_icon INTEGER NOT NULL DEFAULT 0",
[]
).map_err(|e| format!("Failed to add show_context_icon column: {}", e))?;
info!("[PlaybackPrefs] Migration successful");
}
// Step 3b: Check if persist_session column exists
let persist_session_exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('playback_preferences') WHERE name='persist_session'",
[],
|row| {
let count: i32 = row.get(0)?;
Ok(count > 0)
}
)
.unwrap_or(false);
if !persist_session_exists {
info!("[PlaybackPrefs] Migrating: adding persist_session column");
conn.execute(
"ALTER TABLE playback_preferences ADD COLUMN persist_session INTEGER NOT NULL DEFAULT 0",
[]
).map_err(|e| format!("Failed to add persist_session column: {}", e))?;
info!("[PlaybackPrefs] persist_session migration successful");
}
// Step 3c: Check if resume_playback_position column exists
let resume_position_exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('playback_preferences') WHERE name='resume_playback_position'",
[],
|row| {
let count: i32 = row.get(0)?;
Ok(count > 0)
}
)
.unwrap_or(false);
if !resume_position_exists {
info!("[PlaybackPrefs] Migrating: adding resume_playback_position column");
conn.execute(
"ALTER TABLE playback_preferences ADD COLUMN resume_playback_position INTEGER NOT NULL DEFAULT 0",
[]
).map_err(|e| format!("Failed to add resume_playback_position column: {}", e))?;
info!("[PlaybackPrefs] resume_playback_position migration successful");
}
// Step 4: Insert default row if it doesn't exist
conn.execute(
"INSERT OR IGNORE INTO playback_preferences (id, autoplay_mode, show_context_icon, persist_session, resume_playback_position)
VALUES (1, 'continue', 0, 0, 0)",
[],
)
.map_err(|e| format!("Failed to insert default preferences: {}", e))?;
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
Self::open_at(&data_dir, "playback_preferences.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "playback_preferences.db")
}
pub fn get_preferences(&self) -> Result<PlaybackPreferences, String> {
self.conn
.query_row(
"SELECT autoplay_mode, show_context_icon, persist_session, resume_playback_position FROM playback_preferences WHERE id = 1",
[],
|row| {
let autoplay_str: String = row.get(0)?;
let show_icon: i32 = row.get(1)?;
let persist: i32 = row.get(2)?;
let resume_pos: i32 = row.get(3)?;
Ok(PlaybackPreferences {
autoplay_mode: AutoplayMode::from_db_value(&autoplay_str),
show_context_icon: show_icon != 0,
persist_session: persist != 0,
resume_playback_position: resume_pos != 0,
})
},
)
.map_err(|e| format!("Failed to get playback preferences: {}", e))
}
pub fn set_autoplay_mode(&self, mode: AutoplayMode) -> Result<(), String> {
self.conn
.execute(
"UPDATE playback_preferences SET autoplay_mode = ?1 WHERE id = 1",
params![mode.to_db_value()],
)
.map_err(|e| format!("Failed to set autoplay mode: {}", e))?;
Ok(())
}
pub fn set_show_context_icon(&self, show: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE playback_preferences SET show_context_icon = ?1 WHERE id = 1",
params![if show { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set show context icon: {}", e))?;
Ok(())
}
pub fn set_persist_session(&self, persist: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE playback_preferences SET persist_session = ?1 WHERE id = 1",
params![if persist { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set persist session: {}", e))?;
Ok(())
}
pub fn set_resume_playback_position(&self, resume: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE playback_preferences SET resume_playback_position = ?1 WHERE id = 1",
params![if resume { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set resume playback position: {}", e))?;
Ok(())
}
/// Reset all playback preferences to their default values
pub fn reset_all(&self) -> Result<PlaybackPreferences, String> {
let defaults = PlaybackPreferences::default();
self.conn
.execute(
"UPDATE playback_preferences SET autoplay_mode = ?1, show_context_icon = ?2, persist_session = ?3, resume_playback_position = ?4 WHERE id = 1",
params![
defaults.autoplay_mode.to_db_value(),
if defaults.show_context_icon { 1 } else { 0 },
if defaults.persist_session { 1 } else { 0 },
if defaults.resume_playback_position { 1 } else { 0 },
],
)
.map_err(|e| format!("Failed to reset playback preferences: {}", e))?;
Ok(defaults)
}
}
/// Global state wrapper for thread-safe access
pub struct PlaybackPreferencesState {
pub store: Arc<Mutex<Option<PlaybackPreferencesStore>>>,
}
impl PlaybackPreferencesState {
pub fn new() -> Result<Self, String> {
let store = PlaybackPreferencesStore::new()?;
Ok(Self {
store: Arc::new(Mutex::new(Some(store))),
})
}
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
let new_store = PlaybackPreferencesStore::new_at(base_dir)?;
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
*guard = Some(new_store);
Ok(())
}
pub fn teardown(&self) -> Result<(), String> {
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
*guard = None;
Ok(())
}
pub fn get_preferences(&self) -> Result<PlaybackPreferences, String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.get_preferences()
}
pub fn set_autoplay_mode(&self, mode: AutoplayMode) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_autoplay_mode(mode)
}
pub fn set_show_context_icon(&self, show: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_show_context_icon(show)
}
pub fn set_persist_session(&self, persist: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_persist_session(persist)
}
pub fn set_resume_playback_position(&self, resume: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_resume_playback_position(resume)
}
}
// Tauri commands
pub use qbz_app::settings::playback::{
AutoplayMode, PlaybackPreferences, PlaybackPreferencesState, PlaybackPreferencesStore,
};
#[tauri::command]
pub fn get_playback_preferences(
state: tauri::State<PlaybackPreferencesState>,
state: tauri::State<'_, PlaybackPreferencesState>,
) -> Result<PlaybackPreferences, String> {
state.get_preferences()
}
@@ -366,7 +14,7 @@ pub fn get_playback_preferences(
#[tauri::command]
pub fn set_autoplay_mode(
mode: String,
state: tauri::State<PlaybackPreferencesState>,
state: tauri::State<'_, PlaybackPreferencesState>,
) -> Result<(), String> {
let autoplay_mode = match mode.as_str() {
"continue" => AutoplayMode::ContinueWithinSource,
@@ -380,7 +28,7 @@ pub fn set_autoplay_mode(
#[tauri::command]
pub fn set_show_context_icon(
show: bool,
state: tauri::State<PlaybackPreferencesState>,
state: tauri::State<'_, PlaybackPreferencesState>,
) -> Result<(), String> {
state.set_show_context_icon(show)
}
@@ -388,7 +36,7 @@ pub fn set_show_context_icon(
#[tauri::command]
pub fn set_persist_session(
persist: bool,
state: tauri::State<PlaybackPreferencesState>,
state: tauri::State<'_, PlaybackPreferencesState>,
) -> Result<(), String> {
state.set_persist_session(persist)
}
@@ -396,7 +44,7 @@ pub fn set_persist_session(
#[tauri::command]
pub fn set_resume_playback_position(
resume: bool,
state: tauri::State<PlaybackPreferencesState>,
state: tauri::State<'_, PlaybackPreferencesState>,
) -> Result<(), String> {
state.set_resume_playback_position(resume)
}
+7 -530
View File
@@ -1,532 +1,9 @@
//! Remote control API settings
//! Tauri-facing re-exports for remote control settings.
//!
//! Stores user preferences for the local REST/WebSocket server:
//! - enabled: turn the API server on/off
//! - port: TCP port for the API server
//! - token: pairing token used by the remote control PWA
//! Persistence lives in `qbz-app`. API server lifecycle, TLS, QR generation,
//! CORS application, and live restart behavior remain host-owned.
use base64::Engine;
use rand::RngExt;
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RemoteControlSettings {
pub enabled: bool,
pub port: u16,
pub secure: bool,
pub token: String,
}
impl Default for RemoteControlSettings {
fn default() -> Self {
Self {
enabled: false,
port: 8182,
secure: true, // HTTPS by default for security
token: String::new(),
}
}
}
pub struct RemoteControlSettingsStore {
conn: Connection,
}
impl RemoteControlSettingsStore {
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open remote control settings database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| {
format!(
"Failed to enable WAL for remote control settings database: {}",
e
)
})?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS remote_control_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
enabled INTEGER NOT NULL DEFAULT 0,
port INTEGER NOT NULL DEFAULT 8182,
secure INTEGER NOT NULL DEFAULT 1,
token TEXT NOT NULL DEFAULT ''
);",
)
.map_err(|e| format!("Failed to create remote control settings table: {}", e))?;
ensure_secure_column(&conn)?;
let token = generate_token();
conn.execute(
"INSERT OR IGNORE INTO remote_control_settings (id, enabled, port, secure, token)
VALUES (1, 0, 8182, 0, ?1)",
params![token],
)
.map_err(|e| format!("Failed to insert default remote control settings: {}", e))?;
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
Self::open_at(&data_dir, "remote_control_settings.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "remote_control_settings.db")
}
pub fn get_settings(&self) -> Result<RemoteControlSettings, String> {
let mut settings = self
.conn
.query_row(
"SELECT enabled, port, secure, token FROM remote_control_settings WHERE id = 1",
[],
|row| {
let enabled: i32 = row.get(0)?;
let port: i64 = row.get(1)?;
let secure: i32 = row.get(2)?;
let token: String = row.get(3)?;
Ok(RemoteControlSettings {
enabled: enabled != 0,
port: port as u16,
secure: secure != 0,
token,
})
},
)
.map_err(|e| format!("Failed to get remote control settings: {}", e))?;
if settings.token.is_empty() {
settings.token = generate_token();
self.set_token(&settings.token)?;
}
Ok(settings)
}
pub fn set_enabled(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE remote_control_settings SET enabled = ?1 WHERE id = 1",
params![if enabled { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set remote control enabled: {}", e))?;
Ok(())
}
pub fn set_port(&self, port: u16) -> Result<(), String> {
self.conn
.execute(
"UPDATE remote_control_settings SET port = ?1 WHERE id = 1",
params![port as i64],
)
.map_err(|e| format!("Failed to set remote control port: {}", e))?;
Ok(())
}
pub fn set_secure(&self, secure: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE remote_control_settings SET secure = ?1 WHERE id = 1",
params![if secure { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set remote control secure: {}", e))?;
Ok(())
}
pub fn set_token(&self, token: &str) -> Result<(), String> {
self.conn
.execute(
"UPDATE remote_control_settings SET token = ?1 WHERE id = 1",
params![token],
)
.map_err(|e| format!("Failed to set remote control token: {}", e))?;
Ok(())
}
pub fn regenerate_token(&self) -> Result<String, String> {
let token = generate_token();
self.set_token(&token)?;
Ok(token)
}
}
/// Global state wrapper for thread-safe access
pub struct RemoteControlSettingsState {
pub store: Arc<Mutex<Option<RemoteControlSettingsStore>>>,
}
impl RemoteControlSettingsState {
pub fn new() -> Result<Self, String> {
Ok(Self {
store: Arc::new(Mutex::new(Some(RemoteControlSettingsStore::new()?))),
})
}
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
let new_store = RemoteControlSettingsStore::new_at(base_dir)?;
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock remote control settings store".to_string())?;
*guard = Some(new_store);
Ok(())
}
pub fn teardown(&self) -> Result<(), String> {
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock remote control settings store".to_string())?;
*guard = None;
Ok(())
}
pub fn get_settings(&self) -> Result<RemoteControlSettings, String> {
let guard = self
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.get_settings()
}
pub fn set_enabled(&self, enabled: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_enabled(enabled)
}
pub fn set_port(&self, port: u16) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_port(port)
}
pub fn set_secure(&self, secure: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_secure(secure)
}
pub fn regenerate_token(&self) -> Result<String, String> {
let guard = self
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.regenerate_token()
}
}
fn generate_token() -> String {
let mut bytes = [0u8; 32];
rand::rng().fill(&mut bytes);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
fn ensure_secure_column(conn: &Connection) -> Result<(), String> {
let mut stmt = conn
.prepare("PRAGMA table_info(remote_control_settings)")
.map_err(|e| format!("Failed to read settings schema: {}", e))?;
let mut rows = stmt
.query([])
.map_err(|e| format!("Failed to read settings schema: {}", e))?;
while let Some(row) = rows
.next()
.map_err(|e| format!("Schema read error: {}", e))?
{
let name: String = row
.get(1)
.map_err(|e| format!("Schema read error: {}", e))?;
if name == "secure" {
return Ok(());
}
}
conn.execute(
"ALTER TABLE remote_control_settings ADD COLUMN secure INTEGER NOT NULL DEFAULT 0",
[],
)
.map_err(|e| format!("Failed to migrate remote control settings: {}", e))?;
Ok(())
}
// ============================================================================
// Allowed Origins for CORS
// ============================================================================
/// Default allowed origins for the PWA
const DEFAULT_ALLOWED_ORIGINS: &[&str] = &[
"vicrodh.github.io",
"control.qbz.lol",
"www.control.qbz.lol",
];
/// Allowed origin entry
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AllowedOrigin {
pub id: i64,
pub origin: String,
pub is_default: bool,
}
/// Store for allowed CORS origins
pub struct AllowedOriginsStore {
conn: Connection,
}
impl AllowedOriginsStore {
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open allowed origins database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to enable WAL for allowed origins database: {}", e))?;
// Create allowed_origins table
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS allowed_origins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
origin TEXT NOT NULL UNIQUE,
is_default INTEGER NOT NULL DEFAULT 0
);",
)
.map_err(|e| format!("Failed to create allowed_origins table: {}", e))?;
// Insert default origins if table is empty
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM allowed_origins", [], |row| row.get(0))
.unwrap_or(0);
if count == 0 {
for origin in DEFAULT_ALLOWED_ORIGINS {
conn.execute(
"INSERT OR IGNORE INTO allowed_origins (origin, is_default) VALUES (?1, 1)",
params![origin],
)
.ok();
}
}
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
Self::open_at(&data_dir, "remote_control_settings.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "remote_control_settings.db")
}
/// Get all allowed origins
pub fn get_origins(&self) -> Result<Vec<AllowedOrigin>, String> {
let mut stmt = self.conn
.prepare("SELECT id, origin, is_default FROM allowed_origins ORDER BY is_default DESC, origin ASC")
.map_err(|e| format!("Failed to prepare query: {}", e))?;
let origins = stmt
.query_map([], |row| {
Ok(AllowedOrigin {
id: row.get(0)?,
origin: row.get(1)?,
is_default: row.get::<_, i32>(2)? != 0,
})
})
.map_err(|e| format!("Failed to query origins: {}", e))?;
origins
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Failed to collect origins: {}", e))
}
/// Check if an origin is allowed
pub fn is_origin_allowed(&self, origin: &str) -> bool {
self.conn
.query_row(
"SELECT 1 FROM allowed_origins WHERE origin = ?1",
params![origin],
|_| Ok(()),
)
.is_ok()
}
/// Add a new allowed origin
pub fn add_origin(&self, origin: &str) -> Result<AllowedOrigin, String> {
// Normalize origin (lowercase, trim)
let normalized = origin.trim().to_lowercase();
if normalized.is_empty() {
return Err("Origin cannot be empty".to_string());
}
self.conn
.execute(
"INSERT INTO allowed_origins (origin, is_default) VALUES (?1, 0)",
params![normalized],
)
.map_err(|e| {
if e.to_string().contains("UNIQUE constraint") {
"Origin already exists".to_string()
} else {
format!("Failed to add origin: {}", e)
}
})?;
let id = self.conn.last_insert_rowid();
Ok(AllowedOrigin {
id,
origin: normalized,
is_default: false,
})
}
/// Remove an allowed origin by ID
pub fn remove_origin(&self, id: i64) -> Result<(), String> {
let affected = self
.conn
.execute("DELETE FROM allowed_origins WHERE id = ?1", params![id])
.map_err(|e| format!("Failed to remove origin: {}", e))?;
if affected == 0 {
return Err("Origin not found".to_string());
}
Ok(())
}
/// Restore default origins (adds missing defaults back)
pub fn restore_defaults(&self) -> Result<(), String> {
for origin in DEFAULT_ALLOWED_ORIGINS {
self.conn
.execute(
"INSERT OR IGNORE INTO allowed_origins (origin, is_default) VALUES (?1, 1)",
params![origin],
)
.ok();
}
Ok(())
}
}
/// Global state wrapper for thread-safe access to allowed origins
pub struct AllowedOriginsState {
pub store: Arc<Mutex<Option<AllowedOriginsStore>>>,
}
impl AllowedOriginsState {
pub fn new() -> Result<Self, String> {
Ok(Self {
store: Arc::new(Mutex::new(Some(AllowedOriginsStore::new()?))),
})
}
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
let new_store = AllowedOriginsStore::new_at(base_dir)?;
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock allowed origins store".to_string())?;
*guard = Some(new_store);
Ok(())
}
pub fn teardown(&self) -> Result<(), String> {
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock allowed origins store".to_string())?;
*guard = None;
Ok(())
}
pub fn get_origins(&self) -> Result<Vec<AllowedOrigin>, String> {
let guard = self
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.get_origins()
}
pub fn is_origin_allowed(&self, origin: &str) -> bool {
self.store
.lock()
.map(|guard| {
guard
.as_ref()
.map(|s| s.is_origin_allowed(origin))
.unwrap_or(false)
})
.unwrap_or(false)
}
pub fn add_origin(&self, origin: &str) -> Result<AllowedOrigin, String> {
let guard = self
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.add_origin(origin)
}
pub fn remove_origin(&self, id: i64) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.remove_origin(id)
}
pub fn restore_defaults(&self) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.restore_defaults()
}
}
pub use qbz_app::settings::remote_control::{
AllowedOrigin, AllowedOriginsState, AllowedOriginsStore, RemoteControlSettings,
RemoteControlSettingsState, RemoteControlSettingsStore,
};
+4 -182
View File
@@ -1,182 +1,4 @@
//! Subscription validity tracking (for offline download compliance)
//!
//! Tracks when a user was first observed without a valid subscription. If the
//! invalid state persists for more than a grace period, offline downloads are
//! purged.
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex};
/// How long we keep honoring offline access after the server first
/// reports an invalid subscription.
///
/// Qobuz's own mobile app gives a 30-day offline grace window, so we
/// match that — this is the correct posture for the compliance story
/// with Qobuz (we're not weaker than them, but we're also not
/// suspiciously more lenient). Shorter windows would punish users on
/// flaky networks; longer windows would be a compliance red flag.
///
/// NOTE: the useful protection against "bulk-download and ghost"
/// attacks comes from the CMAF-at-rest offline cache (v2 format), not
/// from aggressive revalidation. This grace period is a belt on top of
/// those suspenders, not the primary defense.
const GRACE_PERIOD_SECS: i64 = 30 * 24 * 60 * 60; // 30 days — Qobuz mobile parity
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubscriptionState {
pub invalid_since: Option<i64>,
pub last_invalid_at: Option<i64>,
pub last_valid_at: Option<i64>,
pub last_checked_at: Option<i64>,
pub downloads_purged_at: Option<i64>,
}
impl Default for SubscriptionState {
fn default() -> Self {
Self {
invalid_since: None,
last_invalid_at: None,
last_valid_at: None,
last_checked_at: None,
downloads_purged_at: None,
}
}
}
pub struct SubscriptionStateStore {
conn: Connection,
}
impl SubscriptionStateStore {
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open subscription state database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| {
format!(
"Failed to enable WAL for subscription state database: {}",
e
)
})?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS subscription_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
invalid_since INTEGER,
last_invalid_at INTEGER,
last_valid_at INTEGER,
last_checked_at INTEGER,
downloads_purged_at INTEGER
);
INSERT OR IGNORE INTO subscription_state (id) VALUES (1);",
)
.map_err(|e| format!("Failed to create subscription state table: {}", e))?;
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
Self::open_at(&data_dir, "subscription_state.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "subscription_state.db")
}
pub fn get_state(&self) -> Result<SubscriptionState, String> {
self.conn
.query_row(
"SELECT invalid_since, last_invalid_at, last_valid_at, last_checked_at, downloads_purged_at
FROM subscription_state WHERE id = 1",
[],
|row| {
Ok(SubscriptionState {
invalid_since: row.get(0)?,
last_invalid_at: row.get(1)?,
last_valid_at: row.get(2)?,
last_checked_at: row.get(3)?,
downloads_purged_at: row.get(4)?,
})
},
)
.map_err(|e| format!("Failed to read subscription state: {}", e))
}
pub fn mark_valid(&self, now: i64) -> Result<(), String> {
self.conn
.execute(
"UPDATE subscription_state
SET invalid_since = NULL,
last_valid_at = ?1,
last_checked_at = ?1
WHERE id = 1",
params![now],
)
.map_err(|e| format!("Failed to update subscription state: {}", e))?;
Ok(())
}
pub fn mark_invalid(&self, now: i64) -> Result<(), String> {
// Only set invalid_since if it's not already set (first observation).
self.conn
.execute(
"UPDATE subscription_state
SET invalid_since = COALESCE(invalid_since, ?1),
last_invalid_at = ?1,
last_checked_at = ?1
WHERE id = 1",
params![now],
)
.map_err(|e| format!("Failed to update subscription state: {}", e))?;
Ok(())
}
pub fn mark_offline_cache_purged(&self, now: i64) -> Result<(), String> {
self.conn
.execute(
"UPDATE subscription_state SET downloads_purged_at = ?1 WHERE id = 1",
params![now],
)
.map_err(|e| format!("Failed to update purge timestamp: {}", e))?;
Ok(())
}
pub fn should_purge_offline_cache(&self, now: i64) -> Result<bool, String> {
let state = self.get_state()?;
let Some(invalid_since) = state.invalid_since else {
return Ok(false);
};
if now - invalid_since < GRACE_PERIOD_SECS {
return Ok(false);
}
// Purge at most once per invalid period.
if let Some(purged_at) = state.downloads_purged_at {
if purged_at >= invalid_since {
return Ok(false);
}
}
Ok(true)
}
}
pub type SubscriptionStateState = Arc<Mutex<Option<SubscriptionStateStore>>>;
pub fn create_subscription_state() -> Result<SubscriptionStateState, String> {
let store = SubscriptionStateStore::new()?;
Ok(Arc::new(Mutex::new(Some(store))))
}
pub fn create_empty_subscription_state() -> SubscriptionStateState {
Arc::new(Mutex::new(None))
}
pub use qbz_app::settings::subscription::{
create_empty_subscription_state, create_subscription_state, SubscriptionState,
SubscriptionStateState, SubscriptionStateStore,
};
+47 -308
View File
@@ -1,308 +1,47 @@
//! Tray icon settings
//!
//! Stores user preferences for system tray behavior:
//! - enable_tray: Show tray icon (requires restart)
//! - minimize_to_tray: Hide to tray when minimizing
//! - close_to_tray: Hide to tray when closing window
use log::info;
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraySettings {
/// Show system tray icon (requires restart to take effect)
pub enable_tray: bool,
/// Hide window to tray when clicking minimize
pub minimize_to_tray: bool,
/// Hide window to tray instead of quitting when clicking close
pub close_to_tray: bool,
/// Tray icon variant override:
/// - "auto" (default; follows system color scheme),
/// - "mono-light" (light-coloured glyph for dark panels — useful
/// on GNOME where the top bar is permanently dark),
/// - "mono-dark" (dark-coloured glyph for light panels),
/// - "color" (full colour vinyl logo).
#[serde(default = "default_tray_icon_theme")]
pub tray_icon_theme: String,
}
fn default_tray_icon_theme() -> String {
"auto".to_string()
}
/// Coerce free-form values to the supported set. Anything outside the
/// supported list falls back to "auto".
///
/// Legacy 1.2.9-pre values "light"/"dark" had inverted semantics
/// relative to the labels users saw (picking "Light" got the dark
/// glyph and vice-versa). They're remapped here to the value that
/// matches the user's original intent.
pub fn normalize_tray_icon_theme(input: &str) -> String {
match input {
"mono-light" | "mono-dark" | "color" | "auto" => input.to_string(),
"light" => "mono-light".to_string(),
"dark" => "mono-dark".to_string(),
_ => "auto".to_string(),
}
}
impl Default for TraySettings {
fn default() -> Self {
Self {
enable_tray: true,
minimize_to_tray: false,
close_to_tray: false,
tray_icon_theme: default_tray_icon_theme(),
}
}
}
pub struct TraySettingsStore {
conn: Connection,
}
impl TraySettingsStore {
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = dir.join(db_name);
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open tray settings database: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to enable WAL for tray settings database: {}", e))?;
// Create table
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS tray_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
enable_tray INTEGER NOT NULL DEFAULT 1,
minimize_to_tray INTEGER NOT NULL DEFAULT 0,
close_to_tray INTEGER NOT NULL DEFAULT 0
);",
)
.map_err(|e| format!("Failed to create tray settings table: {}", e))?;
// Migration: add tray_icon_theme column if it doesn't exist (older DBs).
let has_theme_column: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('tray_settings') WHERE name = 'tray_icon_theme'",
[],
|row| row.get(0),
)
.map_err(|e| format!("Failed to check tray_icon_theme column: {}", e))?;
if has_theme_column == 0 {
conn.execute_batch(
"ALTER TABLE tray_settings ADD COLUMN tray_icon_theme TEXT NOT NULL DEFAULT 'auto';",
)
.map_err(|e| format!("Failed to add tray_icon_theme column: {}", e))?;
}
// Insert default row if it doesn't exist
conn.execute(
"INSERT OR IGNORE INTO tray_settings (id, enable_tray, minimize_to_tray, close_to_tray, tray_icon_theme)
VALUES (1, 1, 0, 0, 'auto')",
[],
)
.map_err(|e| format!("Failed to insert default tray settings: {}", e))?;
info!("[TraySettings] Database initialized");
Ok(Self { conn })
}
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
Self::open_at(&data_dir, "tray_settings.db")
}
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
Self::open_at(base_dir, "tray_settings.db")
}
pub fn get_settings(&self) -> Result<TraySettings, String> {
self.conn
.query_row(
"SELECT enable_tray, minimize_to_tray, close_to_tray, tray_icon_theme FROM tray_settings WHERE id = 1",
[],
|row| {
let enable_tray: i32 = row.get(0)?;
let minimize_to_tray: i32 = row.get(1)?;
let close_to_tray: i32 = row.get(2)?;
let tray_icon_theme: String = row.get(3)?;
Ok(TraySettings {
enable_tray: enable_tray != 0,
minimize_to_tray: minimize_to_tray != 0,
close_to_tray: close_to_tray != 0,
tray_icon_theme: normalize_tray_icon_theme(&tray_icon_theme),
})
},
)
.map_err(|e| format!("Failed to get tray settings: {}", e))
}
pub fn set_enable_tray(&self, value: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE tray_settings SET enable_tray = ?1 WHERE id = 1",
params![if value { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set enable_tray: {}", e))?;
Ok(())
}
pub fn set_minimize_to_tray(&self, value: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE tray_settings SET minimize_to_tray = ?1 WHERE id = 1",
params![if value { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set minimize_to_tray: {}", e))?;
Ok(())
}
pub fn set_close_to_tray(&self, value: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE tray_settings SET close_to_tray = ?1 WHERE id = 1",
params![if value { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set close_to_tray: {}", e))?;
Ok(())
}
pub fn set_tray_icon_theme(&self, value: &str) -> Result<(), String> {
let normalized = normalize_tray_icon_theme(value);
self.conn
.execute(
"UPDATE tray_settings SET tray_icon_theme = ?1 WHERE id = 1",
params![normalized],
)
.map_err(|e| format!("Failed to set tray_icon_theme: {}", e))?;
Ok(())
}
}
/// Global state wrapper for thread-safe access
pub struct TraySettingsState {
pub store: Arc<Mutex<Option<TraySettingsStore>>>,
}
impl TraySettingsState {
pub fn new() -> Result<Self, String> {
let store = TraySettingsStore::new()?;
Ok(Self {
store: Arc::new(Mutex::new(Some(store))),
})
}
pub fn new_empty() -> Self {
Self {
store: Arc::new(Mutex::new(None)),
}
}
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
let new_store = TraySettingsStore::new_at(base_dir)?;
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
*guard = Some(new_store);
Ok(())
}
pub fn teardown(&self) -> Result<(), String> {
let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
*guard = None;
Ok(())
}
pub fn get_settings(&self) -> Result<TraySettings, String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.get_settings()
}
pub fn set_enable_tray(&self, value: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_enable_tray(value)
}
pub fn set_minimize_to_tray(&self, value: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_minimize_to_tray(value)
}
pub fn set_close_to_tray(&self, value: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_close_to_tray(value)
}
pub fn set_tray_icon_theme(&self, value: &str) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock tray settings store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_tray_icon_theme(value)
}
}
// Tauri commands
#[tauri::command]
pub fn get_tray_settings(state: tauri::State<TraySettingsState>) -> Result<TraySettings, String> {
state.get_settings()
}
#[tauri::command]
pub fn set_enable_tray(value: bool, state: tauri::State<TraySettingsState>) -> Result<(), String> {
info!(
"[TraySettings] Setting enable_tray to {} (restart required)",
value
);
state.set_enable_tray(value)
}
#[tauri::command]
pub fn set_minimize_to_tray(
value: bool,
state: tauri::State<TraySettingsState>,
) -> Result<(), String> {
info!("[TraySettings] Setting minimize_to_tray to {}", value);
state.set_minimize_to_tray(value)
}
#[tauri::command]
pub fn set_close_to_tray(
value: bool,
state: tauri::State<TraySettingsState>,
) -> Result<(), String> {
info!("[TraySettings] Setting close_to_tray to {}", value);
state.set_close_to_tray(value)
}
//! Tauri commands for tray settings.
//!
//! Persisted tray preferences live in `qbz-app`. Runtime tray creation,
//! icon updates, window behavior, and emitted events remain Tauri-owned.
use log::info;
pub use qbz_app::settings::tray::{
normalize_tray_icon_theme, TraySettings, TraySettingsState, TraySettingsStore,
};
#[tauri::command]
pub fn get_tray_settings(
state: tauri::State<'_, TraySettingsState>,
) -> Result<TraySettings, String> {
state.get_settings()
}
#[tauri::command]
pub fn set_enable_tray(
value: bool,
state: tauri::State<'_, TraySettingsState>,
) -> Result<(), String> {
info!(
"[TraySettings] Setting enable_tray to {} (restart required)",
value
);
state.set_enable_tray(value)
}
#[tauri::command]
pub fn set_minimize_to_tray(
value: bool,
state: tauri::State<'_, TraySettingsState>,
) -> Result<(), String> {
info!("[TraySettings] Setting minimize_to_tray to {}", value);
state.set_minimize_to_tray(value)
}
#[tauri::command]
pub fn set_close_to_tray(
value: bool,
state: tauri::State<'_, TraySettingsState>,
) -> Result<(), String> {
info!("[TraySettings] Setting close_to_tray to {}", value);
state.set_close_to_tray(value)
}
+5 -4
View File
@@ -130,10 +130,11 @@ pub fn v2_discord_rpc_update(
// Bridge the Flatpak sandbox to Discord's IPC socket if we're
// running under Flatpak. No-op outside that context.
prepare_flatpak_discord_socket_links();
if let Ok(mut client) = DiscordIpcClient::new(DISCORD_APP_ID) {
if client.connect().is_ok() {
*guard = Some(client);
}
// discord-rich-presence 1.0+: DiscordIpcClient::new is infallible
// (returns Self directly); failures show up at connect() time.
let mut client = DiscordIpcClient::new(DISCORD_APP_ID);
if client.connect().is_ok() {
*guard = Some(client);
}
}
+6
View File
@@ -0,0 +1,6 @@
//! Tauri adapter for host GPU detection.
//!
//! The portable detection and preference parsing live in `qbz-app`; Tauri keeps
//! the existing import surface and applies the resulting env actions in `main.rs`.
pub use qbz_app::graphics_detection::*;
+60
View File
@@ -25,6 +25,8 @@ pub fn main_window_built_transparent() -> bool {
}
#[cfg(target_os = "linux")]
pub mod autoconfig_graphics;
pub mod boot_watchdog;
pub mod graphics_detection;
pub mod api;
pub mod api_cache;
@@ -540,6 +542,31 @@ fn should_use_main_window_transparency() -> bool {
return false;
}
// Hardware-acceleration gate. A transparent main window forces GTK
// to re-paint the client-side decoration chrome (rounded corners +
// subtle edge) on every frame. Under GPU compositing the cost is
// imperceptible; under SW compositing it becomes the dominant frame-
// budget cost — modals, the splash spinner, and anything else that
// animates visibly drop frames. Force opaque whenever HW accel is
// off, regardless of the user's "match system window chrome"
// preference. The preference itself is preserved in the DB and
// reactivates automatically the next time HW accel is on.
//
// Resolution mirrors main.rs: env var QBZ_HARDWARE_ACCEL (0/1)
// overrides the DB; otherwise read the DB; otherwise assume on.
let hw_accel_enabled = match std::env::var("QBZ_HARDWARE_ACCEL").as_deref() {
Ok("0") => false,
Ok("1") => true,
_ => config::graphics_settings::GraphicsSettingsStore::new_readonly()
.ok()
.and_then(|s| s.get_settings().ok())
.map(|gs| gs.hardware_acceleration)
.unwrap_or(true),
};
if !hw_accel_enabled {
return false;
}
// Opt-in via persisted settings: "match system window chrome" wants
// rounded corners, which need a transparent window so the webview
// background stops painting white outside the radius.
@@ -1219,6 +1246,31 @@ pub fn run(qconnect_cli_override: Option<bool>) {
let mut last_track_id: u64 = 0;
loop {
// Surface backend init failures from the V2 player to
// the frontend exactly once. record_stream_error stores
// the user-readable message; take_stream_error_message
// drains it so we never re-emit the same toast on
// subsequent ticks. Only the V2 (qbz-player) path
// populates this — the legacy player's init failures
// continue to surface via existing channels.
let init_error: Option<String> = v2_player_state
.read()
.await
.as_ref()
.and_then(qbz_player::SharedState::take_stream_error_message);
if let Some(message) = init_error {
#[derive(serde::Serialize, Clone)]
struct AudioInitFailedPayload {
message: String,
}
if let Err(e) = app_handle.emit(
"audio:init-failed",
AudioInitFailedPayload { message },
) {
log::warn!("Failed to emit audio:init-failed event: {}", e);
}
}
// Check V2 player state first (takes priority if active)
// V2 player is accessed via async lock, but we only need a clone
let v2_state_opt: Option<qbz_player::SharedState> =
@@ -1606,6 +1658,13 @@ pub fn run(qconnect_cli_override: Option<bool>) {
commands_v2::v2_set_gdk_scale,
commands_v2::v2_set_gdk_dpi_scale,
commands_v2::v2_set_gsk_renderer,
commands_v2::v2_set_preferred_gpu,
commands_v2::v2_set_nvidia_compat_mode,
commands_v2::v2_get_graphics_recommendation,
commands_v2::v2_enumerate_gpus,
commands_v2::v2_mark_boot_succeeded,
commands_v2::v2_get_crash_flags,
commands_v2::v2_clear_crash_flag,
commands_v2::v2_clear_cache,
commands_v2::v2_clear_artist_cache,
commands_v2::v2_get_vector_store_stats,
@@ -1763,6 +1822,7 @@ pub fn run(qconnect_cli_override: Option<bool>) {
commands_v2::v2_library_get_stats,
commands_v2::v2_library_get_albums,
commands_v2::v2_library_get_albums_metadata,
commands_v2::v2_library_get_albums_page,
commands_v2::v2_library_get_folders,
commands_v2::v2_library_get_folders_with_metadata,
commands_v2::v2_library_count_folder_tracks,
+157 -30
View File
@@ -288,8 +288,23 @@ fn main() {
}
};
// Developer settings: force_dmabuf override (from Settings > Developer Mode)
let dev_force_dmabuf =
// ---- Read remaining risky settings (no env vars set yet) ----
let preferred_gpu_raw_intended = graphics_db
.as_ref()
.map(|s| s.preferred_gpu.clone())
.unwrap_or_else(|| "auto".to_string());
let force_x11_db = graphics_db.as_ref().map(|s| s.force_x11).unwrap_or(false);
// NVIDIA Wayland compatibility opt-in: when ON, sets
// __NV_DISABLE_EXPLICIT_SYNC=1 to bypass libnvidia-egl-gbm's
// buggy implicit-sync paths AND lifts the defensive
// compositing-off shutdown that QBZ applies to NVIDIA-only
// Wayland sessions.
let nvidia_compat_mode = graphics_db
.as_ref()
.map(|s| s.nvidia_compat_mode)
.unwrap_or(false);
let dev_force_dmabuf_intended =
match qbz_nix_lib::config::developer_settings::DeveloperSettingsStore::new() {
Ok(store) => match store.get_settings() {
Ok(settings) => settings.force_dmabuf,
@@ -309,6 +324,104 @@ fn main() {
false
}
};
// ---- Boot watchdog: revert risky settings if previous boot crashed ----
// If a prior launch wrote a pending marker that never got cleared,
// those settings crashed WebKit before first paint. The watchdog
// returns the resolved values to actually use this boot (usually
// the intended ones, with revert flags set for any knob that
// matched the prior crash). It also writes a fresh pending marker
// for THIS boot; the frontend's first-paint hook clears it.
let watchdog_resolution = qbz_nix_lib::boot_watchdog::before_webkit_init(
qbz_nix_lib::boot_watchdog::BootAttempt {
hardware_acceleration: hardware_accel,
force_dmabuf: dev_force_dmabuf_intended,
preferred_gpu: preferred_gpu_raw_intended.clone(),
force_x11: force_x11_db,
},
);
let mut hardware_accel = watchdog_resolution.hardware_acceleration;
let mut dev_force_dmabuf = watchdog_resolution.force_dmabuf;
let preferred_gpu_raw = watchdog_resolution.preferred_gpu.clone();
for msg in &watchdog_resolution.recovery_messages {
eprintln!("[QBZ] {}", msg);
qbz_nix_lib::logging::log_startup(&format!("[QBZ] {}", msg));
}
// Software GPU choice (preferred_gpu=software) implies CPU mode:
// forcing Mesa llvmpipe AND keeping WebKit's GPU compositing on
// would just round-trip software pixels through a "GPU" path
// that has no GPU. Treat it as the nuclear "no HW" option and
// mirror it into hardware_acceleration so the WEBKIT_DISABLE_*
// env vars below also fire.
if preferred_gpu_raw == "software" {
if hardware_accel || dev_force_dmabuf {
qbz_nix_lib::logging::log_startup(
"[QBZ] preferred_gpu=software → forcing hardware_acceleration=off + DMA-BUF=off (full CPU mode)",
);
}
hardware_accel = false;
dev_force_dmabuf = false;
}
// NVIDIA Wayland compatibility mode: opt-in toggle that says
// "I have libnvidia-egl-gbm installed, try the new path".
// Sets __NV_DISABLE_EXPLICIT_SYNC=1 to bypass the buggy implicit
// sync code that produces "Failed to create GBM buffer of size
// NxN: Invalid argument" under WebKit + NVIDIA + Wayland on
// driver 550+. Set unconditionally when the toggle is ON — the
// env var is a no-op when NVIDIA isn't in use.
if nvidia_compat_mode {
qbz_nix_lib::logging::log_startup(
"[QBZ] NVIDIA compatibility mode ON: setting __NV_DISABLE_EXPLICIT_SYNC=1",
);
std::env::set_var("__NV_DISABLE_EXPLICIT_SYNC", "1");
}
// ---- Apply preferred_gpu env vars (using watchdog-resolved value) ----
let preferred_gpu_choice =
qbz_nix_lib::graphics_detection::PreferredGpu::parse(&preferred_gpu_raw);
if !matches!(
preferred_gpu_choice,
qbz_nix_lib::graphics_detection::PreferredGpu::Auto
) {
let detected = qbz_nix_lib::graphics_detection::enumerate_gpus();
let env_pairs = qbz_nix_lib::graphics_detection::env_vars_for_preferred_gpu(
&preferred_gpu_choice,
&detected,
);
if env_pairs.is_empty() {
qbz_nix_lib::logging::log_startup(&format!(
"[QBZ] preferred_gpu={:?} could not be resolved (no matching detected GPU); falling back to system default",
preferred_gpu_raw
));
} else {
qbz_nix_lib::logging::log_startup(&format!(
"[QBZ] preferred_gpu={:?}: applying GPU pin env vars",
preferred_gpu_raw
));
for action in &env_pairs {
match action {
qbz_nix_lib::graphics_detection::EnvAction::Set(name, value) => {
qbz_nix_lib::logging::log_startup(&format!(
"[QBZ] set {}={}",
name, value
));
std::env::set_var(name, value);
}
qbz_nix_lib::graphics_detection::EnvAction::Unset(name) => {
qbz_nix_lib::logging::log_startup(&format!(
"[QBZ] unset {}",
name
));
std::env::remove_var(name);
}
}
}
}
}
// ---- Apply dev_force_dmabuf env var (using watchdog-resolved value) ----
if dev_force_dmabuf {
std::env::set_var("QBZ_FORCE_DMABUF", "1");
qbz_nix_lib::logging::log_startup(
@@ -321,8 +434,8 @@ fn main() {
let force_dmabuf = std::env::var("QBZ_FORCE_DMABUF").as_deref() == Ok("1");
let disable_dmabuf = std::env::var("QBZ_DISABLE_DMABUF").as_deref() == Ok("1");
// Force X11: persistent setting from DB, env var overrides (crash recovery)
let force_x11_db = graphics_db.as_ref().map(|s| s.force_x11).unwrap_or(false);
// Force X11: persistent setting from DB (read above for watchdog),
// env var overrides (crash recovery).
let force_x11 = match std::env::var("QBZ_FORCE_X11").as_deref() {
Ok("1") => {
qbz_nix_lib::logging::log_startup("[QBZ] Env override: QBZ_FORCE_X11=1");
@@ -453,18 +566,32 @@ fn main() {
//
// QBZ_HARDWARE_ACCEL=0 is the nuclear option: disables everything.
// QBZ_HARDWARE_ACCEL=1 (explicit) bypasses ALL safety measures.
// Default (no env var): v1.1.9 targeted mitigations only.
// Default (no env var): GPU compositing on, DMA-BUF off (opt-in).
//
// v1.1.9 defaults:
// - Wayland: COMPOSITING off + DMABUF off (prevents EGL/protocol errors)
// - X11 + NVIDIA: DMABUF off only (prevents Error 71)
// - X11 + non-NVIDIA: nothing disabled (full GPU acceleration)
// Policy (since 1.2.13):
// - GPU compositing: opt-out — on by default. User can disable in
// Settings if their stack is broken. The CPU-mode lite path
// covers the disabled case.
// - DMA-BUF renderer: opt-in — off by default. The user has to
// flip Settings > Developer > force_dmabuf to turn it on.
// Rationale: DMA-BUF is the most fragile WebKitGTK feature on
// Linux — NVIDIA+Wayland breaks reliably (EGL Error 71),
// hybrid Intel+NVIDIA can flicker, and the failure mode is
// catastrophic (black window, missing textures) rather than
// degraded. The perf gain when it works is marginal (~5-10%
// memory bandwidth on heavy compositing) and not worth the
// rendering risk for a music app.
// - Wayland+NVIDIA-only compositing: still gated off automatically
// because the EGL protocol errors corrupt the whole window,
// not just texture sharing. Separate axis from DMA-BUF.
//
// Override hierarchy (highest to lowest):
// 1. QBZ_HARDWARE_ACCEL=0 → disable everything
// 2. QBZ_HARDWARE_ACCEL=1 → enable everything (bypass all safety)
// 3. QBZ_FORCE_DMABUF=1 / QBZ_DISABLE_DMABUF=1 → fine-grained DMA-BUF
// 4. Auto-detection (Wayland/NVIDIA)
// 2. QBZ_HARDWARE_ACCEL=1 → enable everything (bypass all safety,
// including the DMA-BUF opt-in default)
// 3. QBZ_FORCE_DMABUF=1 / QBZ_DISABLE_DMABUF=1 → explicit override
// 4. force_dmabuf DB toggle → opt-in DMA-BUF
// 5. Default → DMA-BUF off, compositing on (except Wayland+NV-only)
if !hardware_accel {
// Nuclear opt-out: disable all GPU compositing and DMA-BUF
qbz_nix_lib::logging::log_startup(
@@ -476,17 +603,27 @@ fn main() {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
} else if std::env::var("QBZ_HARDWARE_ACCEL").as_deref() == Ok("1") {
// Explicit full GPU: skip ALL safety measures
// Explicit full GPU: skip ALL safety measures, including the
// DMA-BUF opt-in default. Power-user escape hatch.
qbz_nix_lib::logging::log_startup("[QBZ] Full GPU mode: all WebKit safety bypassed");
} else {
// Default path: v1.1.9 targeted mitigations
// Default path: opt-in DMA-BUF, opt-out compositing
// --- Compositing mode ---
// NVIDIA-only on Wayland has protocol errors with compositing.
// Hybrid Intel+NVIDIA and AMD systems can handle compositing fine
// (WebKit uses the iGPU, not the dGPU).
// If user forced DMA-BUF on, they want full GPU — skip compositing disable too.
if is_wayland && !force_x11 && has_nvidia && !has_amd && !has_intel && !force_dmabuf {
// NVIDIA Wayland compat mode also bypasses this safety —
// the user has the fix in place and wants the full path.
if is_wayland
&& !force_x11
&& has_nvidia
&& !has_amd
&& !has_intel
&& !force_dmabuf
&& !nvidia_compat_mode
{
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
qbz_nix_lib::logging::log_startup(
"[QBZ] Wayland+NVIDIA-only: compositing mode disabled (prevents protocol errors)",
@@ -495,31 +632,21 @@ fn main() {
qbz_nix_lib::logging::log_startup("[QBZ] Wayland: compositing mode enabled");
}
// --- DMA-BUF renderer control ---
// --- DMA-BUF renderer control (opt-in) ---
if force_dmabuf {
qbz_nix_lib::logging::log_startup(
"[QBZ] User override: DMA-BUF renderer forced ON (QBZ_FORCE_DMABUF=1)",
"[QBZ] User opt-in: DMA-BUF renderer ON (Settings > Developer > force_dmabuf)",
);
} else if disable_dmabuf {
qbz_nix_lib::logging::log_startup(
"[QBZ] User override: DMA-BUF renderer forced OFF (QBZ_DISABLE_DMABUF=1)",
"[QBZ] Env override: DMA-BUF renderer OFF (QBZ_DISABLE_DMABUF=1)",
);
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
} else if is_wayland && !force_x11 && has_nvidia && !has_amd && !has_intel {
// NVIDIA-only on Wayland: disable DMA-BUF (Error 71 protocol error)
qbz_nix_lib::logging::log_startup(
"[QBZ] Wayland+NVIDIA-only: DMA-BUF renderer disabled (prevents Error 71)",
);
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
} else if has_nvidia && !has_intel && !has_amd && !is_wayland {
// X11 + NVIDIA: disable DMA-BUF only (keeps full compositing)
qbz_nix_lib::logging::log_startup("[QBZ] NVIDIA on X11: DMA-BUF renderer disabled");
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
} else {
// AMD/Intel on Wayland or X11: full GPU acceleration
qbz_nix_lib::logging::log_startup(
"[QBZ] Using default WebKit renderer (full hardware acceleration)",
"[QBZ] DMA-BUF renderer off by default (opt-in via Settings > Developer)",
);
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
}
+122 -18
View File
@@ -86,6 +86,17 @@ enum AudioCommand {
sample_rate: u32,
channels: u16,
duration_secs: u64,
/// Resume offset in seconds. 0 = start from beginning. >0 = the
/// audio thread waits until enough bytes are buffered to cover
/// (position + headroom) seconds, then decodes-and-discards
/// samples up to `start_position_secs` before feeding the engine.
/// Avoids the play-then-seek race where Seek would silently
/// bail because `current_audio_data` is unset for streaming.
start_position_secs: u64,
/// Total content length in bytes. Used together with
/// `duration_secs` to estimate bytes-per-second when computing
/// how much buffer is needed for `start_position_secs > 0`.
content_length: u64,
},
/// Pause playback
Pause,
@@ -1619,13 +1630,16 @@ impl Player {
sample_rate,
channels,
duration_secs,
start_position_secs,
content_length,
} => {
log::info!(
"Audio thread: starting streaming playback for track {} ({}Hz, {} channels, {}s)",
"Audio thread: starting streaming playback for track {} ({}Hz, {} channels, {}s, start={}s)",
track_id,
sample_rate,
channels,
duration_secs
duration_secs,
start_position_secs
);
*pause_suspend_deadline = None;
@@ -1839,12 +1853,45 @@ impl Player {
let volume = thread_state.volume.load(Ordering::SeqCst) as f32 / 100.0;
apply_engine_volume(&stream_opt, &engine, volume);
// Wait for minimum buffer before starting playback
// Wait for minimum buffer before starting playback.
// When start_position_secs > 0 (session resume),
// also wait for enough buffer to cover the resume
// offset plus an 8s headroom — the eager pre-skip
// below will decode-and-discard up to the offset,
// and we need bytes in the buffer to feed it
// without blocking the audio device.
log::info!("Streaming: waiting for initial buffer...");
let start_wait = Instant::now();
let max_wait = Duration::from_secs(30);
let max_wait = Duration::from_secs(60);
while !source.has_min_buffer() && start_wait.elapsed() < max_wait {
let bytes_per_sec_estimate: u64 = if duration_secs > 0
&& content_length > 0
{
content_length / duration_secs
} else {
// Conservative fallback: ~200 KB/s ≈ 16/44 FLAC.
200_000
};
let resume_buffer_target: u64 = if start_position_secs > 0 {
bytes_per_sec_estimate
.saturating_mul(start_position_secs.saturating_add(8))
} else {
0
};
let buffer_sufficient = |src: &Arc<BufferedMediaSource>| -> bool {
if !src.has_min_buffer() {
return false;
}
if resume_buffer_target == 0 {
return true;
}
(src.buffer_size() as u64) >= resume_buffer_target
};
while !buffer_sufficient(&source)
&& start_wait.elapsed() < max_wait
{
std::thread::sleep(Duration::from_millis(50));
}
@@ -1852,11 +1899,20 @@ impl Player {
log::error!("Streaming: timeout waiting for initial buffer");
return;
}
if resume_buffer_target > 0
&& (source.buffer_size() as u64) < resume_buffer_target
{
log::warn!(
"Streaming: timed out waiting for resume buffer (got {} bytes, wanted {}); pre-skip may underrun briefly",
source.buffer_size(),
resume_buffer_target
);
}
let buffer_wait_ms = start_wait.elapsed().as_millis();
log::info!(
"Streaming: initial buffer ready in {}ms, creating incremental decoder...",
buffer_wait_ms
"Streaming: buffer ready in {}ms ({} bytes, target {}), creating incremental decoder...",
buffer_wait_ms, source.buffer_size(), resume_buffer_target
);
// Create incremental streaming source - this starts playback IMMEDIATELY
@@ -1934,8 +1990,41 @@ impl Player {
thread_state.set_normalization_gain(normalization);
// Box the incremental source to match the expected type
let source_to_play: Box<dyn Source<Item = f32> + Send> =
let mut source_to_play: Box<dyn Source<Item = f32> + Send> =
Box::new(incremental_source);
// Eager pre-skip for session resume. We decode and
// discard samples here so that the engine's first
// pull doesn't have to do the work synchronously,
// which would underrun the audio device for
// multi-second offsets. The buffer wait above
// guarantees there's enough downloaded data to
// feed this loop without blocking on bytes.
if start_position_secs > 0 {
let target_samples: u64 = (start_position_secs)
.saturating_mul(actual_sr as u64)
.saturating_mul(actual_ch as u64);
let skip_start = Instant::now();
let mut skipped: u64 = 0;
while skipped < target_samples {
if source_to_play.next().is_none() {
log::warn!(
"Resume: source ended before reaching {}s (pre-skipped {} samples)",
start_position_secs,
skipped
);
break;
}
skipped += 1;
}
log::info!(
"Resume: pre-skipped {} samples ({}s) in {}ms",
skipped,
start_position_secs,
skip_start.elapsed().as_millis()
);
}
// Wrap source with diagnostic, normalization, and visualizer
let source_to_play = wrap_source(
source_to_play,
@@ -1950,16 +2039,19 @@ impl Player {
}
thread_state.is_playing.store(true, Ordering::SeqCst);
thread_state.position.store(0, Ordering::SeqCst);
thread_state
.position
.store(start_position_secs, Ordering::SeqCst);
thread_state
.current_track_id
.store(track_id, Ordering::SeqCst);
thread_state.start_playback_timer(0);
thread_state.start_playback_timer(start_position_secs);
*current_engine = Some(engine);
log::info!(
"Audio thread: streaming playback STARTED in {}ms (incremental decode active)",
start_wait.elapsed().as_millis()
"Audio thread: streaming playback STARTED in {}ms at {}s (incremental decode active)",
start_wait.elapsed().as_millis(),
start_position_secs
);
}
AudioCommand::Pause => {
@@ -2689,7 +2781,10 @@ impl Player {
}
/// Play from streaming source (starts playback before full download)
/// Returns the BufferWriter so caller can push data as it downloads
/// Returns the BufferWriter so caller can push data as it downloads.
/// `start_position_secs` > 0 turns this into a session-resume play:
/// the audio thread waits for enough buffer to cover the offset and
/// pre-skips decode output up to that point before engaging audio.
pub fn play_streaming(
&self,
track_id: u64,
@@ -2698,14 +2793,16 @@ impl Player {
content_length: u64,
buffer_seconds: u8,
duration_secs: u64,
start_position_secs: u64,
) -> Result<BufferWriter, String> {
log::info!(
"Player: Starting streaming playback for track {} ({}Hz, {}ch, {} bytes total, {}s)",
"Player: Starting streaming playback for track {} ({}Hz, {}ch, {} bytes total, {}s, start={}s)",
track_id,
sample_rate,
channels,
content_length,
duration_secs
duration_secs,
start_position_secs
);
// Use StreamingConfig::from_seconds for proper buffer sizing
@@ -2721,6 +2818,8 @@ impl Player {
sample_rate,
channels,
duration_secs,
start_position_secs,
content_length,
})
.map_err(|e| {
log::error!("Player: Failed to send streaming command: {}", e);
@@ -2732,7 +2831,8 @@ impl Player {
}
/// Play from streaming source with dynamic buffer based on measured speed
/// Returns the BufferWriter so caller can push data as it downloads
/// Returns the BufferWriter so caller can push data as it downloads.
/// `start_position_secs` > 0 signals session resume (see `play_streaming`).
pub fn play_streaming_dynamic(
&self,
track_id: u64,
@@ -2742,16 +2842,18 @@ impl Player {
content_length: u64,
speed_mbps: f64,
duration_secs: u64,
start_position_secs: u64,
) -> Result<BufferWriter, String> {
log::info!(
"Player: Starting dynamic streaming for track {} ({}Hz, {}ch, {}-bit, {:.2} MB, {:.1} MB/s, {}s)",
"Player: Starting dynamic streaming for track {} ({}Hz, {}ch, {}-bit, {:.2} MB, {:.1} MB/s, {}s, start={}s)",
track_id,
sample_rate,
channels,
bit_depth,
content_length as f64 / (1024.0 * 1024.0),
speed_mbps,
duration_secs
duration_secs,
start_position_secs
);
// Update shared state with actual stream quality
@@ -2770,6 +2872,8 @@ impl Player {
sample_rate,
channels,
duration_secs,
start_position_secs,
content_length,
})
.map_err(|e| {
log::error!("Player: Failed to send streaming command: {}", e);
+1
View File
@@ -131,6 +131,7 @@ async fn stream_remote_track_into_player(
stream_info.content_length,
stream_info.speed_mbps,
duration_secs,
0, // QConnect tracks always start from 0; resume is local-only
)
.map_err(|err| format!("start streaming remote track {track_id}: {err}"))?;
+5 -404
View File
@@ -1,360 +1,10 @@
//! Runtime Session Contract
//!
//! Implements the UI-agnostic runtime lifecycle as per ADR_RUNTIME_SESSION_CONTRACT.md
//!
//! Key concepts:
//! - Single bootstrap entrypoint
//! - Canonical state machine (Uninitialized -> Ready)
//! - Typed errors (no string matching)
//! - Command gating in backend
//! - Lifecycle events
//! Tauri runtime state wrapper.
use serde::{Deserialize, Serialize};
pub use qbz_app::runtime::{
CommandRequirement, DegradedReason, RuntimeError, RuntimeEvent, RuntimeManager, RuntimeState,
RuntimeStatus,
};
use std::sync::Arc;
use tokio::sync::RwLock;
/// Canonical runtime states
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", content = "data")]
pub enum RuntimeState {
/// Initial state - nothing initialized
Uninitialized,
/// Client initialized but no authentication
InitializedNoAuth,
/// Authenticated but per-user session not activated (transitional)
AuthenticatedNoUserSession { user_id: u64 },
/// Fully ready - all systems operational
Ready { user_id: u64 },
/// Degraded state - something is broken
Degraded { reason: DegradedReason },
}
impl Default for RuntimeState {
fn default() -> Self {
Self::Uninitialized
}
}
/// Reasons for degraded state
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "code", content = "message")]
pub enum DegradedReason {
/// Bundle token extraction failed
BundleExtractionFailed(String),
/// CoreBridge initialization failed
CoreBridgeInitFailed(String),
/// Network connectivity issues
NetworkError(String),
/// Database/storage issues
StorageError(String),
/// Session activation failed (per-user stores not initialized)
SessionActivationFailed(String),
}
/// Full runtime status returned by runtime_get_status and runtime_bootstrap
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeStatus {
/// Current state
pub state: RuntimeState,
/// User ID if authenticated (None if not logged in)
pub user_id: Option<u64>,
/// Whether the API client is initialized (bundle tokens extracted)
pub client_initialized: bool,
/// Whether legacy auth is active
pub legacy_auth: bool,
/// Whether CoreBridge/V2 auth is active
pub corebridge_auth: bool,
/// Whether per-user session is activated
pub session_activated: bool,
/// Degraded reason if state is Degraded
pub degraded_reason: Option<DegradedReason>,
}
impl Default for RuntimeStatus {
fn default() -> Self {
Self {
state: RuntimeState::Uninitialized,
user_id: None,
client_initialized: false,
legacy_auth: false,
corebridge_auth: false,
session_activated: false,
degraded_reason: None,
}
}
}
/// Typed runtime errors - no string matching in clients
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "code", content = "details")]
pub enum RuntimeError {
/// Runtime not initialized - call runtime_bootstrap first
RuntimeNotInitialized,
/// Authentication required for this operation
AuthRequired,
/// Per-user session not activated - call activate_user_session
UserSessionNotActivated,
/// CoreBridge/V2 auth missing - V2 commands won't work
CoreBridgeAuthMissing,
/// Runtime is in degraded state
RuntimeDegraded(DegradedReason),
/// Invalid user ID (e.g., 0)
InvalidUserId,
/// Bootstrap already in progress
BootstrapInProgress,
/// V2 CoreBridge authentication failed
V2AuthFailed(String),
/// V2 CoreBridge not initialized
V2NotInitialized,
/// Manual offline mode is on and the requested track is not available
/// in any local cache. Emitted instead of silently streaming from the
/// network (see issue #279).
TrackNotAvailableOffline,
/// Internal error
Internal(String),
}
impl std::fmt::Display for RuntimeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::RuntimeNotInitialized => write!(f, "Runtime not initialized"),
Self::AuthRequired => write!(f, "Authentication required"),
Self::UserSessionNotActivated => write!(f, "User session not activated"),
Self::CoreBridgeAuthMissing => write!(f, "CoreBridge authentication missing"),
Self::RuntimeDegraded(reason) => write!(f, "Runtime degraded: {:?}", reason),
Self::InvalidUserId => write!(f, "Invalid user ID"),
Self::BootstrapInProgress => write!(f, "Bootstrap already in progress"),
Self::V2AuthFailed(msg) => write!(f, "V2 authentication failed: {}", msg),
Self::V2NotInitialized => write!(f, "V2 CoreBridge not initialized"),
Self::TrackNotAvailableOffline => {
write!(f, "Track not available in offline cache while in offline mode")
}
Self::Internal(msg) => write!(f, "Internal error: {}", msg),
}
}
}
impl std::error::Error for RuntimeError {}
/// Command prerequisites - what each command requires
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandRequirement {
/// No requirements (public endpoints)
None,
/// Requires client to be initialized (bundle tokens)
RequiresClientInit,
/// Requires authentication (logged in)
RequiresAuth,
/// Requires per-user session to be activated
RequiresUserSession,
/// Requires CoreBridge/V2 auth (for V2 commands)
RequiresCoreBridgeAuth,
}
/// Runtime state manager - thread-safe, holds canonical state
pub struct RuntimeManager {
state: Arc<RwLock<RuntimeStatus>>,
bootstrap_in_progress: Arc<RwLock<bool>>,
/// Tracks which Mixtape/Collection the current queue was built from.
/// Set by v2_enqueue_collection (replace mode), cleared by any
/// non-Mixtape queue replacement (set_queue / clear_queue).
/// Append-style ops (add/add_next/bulk) preserve this value.
/// Persistence is in-memory only; session_queue_state table will
/// add the source_collection_id column when it lands.
queue_source_collection_id: RwLock<Option<String>>,
}
impl RuntimeManager {
pub fn new() -> Self {
Self {
state: Arc::new(RwLock::new(RuntimeStatus::default())),
bootstrap_in_progress: Arc::new(RwLock::new(false)),
queue_source_collection_id: RwLock::new(None),
}
}
/// Set (or clear) which collection the current queue was built from.
pub async fn set_queue_source_collection(&self, id: Option<String>) {
*self.queue_source_collection_id.write().await = id;
}
/// Return the collection ID that seeded the current queue, if any.
pub async fn get_queue_source_collection(&self) -> Option<String> {
self.queue_source_collection_id.read().await.clone()
}
/// Get current runtime status
pub async fn get_status(&self) -> RuntimeStatus {
self.state.read().await.clone()
}
/// Update runtime state
pub async fn set_state(&self, new_state: RuntimeState) {
let mut status = self.state.write().await;
status.state = new_state.clone();
// Update derived fields based on state
match &new_state {
RuntimeState::Uninitialized => {
status.client_initialized = false;
status.legacy_auth = false;
status.corebridge_auth = false;
status.session_activated = false;
status.user_id = None;
status.degraded_reason = None;
}
RuntimeState::InitializedNoAuth => {
status.client_initialized = true;
status.legacy_auth = false;
status.corebridge_auth = false;
status.session_activated = false;
status.user_id = None;
status.degraded_reason = None;
}
RuntimeState::AuthenticatedNoUserSession { user_id } => {
status.client_initialized = true;
status.legacy_auth = true;
status.session_activated = false;
status.user_id = Some(*user_id);
status.degraded_reason = None;
}
RuntimeState::Ready { user_id } => {
status.client_initialized = true;
status.legacy_auth = true;
status.corebridge_auth = true;
status.session_activated = true;
status.user_id = Some(*user_id);
status.degraded_reason = None;
}
RuntimeState::Degraded { reason } => {
status.degraded_reason = Some(reason.clone());
}
}
log::info!("[Runtime] State changed to: {:?}", new_state);
}
/// Mark client as initialized
pub async fn set_client_initialized(&self, initialized: bool) {
let mut status = self.state.write().await;
status.client_initialized = initialized;
if initialized && status.state == RuntimeState::Uninitialized {
status.state = RuntimeState::InitializedNoAuth;
}
}
/// Mark legacy auth status
pub async fn set_legacy_auth(&self, auth: bool, user_id: Option<u64>) {
let mut status = self.state.write().await;
status.legacy_auth = auth;
if auth {
if let Some(uid) = user_id {
status.user_id = Some(uid);
if !status.session_activated {
status.state = RuntimeState::AuthenticatedNoUserSession { user_id: uid };
}
}
} else {
status.user_id = None;
status.corebridge_auth = false;
status.session_activated = false;
status.state = RuntimeState::InitializedNoAuth;
}
}
/// Mark CoreBridge auth status
pub async fn set_corebridge_auth(&self, auth: bool) {
let mut status = self.state.write().await;
status.corebridge_auth = auth;
}
/// Mark session as activated
pub async fn set_session_activated(&self, activated: bool, user_id: u64) {
let mut status = self.state.write().await;
status.session_activated = activated;
if activated && status.legacy_auth && status.corebridge_auth {
status.state = RuntimeState::Ready { user_id };
status.user_id = Some(user_id);
}
}
/// Check if bootstrap is in progress
pub async fn is_bootstrap_in_progress(&self) -> bool {
*self.bootstrap_in_progress.read().await
}
/// Set bootstrap in progress flag
pub async fn set_bootstrap_in_progress(&self, in_progress: bool) {
*self.bootstrap_in_progress.write().await = in_progress;
}
/// Validate command requirements against current state
pub async fn check_requirements(&self, req: CommandRequirement) -> Result<(), RuntimeError> {
let status = self.state.read().await;
match req {
CommandRequirement::None => Ok(()),
CommandRequirement::RequiresClientInit => {
if !status.client_initialized {
Err(RuntimeError::RuntimeNotInitialized)
} else {
Ok(())
}
}
CommandRequirement::RequiresAuth => {
if !status.client_initialized {
Err(RuntimeError::RuntimeNotInitialized)
} else if !status.legacy_auth {
Err(RuntimeError::AuthRequired)
} else {
Ok(())
}
}
CommandRequirement::RequiresUserSession => {
// RequiresUserSession only checks session_activated, not legacy_auth.
// This allows both:
// - Online sessions: session_activated=true via activate_session()
// - Offline sessions: session_activated=true via activate_offline_session()
//
// Commands needing Qobuz API should use RequiresAuth or RequiresCoreBridgeAuth.
if !status.session_activated {
Err(RuntimeError::UserSessionNotActivated)
} else {
Ok(())
}
}
CommandRequirement::RequiresCoreBridgeAuth => {
if !status.client_initialized {
Err(RuntimeError::RuntimeNotInitialized)
} else if !status.legacy_auth {
Err(RuntimeError::AuthRequired)
} else if !status.session_activated {
Err(RuntimeError::UserSessionNotActivated)
} else if !status.corebridge_auth {
Err(RuntimeError::CoreBridgeAuthMissing)
} else {
Ok(())
}
}
}
}
/// Check if in degraded state
pub async fn is_degraded(&self) -> bool {
matches!(self.state.read().await.state, RuntimeState::Degraded { .. })
}
/// Set degraded state
pub async fn set_degraded(&self, reason: DegradedReason) {
self.set_state(RuntimeState::Degraded { reason }).await;
}
}
impl Default for RuntimeManager {
fn default() -> Self {
Self::new()
}
}
/// Tauri state wrapper
pub struct RuntimeManagerState(pub Arc<RuntimeManager>);
@@ -374,52 +24,3 @@ impl Default for RuntimeManagerState {
Self::new()
}
}
// ==================== Lifecycle Events ====================
/// Events emitted during runtime lifecycle
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "event", content = "data")]
pub enum RuntimeEvent {
/// Runtime initialized (client ready)
RuntimeInitialized,
/// Authentication state changed
AuthChanged {
logged_in: bool,
user_id: Option<u64>,
},
/// Per-user session activated
UserSessionActivated { user_id: u64 },
/// Per-user session deactivated
UserSessionDeactivated,
/// CoreBridge auth failed
CoreBridgeAuthFailed { error: String },
/// Runtime entered degraded state
RuntimeDegraded { reason: DegradedReason },
/// Runtime fully ready
RuntimeReady { user_id: u64 },
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_mixtape_context_set_clear() {
let mgr = RuntimeManager::new();
// Initially None
assert_eq!(mgr.get_queue_source_collection().await, None);
// Set Some
mgr.set_queue_source_collection(Some("col-abc".to_string())).await;
assert_eq!(
mgr.get_queue_source_collection().await,
Some("col-abc".to_string())
);
// Clear to None
mgr.set_queue_source_collection(None).await;
assert_eq!(mgr.get_queue_source_collection().await, None);
}
}
+39 -5
View File
@@ -31,6 +31,12 @@ pub struct PersistedQueueTrack {
pub source: Option<String>,
#[serde(default)]
pub parental_warning: bool,
/// Opaque id of the Mixtape/Collection item that produced this track.
/// Persisted so a Mixtape-sourced queue restored from disk keeps the
/// item association the live queue would have (album_id is the safe
/// fallback for non-Mixtape paths; None reads fine downstream).
#[serde(default)]
pub source_item_id_hint: Option<String>,
}
/// Represents the full persisted session state
@@ -195,6 +201,30 @@ impl SessionStore {
);
}
// streamable / parental_warning / source_item_id_hint were hardcoded
// to defaults on load (streamable=true, parental_warning=false,
// source_item_id_hint=None) because the columns didn't exist.
// Persisting them round-trips the live QueueTrack faithfully so
// a restored queue matches what the user had open before close.
let has_streamable: bool = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('queue_tracks') WHERE name = 'streamable'",
[],
|row| row.get(0),
)
.unwrap_or(0)
> 0;
if !has_streamable {
let _ = conn.execute_batch(
"
ALTER TABLE queue_tracks ADD COLUMN streamable INTEGER NOT NULL DEFAULT 1;
ALTER TABLE queue_tracks ADD COLUMN parental_warning INTEGER NOT NULL DEFAULT 0;
ALTER TABLE queue_tracks ADD COLUMN source_item_id_hint TEXT;
",
);
}
// Add last_view, view_context_id, view_context_type columns to player_state
let has_last_view: bool = conn
.query_row(
@@ -239,8 +269,8 @@ impl SessionStore {
// Insert queue tracks
for (pos, track) in session.queue_tracks.iter().enumerate() {
if let Err(e) = self.conn.execute(
"INSERT INTO queue_tracks (position, track_id, title, artist, album, duration_secs, artwork_url, hires, bit_depth, sample_rate, is_local, album_id, artist_id, source)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
"INSERT INTO queue_tracks (position, track_id, title, artist, album, duration_secs, artwork_url, hires, bit_depth, sample_rate, is_local, album_id, artist_id, source, streamable, parental_warning, source_item_id_hint)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
params![
pos as i64,
track.id as i64,
@@ -256,6 +286,9 @@ impl SessionStore {
track.album_id,
track.artist_id.map(|v| v as i64),
track.source,
track.streamable as i64,
track.parental_warning as i64,
track.source_item_id_hint,
],
) {
let _ = self.conn.execute("ROLLBACK", []);
@@ -327,7 +360,7 @@ impl SessionStore {
// Load queue tracks
let mut stmt = self.conn
.prepare("SELECT track_id, title, artist, album, duration_secs, artwork_url, hires, bit_depth, sample_rate, is_local, album_id, artist_id, source FROM queue_tracks ORDER BY position")
.prepare("SELECT track_id, title, artist, album, duration_secs, artwork_url, hires, bit_depth, sample_rate, is_local, album_id, artist_id, source, streamable, parental_warning, source_item_id_hint FROM queue_tracks ORDER BY position")
.map_err(|e| format!("Failed to prepare queue query: {}", e))?;
let tracks: Vec<PersistedQueueTrack> = stmt
@@ -345,9 +378,10 @@ impl SessionStore {
is_local: row.get::<_, i64>(9).unwrap_or(0) != 0,
album_id: row.get(10)?,
artist_id: row.get::<_, Option<i64>>(11)?.map(|v| v as u64),
streamable: true, // Default to true for persisted tracks
source: row.get(12)?,
parental_warning: false, // Not persisted in DB
streamable: row.get::<_, i64>(13).unwrap_or(1) != 0,
parental_warning: row.get::<_, i64>(14).unwrap_or(0) != 0,
source_item_id_hint: row.get(15)?,
})
})
.map_err(|e| format!("Failed to query queue tracks: {}", e))?
+4 -130
View File
@@ -1,132 +1,6 @@
//! Per-user data path management
//! Tauri adapter for per-user data path management.
//!
//! Each Qobuz user gets their own subdirectory under the app's data/cache paths.
//! This module provides the central path provider that all per-user state modules
//! use to determine where to store their databases.
//! The portable path provider lives in `qbz-app`; this module keeps the existing
//! Tauri import surface unchanged.
use std::path::PathBuf;
use std::sync::RwLock;
/// Central path provider for per-user data isolation.
///
/// Holds the current user_id and provides methods to get
/// user-scoped data and cache directories.
pub struct UserDataPaths {
user_id: RwLock<Option<u64>>,
}
impl UserDataPaths {
pub fn new() -> Self {
Self {
user_id: RwLock::new(None),
}
}
/// Set the current user after login
pub fn set_user(&self, user_id: u64) {
*self
.user_id
.write()
.expect("UserDataPaths write lock poisoned") = Some(user_id);
log::info!("UserDataPaths: active user set");
}
/// Clear the current user on logout
pub fn clear_user(&self) {
*self
.user_id
.write()
.expect("UserDataPaths write lock poisoned") = None;
log::info!("UserDataPaths: active user cleared");
}
/// Get the current user ID, if set
pub fn current_user_id(&self) -> Option<u64> {
*self
.user_id
.read()
.expect("UserDataPaths read lock poisoned")
}
/// Get the user-scoped data directory: ~/.local/share/qbz/users/{uid}/
pub fn user_data_dir(&self) -> Result<PathBuf, String> {
let uid = self
.user_id
.read()
.map_err(|e| format!("UserDataPaths read lock error: {}", e))?
.ok_or("No active user - please log in")?;
let base = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz")
.join("users")
.join(uid.to_string());
Ok(base)
}
/// Get the user-scoped cache directory: ~/.cache/qbz/users/{uid}/
pub fn user_cache_dir(&self) -> Result<PathBuf, String> {
let uid = self
.user_id
.read()
.map_err(|e| format!("UserDataPaths read lock error: {}", e))?
.ok_or("No active user - please log in")?;
let base = dirs::cache_dir()
.ok_or("Could not determine cache directory")?
.join("qbz")
.join("users")
.join(uid.to_string());
Ok(base)
}
/// Get the global (non-user-scoped) data directory: ~/.local/share/qbz/
pub fn global_data_dir() -> Result<PathBuf, String> {
dirs::data_dir()
.ok_or_else(|| "Could not determine data directory".to_string())
.map(|d| d.join("qbz"))
}
/// Get the global (non-user-scoped) cache directory: ~/.cache/qbz/
pub fn global_cache_dir() -> Result<PathBuf, String> {
dirs::cache_dir()
.ok_or_else(|| "Could not determine cache directory".to_string())
.map(|d| d.join("qbz"))
}
/// Save the last active user_id to a flat-path file so the session
/// can be restored on next app launch (when "remember me" is active).
pub fn save_last_user_id(user_id: u64) -> Result<(), String> {
let path = Self::last_user_id_path()?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create global data directory: {}", e))?;
}
std::fs::write(&path, user_id.to_string())
.map_err(|e| format!("Failed to save last user id: {}", e))?;
log::info!("Saved last_user_id marker");
Ok(())
}
/// Read the last active user_id (returns None if file doesn't exist or is invalid).
pub fn load_last_user_id() -> Option<u64> {
let path = Self::last_user_id_path().ok()?;
let contents = std::fs::read_to_string(&path).ok()?;
contents.trim().parse::<u64>().ok()
}
/// Clear the last user_id file (called on explicit logout).
pub fn clear_last_user_id() {
if let Ok(path) = Self::last_user_id_path() {
let _ = std::fs::remove_file(&path);
log::info!("Cleared last_user_id file");
}
}
fn last_user_id_path() -> Result<PathBuf, String> {
let dir = Self::global_data_dir()?;
Ok(dir.join("last_user_id"))
}
}
pub use qbz_app::user_data::UserDataPaths;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "QBZ",
"version": "1.2.12",
"version": "1.2.13",
"identifier": "com.blitzfc.qbz",
"build": {
"beforeDevCommand": "npm run dev",
+34
View File
@@ -1547,6 +1547,40 @@ body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden;
/* App-wide non-selection. Music app text (titles, artists, headers) is
not meant to be selected — and selectable text breaks window drag
regions, since native text selection preempts the drag gesture.
Exceptions below opt specific elements back into selectability. */
user-select: none;
-webkit-user-select: none;
}
/* Text-input controls always get native selection back. */
input,
textarea,
[contenteditable]:not([contenteditable='false']) {
user-select: text;
-webkit-user-select: text;
}
/* Semantic literal-text elements stay selectable everywhere. Covers the
HiFi Wizard command/path blocks, recovery commands, environment-variable
hints, log viewers, keyboard-shortcut chips, and any other place where
users need to copy text verbatim from settings, diagnostics, or modals. */
pre,
code,
kbd,
samp {
user-select: text;
-webkit-user-select: text;
}
/* Escape hatch: add class="selectable" to any element whose text users
should be able to copy (error messages, tokens, release notes, etc.). */
.selectable,
.selectable * {
user-select: text;
-webkit-user-select: text;
}
/* Scrollbar Styling */
+12 -4
View File
@@ -9,14 +9,22 @@
*/
import { getCachedImageUrl, getResolvedIfCached } from '$lib/services/imageCacheService';
import { isHardwareAccelEnabled } from '$lib/runtime/graphicsState';
export function cachedSrc(node: HTMLImageElement, url: string | undefined) {
let currentUrl = url;
// Force own compositing layer to prevent WebKitGTK 2.50+ texture
// eviction during repaints (causes placeholder flash on hover/scroll)
node.style.willChange = 'transform';
node.style.transform = 'translateZ(0)';
// WebKitGTK 2.50+ evicts <img> textures from its compositor cache during
// rapid repaints (hover, scroll) which manifests as a flash of the
// placeholder background. Forcing the image into its own compositor
// layer prevents the eviction. We only apply this under hardware
// acceleration: under software compositing each "layer" is just an
// extra CPU framebuffer, which makes the cure worse than the disease
// for big card grids (confirmed 2026-05-12 perf session). See ADR-004.
if (isHardwareAccelEnabled()) {
node.style.willChange = 'transform';
node.style.transform = 'translateZ(0)';
}
// Sync cache hit? Set the src immediately, no opacity dance. This is
// the common path when preloadImages has already primed the map
+65 -3
View File
@@ -16,6 +16,8 @@ import { getNextZoomLevel } from '$lib/utils/zoom';
import { getUserItem } from '$lib/utils/userStorage';
import { getZoom, setZoom } from '$lib/stores/zoomStore';
import { restoreAutoThemeVars } from '$lib/stores/autoThemeStore';
import { bootstrapGraphicsState, isHardwareAccelEnabled } from '$lib/runtime/graphicsState';
import { applyCpuModeDefaultsIfNeeded } from '$lib/stores/immersivePanelsStore';
// ============ Theme Management ============
@@ -75,10 +77,59 @@ function handleZoomWheel(event: WheelEvent): void {
void applyZoomLevel(nextZoom);
}
/**
* Zoom-on-Ctrl-wheel handling.
*
* Previously this registered a non-passive, capture-phase `wheel` listener
* on `window` for the lifetime of the app. The non-passive flag (required
* so `preventDefault` actually stops the WebView's native Ctrl+wheel zoom)
* also tells the browser "wait for my handler before applying scroll" —
* which kills the native fast-path for *every* wheel event in the app,
* not just zoom-modified ones. The user perceived this as choppy mouse-wheel
* scrolling vs perfectly-smooth scrollbar-drag scrolling.
*
* New approach: the wheel listener only exists while Ctrl/Meta is held.
* Keydown registers it, keyup (or window blur) unregisters it. Net:
* - 99% of normal scrolling: no wheel listener at all, browser fast-path
* - During zoom (Ctrl held): non-passive listener active, preventDefault
* still blocks native zoom and our `applyZoomLevel` runs
*/
const ZOOM_WHEEL_OPTIONS: AddEventListenerOptions = { passive: false, capture: true };
let zoomWheelActive = false;
function registerZoomWheel(): void {
if (zoomWheelActive) return;
zoomWheelActive = true;
window.addEventListener('wheel', handleZoomWheel, ZOOM_WHEEL_OPTIONS);
}
function unregisterZoomWheel(): void {
if (!zoomWheelActive) return;
zoomWheelActive = false;
window.removeEventListener('wheel', handleZoomWheel, ZOOM_WHEEL_OPTIONS);
}
function handleZoomKeyDown(e: KeyboardEvent): void {
if (e.ctrlKey || e.metaKey) registerZoomWheel();
}
function handleZoomKeyUp(e: KeyboardEvent): void {
// After any keyup, if neither modifier is held anymore, drop the listener.
if (!e.ctrlKey && !e.metaKey) unregisterZoomWheel();
}
export function setupZoomControls(): () => void {
const options: AddEventListenerOptions = { passive: false, capture: true };
window.addEventListener('wheel', handleZoomWheel, options);
return () => window.removeEventListener('wheel', handleZoomWheel, options);
window.addEventListener('keydown', handleZoomKeyDown);
window.addEventListener('keyup', handleZoomKeyUp);
// Releases the listener if the user alt-tabs with Ctrl held — otherwise
// the keyup never fires and we stay in the slow path.
window.addEventListener('blur', unregisterZoomWheel);
return () => {
window.removeEventListener('keydown', handleZoomKeyDown);
window.removeEventListener('keyup', handleZoomKeyUp);
window.removeEventListener('blur', unregisterZoomWheel);
unregisterZoomWheel();
};
}
// ============ Last.fm Session ============
@@ -146,6 +197,17 @@ export function bootstrapApp(): BootstrapResult {
loadToastsPreference();
loadSystemNotificationsPreference();
// Prime the runtime graphics-state cache so hot paths like `cachedSrc`
// can read it sync. Fire-and-forget; falls back to "HW accel ON"
// defaults if it errors out (see ADR-004). Once the cache is loaded,
// re-apply CPU-mode defaults to the immersive panels store — the
// store's module-load init may have run before this resolved and
// defaulted to GPU mode, which would leave heavy panels enabled by
// mistake on first launch for CPU-bound users.
void bootstrapGraphicsState().then(() => {
applyCpuModeDefaultsIfNeeded(!isHardwareAccelEnabled());
});
// Setup mouse navigation
const cleanupMouse = setupMouseNavigation();
const cleanupZoom = setupZoomControls();
+2 -2
View File
@@ -49,7 +49,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="modal-backdrop" onclick={onClose} role="presentation">
<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
<div class="modal" onclick={(e) => e.stopPropagation()}>
<div class="modal selectable" onclick={(e) => e.stopPropagation()}>
<!-- Header -->
<div class="modal-header">
<div class="app-branding">
@@ -99,7 +99,7 @@
<span class="label">{$t('about.buildInfo.version')}</span>
<span class="value">{appVersion}</span>
<span class="label">{$t('about.buildInfo.codename')}</span>
<span class="value codename">Exclusive Hardening</span>
<span class="value codename">400+ stars</span>
<span class="label">{$t('about.buildInfo.license')}</span>
<span class="value">MIT</span>
<span class="label">{$t('about.buildInfo.platform')}</span>
-814
View File
@@ -1,814 +0,0 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { Play, Disc3, Heart, Check } from 'lucide-svelte';
import { t, locale } from 'svelte-i18n';
import AlbumMenu from './AlbumMenu.svelte';
import { openAddToMixtape } from '$lib/stores/addToMixtapeModalStore';
import {
subscribe as subscribeAlbumFavorites,
isAlbumFavorite,
isAlbumToggling,
loadAlbumFavorites,
toggleAlbumFavorite
} from '$lib/stores/albumFavoritesStore';
import { resolveAlbumCover } from '$lib/stores/customAlbumCoverStore';
import { cachedSrc } from '$lib/actions/cachedImage';
import SourceBadge from './SourceBadge.svelte';
interface Props {
albumId?: string;
artwork: string;
title: string;
artist: string;
genre?: string;
releaseDate?: string;
quality?: string;
size?: 'standard' | 'large';
searchId?: string;
onclick?: () => void;
onPlay?: () => void;
onPlayNext?: () => void;
onPlayLater?: () => void;
onAddAlbumToPlaylist?: () => void;
onShareQobuz?: () => void;
onShareSonglink?: () => void;
onDownload?: () => void;
showFavorite?: boolean;
favoriteEnabled?: boolean;
showGenre?: boolean;
isAlbumFullyDownloaded?: boolean;
onOpenContainingFolder?: () => void;
onReDownloadAlbum?: () => void;
downloadStateVersion?: number;
/** Source badge for local library: 'user' | 'qobuz_download' | 'qobuz_purchase' | 'plex' */
sourceBadge?: 'user' | 'qobuz_download' | 'qobuz_purchase' | 'plex';
artistId?: number;
onArtistClick?: (artistId: number) => void;
/** Editorial ribbon: Qobuz award (id 88 = Qobuzissime,
* id 151 = Album of the Week) or a press accolade
* (Pitchfork BNM, Rolling Stone 5★, etc.). `label` is the
* display string the card renders. */
ribbon?: { kind: 'qobuzissime' | 'albumOfTheWeek' | 'press'; label: string };
/** Whether this album is from the local library (determines source field in AddToMixtapeItem) */
isLocal?: boolean;
/** Year for AddToMixtapeItem (optional, parsed from releaseDate if not provided) */
year?: number;
/** Track count for AddToMixtapeItem */
trackCount?: number;
/** Multi-select mode: shows a persistent corner checkbox and routes
* clicks through onToggleSelect instead of onclick. */
selectable?: boolean;
selected?: boolean;
onToggleSelect?: (e: MouseEvent | KeyboardEvent) => void;
}
let {
albumId,
artwork,
title,
artist,
genre,
releaseDate,
quality,
size = 'standard',
searchId,
onclick,
onPlay,
onPlayNext,
onPlayLater,
onAddAlbumToPlaylist,
onShareQobuz,
onShareSonglink,
onDownload,
showFavorite,
favoriteEnabled,
showGenre = true,
isAlbumFullyDownloaded = false,
onOpenContainingFolder,
onReDownloadAlbum,
downloadStateVersion,
sourceBadge,
artistId,
onArtistClick,
ribbon,
isLocal,
year,
trackCount,
selectable = false,
selected = false,
onToggleSelect
}: Props = $props();
const isDownloaded = $derived.by(() => {
void downloadStateVersion;
return isAlbumFullyDownloaded;
});
let imageError = $state(false);
const cardSize = $derived(size === 'large' ? 210 : 190);
let titleRef: HTMLDivElement | null = $state(null);
let titleTextRef: HTMLSpanElement | null = $state(null);
let titleOverflow = $state(0);
let artistRef: HTMLElement | null = $state(null);
let artistTextRef: HTMLSpanElement | null = $state(null);
let artistOverflow = $state(0);
const titleOffset = $derived(titleOverflow > 0 ? `-${titleOverflow + 16}px` : '0px');
const artistOffset = $derived(artistOverflow > 0 ? `-${artistOverflow + 16}px` : '0px');
const tickerSpeed = 40;
const titleDuration = $derived(titleOverflow > 0 ? `${(titleOverflow + 16) / tickerSpeed}s` : '0s');
const artistDuration = $derived(artistOverflow > 0 ? `${(artistOverflow + 16) / tickerSpeed}s` : '0s');
let favoriteFromStore = $state(false);
let isToggling = $state(false);
const isFavorite = $derived(albumId ? favoriteFromStore : false);
const hasMenu = $derived(!!(onPlayNext || onPlayLater || onShareQobuz || onShareSonglink || onDownload || albumId));
const showFavoriteButton = $derived(showFavorite ?? !!albumId);
const favoriteAvailable = $derived(favoriteEnabled ?? !!albumId);
const hasOverlay = $derived(!!(showFavoriteButton || onPlay || hasMenu));
let menuOpen = $state(false);
let contextMenuOpen = $state(false);
let contextMenuPos = $state<{ x: number; y: number } | null>(null);
function handleImageError() {
imageError = true;
}
function formatReleaseDate(dateStr: string | undefined): string {
if (!dateStr) return '';
const date = new Date(dateStr);
if (isNaN(date.getTime())) return '';
return date.toLocaleDateString($locale ? $locale : 'en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
const formattedDate = $derived(formatReleaseDate(releaseDate));
async function handleToggleFavorite(event: MouseEvent) {
event.stopPropagation();
if (!albumId || !favoriteAvailable) return;
await toggleAlbumFavorite(albumId);
}
function handlePlay(event: MouseEvent) {
event.stopPropagation();
onPlay?.();
}
function handleArtistClick(event: MouseEvent) {
event.stopPropagation();
if (artistId && onArtistClick) {
onArtistClick(artistId);
}
}
const artistClickable = $derived(!!(artistId && onArtistClick));
function handleAddToMixtape() {
if (!albumId) return;
const source = isLocal || !!sourceBadge ? 'local' : 'qobuz';
const parsedYear = year ?? (releaseDate ? new Date(releaseDate).getFullYear() : undefined);
openAddToMixtape({
item_type: 'album',
source,
source_item_id: albumId,
title,
subtitle: artist,
artwork_url: artwork,
year: parsedYear && !isNaN(parsedYear) ? parsedYear : undefined,
track_count: trackCount,
});
}
function isOverlayAction(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
return !!target.closest('.action-buttons') || !!target.closest('.artist-link');
}
function handleCardClick(event: MouseEvent) {
if (isOverlayAction(event.target)) return;
// Multi-select mode: click toggles selection instead of opening the card.
if (selectable) {
onToggleSelect?.(event);
return;
}
onclick?.();
}
function updateOverflow() {
if (titleRef && titleTextRef) {
const overflow = titleTextRef.scrollWidth - titleRef.clientWidth;
titleOverflow = overflow > 0 ? overflow : 0;
}
if (artistRef && artistTextRef) {
const overflow = artistTextRef.scrollWidth - artistRef.clientWidth;
artistOverflow = overflow > 0 ? overflow : 0;
}
}
// Track if overflow has been measured (only measure on first hover for performance)
let overflowMeasured = false;
function measureOverflowOnce() {
if (!overflowMeasured) {
updateOverflow();
overflowMeasured = true;
}
}
onMount(() => {
// Don't create ResizeObserver per-card for performance in large libraries
// Overflow is measured on first hover instead
if (albumId) {
void loadAlbumFavorites();
favoriteFromStore = isAlbumFavorite(albumId);
isToggling = isAlbumToggling(albumId);
const unsubscribe = subscribeAlbumFavorites(() => {
favoriteFromStore = isAlbumFavorite(albumId);
isToggling = isAlbumToggling(albumId);
});
return () => unsubscribe();
}
});
// Reset measurement when title/artist changes
$effect(() => {
title;
artist;
overflowMeasured = false;
titleOverflow = 0;
artistOverflow = 0;
});
</script>
<div
class="album-card"
class:is-selectable={selectable}
class:is-selected={selectable && selected}
style="width: {cardSize}px"
data-search-id={searchId}
onclick={handleCardClick}
oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); menuOpen = true; contextMenuOpen = true; contextMenuPos = { x: e.clientX, y: e.clientY }; }}
onmouseenter={measureOverflowOnce}
onfocus={measureOverflowOnce}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (selectable) onToggleSelect?.(e);
else onclick?.();
}
}}
>
<!-- Artwork Container -->
<div
class="artwork-container"
style="width: {cardSize}px; height: {cardSize}px"
>
<!-- Placeholder always rendered as background layer (visible while image loads) -->
<div class="artwork-placeholder">
<Disc3 size={48} />
</div>
<!-- Image overlays placeholder when loaded -->
{#if !imageError && artwork}
<img class="artwork-image" use:cachedSrc={albumId ? resolveAlbumCover(albumId, artwork) : artwork} alt={title} loading="lazy" decoding="async" onerror={handleImageError} />
{/if}
{#if selectable}
<div class="select-checkbox" aria-hidden="true">
{#if selected}<Check size={14} />{/if}
</div>
{/if}
<!-- Action Overlay -->
{#if hasOverlay}
<div class="action-overlay" class:menu-open={menuOpen}>
{#if showGenre && (genre || formattedDate)}
<div class="overlay-info">
{#if genre}<span class="overlay-genre">{genre}</span>{/if}
{#if formattedDate}
<span class="overlay-date">{formattedDate}</span>
{/if}
</div>
{/if}
<div class="action-buttons">
{#if showFavoriteButton}
<button
class="overlay-btn overlay-btn--minor"
class:is-active={isFavorite}
class:is-toggling={isToggling}
class:disabled={!favoriteAvailable}
type="button"
aria-disabled={!favoriteAvailable || isToggling}
disabled={!favoriteAvailable || isToggling}
onclick={handleToggleFavorite}
title={isFavorite ? $t('actions.removeFromFavorites') : $t('actions.addToFavorites')}
>
<Heart size={18} fill={isFavorite ? 'white' : 'none'} color="white" />
</button>
{:else}
<!-- Spacer to keep Play button centered -->
<div class="overlay-btn--spacer"></div>
{/if}
{#if onPlay}
<button class="overlay-btn" type="button" onclick={handlePlay} title={$t('actions.play')}>
<Play size={18} fill="white" color="white" />
</button>
{/if}
{#if hasMenu}
<div class="overlay-menu">
<AlbumMenu
onPlayNext={onPlayNext}
onPlayLater={onPlayLater}
onAddToPlaylist={onAddAlbumToPlaylist}
onAddToMixtape={albumId ? handleAddToMixtape : undefined}
onShareQobuz={onShareQobuz}
onShareSonglink={onShareSonglink}
onDownload={onDownload}
isAlbumFullyDownloaded={isDownloaded}
onOpenContainingFolder={onOpenContainingFolder}
onReDownloadAlbum={onReDownloadAlbum}
externalOpen={contextMenuOpen}
contextMenuPosition={contextMenuPos}
onOpenChange={(open) => { menuOpen = open; if (!open) { contextMenuOpen = false; contextMenuPos = null; } }}
/>
</div>
{/if}
</div>
</div>
{/if}
<!-- Editorial ribbon (Album of the Week / Qobuzissime / Press).
Only the last award in album.awards is shown on the card; the
full stack lives in AlbumView's sidebar. -->
{#if ribbon}
<div class="editorial-ribbon"
class:ribbon-aotw={ribbon.kind === 'albumOfTheWeek'}
class:ribbon-qbz={ribbon.kind === 'qobuzissime'}
class:ribbon-press={ribbon.kind === 'press'}
title={ribbon.label}>
{ribbon.label}
</div>
{/if}
<!-- Source Badge (Local Library only) -->
{#if sourceBadge}
<div class="source-badge-slot">
<SourceBadge value={sourceBadge} />
</div>
{/if}
</div>
<!-- Text Info -->
<div class="info">
<div
class="title"
class:scrollable={titleOverflow > 0}
style="--ticker-offset: {titleOffset}; --ticker-duration: {titleDuration};"
bind:this={titleRef}
>
<span class="title-text" bind:this={titleTextRef}>{title}</span>
</div>
{#if artistClickable}
<button
class="artist artist-link"
class:scrollable={artistOverflow > 0}
style="--ticker-offset: {artistOffset}; --ticker-duration: {artistDuration};"
bind:this={artistRef}
onclick={handleArtistClick}
type="button"
>
<span class="artist-text" bind:this={artistTextRef}>{artist}</span>
</button>
{:else}
<div
class="artist"
class:scrollable={artistOverflow > 0}
style="--ticker-offset: {artistOffset}; --ticker-duration: {artistDuration};"
bind:this={artistRef}
>
<span class="artist-text" bind:this={artistTextRef}>{artist}</span>
</div>
{/if}
{#if quality}
<div class="quality-badge">{quality}</div>
{/if}
</div>
</div>
<style>
.album-card {
flex-shrink: 0;
cursor: pointer;
transition: transform 150ms ease;
}
.artwork-container {
position: relative;
margin-bottom: 8px;
border-radius: 8px;
overflow: hidden;
}
/* Multi-select corner checkbox (top-left of the artwork). Always rendered
when selectable=true; filled accent when selected, outlined-only when not. */
.select-checkbox {
position: absolute;
top: 8px;
left: 8px;
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.85);
background: rgba(0, 0, 0, 0.45);
color: #fff;
pointer-events: none;
backdrop-filter: blur(2px);
transition: background 120ms ease, border-color 120ms ease;
}
.album-card.is-selected .select-checkbox {
background: var(--accent-primary);
border-color: var(--accent-primary);
}
.album-card.is-selected {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
border-radius: 10px;
}
/* Disable the drag-cursor feel when in select-mode — tap = toggle. */
.album-card.is-selectable {
cursor: pointer;
}
.artwork-container .artwork-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
z-index: 1;
transition: opacity 0.15s ease-in;
}
.artwork-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%);
color: var(--text-muted);
border-radius: inherit;
}
.quality-badge {
display: inline-block;
margin-top: 4px;
font-family: 'LINE Seed JP', var(--font-sans);
font-size: 10px;
font-weight: 100;
color: var(--alpha-85);
background: var(--alpha-10);
border: 1px solid var(--alpha-15);
border-radius: 4px;
padding: 4px 6px;
min-width: 90px;
text-align: center;
box-sizing: border-box;
}
:global([data-theme="light"]) .quality-badge {
color: rgba(40, 42, 54, 0.85) !important;
background: #ffffff !important;
border: 1px solid rgba(40, 42, 54, 0.95) !important;
}
/* Source badge for Local Library albums */
/* Editorial ribbon — sits at bottom-left of the artwork. z-index
below the action-overlay (z-index 2) so it hides on hover, as
requested. */
.editorial-ribbon {
position: absolute;
bottom: 8px;
left: 0;
z-index: 1;
padding: 3px 10px 3px 8px;
font-family: var(--font-sans);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #fff;
background: rgba(0, 0, 0, 0.75);
border-left: 3px solid var(--accent-primary);
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
pointer-events: none;
white-space: nowrap;
max-width: calc(100% - 12px);
overflow: hidden;
text-overflow: ellipsis;
}
.editorial-ribbon.ribbon-aotw {
border-left-color: #eab308;
}
.editorial-ribbon.ribbon-qbz {
border-left-color: #8b5cf6;
}
/* Press accolades get a solid gold ribbon with dark, readable text —
distinct from the Qobuz-branded variants (which stay dark with a
coloured left border). */
.editorial-ribbon.ribbon-press {
background: linear-gradient(135deg, #f5c042 0%, #d49511 100%);
color: #1f1407;
border-left: none;
padding-left: 10px;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.15);
}
.source-badge-slot {
position: absolute;
bottom: 6px;
right: 6px;
z-index: 3;
}
.action-overlay {
position: absolute;
inset: -1px;
display: flex;
align-items: flex-start;
justify-content: center;
opacity: 0;
transition: opacity 150ms ease;
background: rgba(10, 10, 10, 0.75);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
pointer-events: auto;
border-radius: inherit;
z-index: 2;
}
.album-card:hover .action-overlay,
.action-overlay:focus-within,
.action-overlay.menu-open {
opacity: 1;
}
.action-buttons {
display: flex;
align-items: center;
gap: 12px;
pointer-events: auto;
position: absolute;
left: 50%;
top: 75%;
transform: translate(-50%, -50%);
opacity: 0;
}
.album-card:hover .action-buttons,
.action-overlay:focus-within .action-buttons,
.action-overlay.menu-open .action-buttons {
animation: slide-in-down 0.4s ease-out forwards;
}
@keyframes slide-in-down {
0% {
opacity: 0;
transform: translate(-50%, calc(-50% - 12px));
}
100% {
opacity: 1;
transform: translate(-50%, -50%);
}
}
.overlay-btn {
width: 38px;
height: 38px;
border-radius: 50%;
border: none;
background: transparent;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
/* Use box-shadow instead of border for smoother anti-aliasing */
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.85), 0 0 1px rgba(0, 0, 0, 0.3);
transition: transform 150ms ease, background-color 150ms ease, box-shadow 150ms ease;
}
.overlay-btn:hover {
background-color: rgba(0, 0, 0, 0.3);
box-shadow: inset 0 0 0 1px var(--accent-primary), 0 0 4px rgba(0, 0, 0, 0.5);
}
.overlay-btn.is-active {
background-color: rgba(255, 255, 255, 0.2);
}
.overlay-btn.disabled,
.overlay-btn:disabled {
opacity: 0.5;
cursor: default;
transform: none;
}
.overlay-btn.is-toggling {
cursor: wait;
animation: favorite-pulse 0.8s ease-in-out infinite;
}
@keyframes favorite-pulse {
0%, 100% {
opacity: 0.5;
}
50% {
opacity: 1;
}
}
.overlay-menu {
display: flex;
align-items: center;
justify-content: center;
}
.overlay-btn--minor {
width: 30px;
height: 30px;
}
.overlay-btn--spacer {
width: 30px;
height: 30px;
visibility: hidden;
}
.overlay-info {
align-self: flex-start;
width: 100%;
text-align: left;
padding: 14px 14px;
display: flex;
flex-direction: column;
gap: 4px;
opacity: 0;
transform: translateY(12px);
}
.album-card:hover .overlay-info,
.action-overlay:focus-within .overlay-info,
.action-overlay.menu-open .overlay-info {
animation: slide-in-up 0.4s ease-out forwards;
}
@keyframes slide-in-up {
0% {
opacity: 0;
transform: translateY(12px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.overlay-genre {
font-size: 14px;
font-weight: 600;
color: white;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
word-wrap: break-word;
overflow-wrap: break-word;
}
.overlay-date {
font-size: 12px;
font-weight: 400;
color: rgba(255, 255, 255, 0.85);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
}
:global(.album-card .album-menu) {
display: flex;
align-items: center;
}
:global(.album-card .album-menu .menu-trigger) {
width: 30px;
height: 30px;
border-radius: 50%;
border: none;
background: transparent;
color: white;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
/* Use box-shadow instead of border for smoother anti-aliasing */
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.85), 0 0 1px rgba(0, 0, 0, 0.3);
transition: background-color 150ms ease, box-shadow 150ms ease;
}
:global(.album-card .album-menu .menu-trigger:hover) {
background-color: rgba(0, 0, 0, 0.3);
box-shadow: inset 0 0 0 1px var(--accent-primary), 0 0 4px rgba(0, 0, 0, 0.5);
}
.info {
width: 100%;
}
.title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 2px;
}
.title.scrollable {
text-overflow: clip;
}
.title-text {
display: inline-block;
white-space: nowrap;
}
.album-card:hover .title.scrollable .title-text {
animation: title-ticker var(--ticker-duration) linear infinite;
will-change: transform;
}
@keyframes title-ticker {
0%, 20% { transform: translateX(0); }
70%, 80% { transform: translateX(var(--ticker-offset)); }
90%, 100% { transform: translateX(0); }
}
.artist {
font-size: 13px;
font-weight: 400;
color: var(--text-muted);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.artist.artist-link {
display: block;
appearance: none;
-webkit-appearance: none;
background: none;
border: none;
padding: 0;
margin: 0;
font: inherit;
text-align: left;
width: 100%;
cursor: pointer;
color: var(--text-muted);
}
.artist.artist-link:hover {
color: var(--text-primary);
}
.artist.scrollable {
text-overflow: clip;
}
.artist-text {
display: inline-block;
white-space: nowrap;
}
.album-card:hover .artist.scrollable .artist-text,
.artist.artist-link.scrollable:hover .artist-text {
animation: title-ticker var(--ticker-duration) linear infinite;
will-change: transform;
}
</style>
+1 -1
View File
@@ -173,7 +173,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={onClose} role="presentation">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="credits-modal" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<div class="credits-modal selectable" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()}>
{#if loading}
<div class="loading-state">
<LoaderCircle size={32} class="spinner" />
+1 -1
View File
@@ -305,7 +305,7 @@
</div>
<!-- Content -->
<div class="booklet-content" bind:this={containerEl}>
<div class="booklet-content selectable" bind:this={containerEl}>
{#if isLoading}
<div class="booklet-loading">
<div class="spinner"></div>
@@ -0,0 +1,101 @@
<script lang="ts">
import { t } from '$lib/i18n';
import ModalLite from '$lib/discovery-v2/ModalLite.svelte';
// First-time warning shown to users running without hardware acceleration
// when they enter immersive mode. Five known-heavy panels are already
// disabled by default in CPU mode; this modal tells the user why so they
// know where to look if they want to override or fix their graphics
// configuration.
//
// Triggered by the parent (+page.svelte) when immersive opens, HW accel
// is off, and the user hasn't dismissed the warning before.
interface Props {
isOpen: boolean;
onClose: (dontShowAgain: boolean) => void;
}
let { isOpen, onClose }: Props = $props();
let dontShowAgain = $state(true);
function handleAcknowledge() {
onClose(dontShowAgain);
}
function handleClose() {
onClose(dontShowAgain);
}
</script>
<ModalLite
{isOpen}
onClose={handleClose}
title={$t('settings.appearance.immersivePanels.warningModal.title')}
maxWidth="520px"
footerAlign="end"
>
<p class="warning-body">{$t('settings.appearance.immersivePanels.warningModal.body')}</p>
<p class="warning-hint">{$t('settings.appearance.immersivePanels.warningModal.hint')}</p>
<label class="dont-show-row">
<input type="checkbox" bind:checked={dontShowAgain} />
<span>{$t('settings.appearance.immersivePanels.warningModal.dontShowAgain')}</span>
</label>
{#snippet footer()}
<button class="btn-primary" onclick={handleAcknowledge}>
{$t('settings.appearance.immersivePanels.warningModal.acknowledge')}
</button>
{/snippet}
</ModalLite>
<style>
.warning-body {
margin: 0 0 12px;
color: var(--text-primary);
line-height: 1.5;
}
.warning-hint {
margin: 0 0 16px;
padding: 10px 12px;
background: var(--alpha-05, rgba(255, 255, 255, 0.04));
border-left: 3px solid var(--warning, #f59e0b);
border-radius: 4px;
color: var(--text-secondary, var(--text-muted));
font-size: 13px;
line-height: 1.4;
}
.dont-show-row {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: var(--text-secondary, var(--text-muted));
font-size: 13px;
}
.dont-show-row input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent-primary, #7c3aed);
cursor: pointer;
}
.btn-primary {
padding: 8px 16px;
background: var(--accent-primary, #7c3aed);
color: var(--btn-primary-text, white);
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
}
.btn-primary:hover {
filter: brightness(1.1);
}
</style>
-1
View File
@@ -490,7 +490,6 @@
letter-spacing: 0.5px;
color: var(--text-muted);
pointer-events: none;
user-select: none;
}
.option {
+5 -1
View File
@@ -40,6 +40,8 @@
runtimeIsVm: boolean;
runtimeHwAccelEnabled: boolean;
runtimeForceX11Active: boolean;
runtimeGpuName: string;
runtimeDesktopEnvironment: string;
devForceDmabuf: boolean;
envWebkitDisableDmabuf: string | null;
envWebkitDisableCompositing: string | null;
@@ -306,9 +308,11 @@
{ label: 'GDK Scale', saved: str(diag.gfxGdkScale), runtime: '—', status: 'info' },
{ label: 'GDK DPI Scale', saved: str(diag.gfxGdkDpiScale), runtime: '—', status: 'info' },
{ label: 'Compositing Mode', saved: '—', runtime: compositing, status: 'info' },
{ label: 'GPU', saved: '—', runtime: diag.runtimeGpuName || 'Unknown', status: 'info' },
{ label: 'GPU: NVIDIA', saved: '—', runtime: diag.runtimeHasNvidia ? 'Detected' : 'No', status: 'info' },
{ label: 'GPU: Intel', saved: '—', runtime: diag.runtimeHasIntel ? 'Detected' : 'No', status: 'info' },
{ label: 'GPU: AMD', saved: '—', runtime: diag.runtimeHasAmd ? 'Detected' : 'No', status: 'info' },
{ label: 'Desktop Environment', saved: '—', runtime: diag.runtimeDesktopEnvironment || 'Unknown', status: 'info' },
{ label: 'Wayland', saved: '—', runtime: diag.runtimeIsWayland ? 'Yes' : 'No (X11)', status: 'info' },
{ label: 'VM', saved: '—', runtime: diag.runtimeIsVm ? 'Yes' : 'No', status: 'info' },
{ label: 'Using Fallback', saved: '—', runtime: bool(diag.runtimeUsingFallback), status: diag.runtimeUsingFallback ? 'mismatch' : 'info' },
@@ -461,7 +465,7 @@
}
</script>
<div class="diagnostics-panel">
<div class="diagnostics-panel selectable">
<button class="section-toggle panel-toggle" onclick={togglePanel}>
{#if panelOpen}<ChevronDown size={14} />{:else}<ChevronRight size={14} />{/if}
<h4 class="subsection-title" style="margin:0">{$t('settings.developer.diagnostics.title')}</h4>
@@ -243,7 +243,6 @@
.playlist-card {
width: 140px;
cursor: pointer;
user-select: none;
}
.artwork-container {
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import Modal from './Modal.svelte';
import Modal from '$lib/discovery-v2/ModalLite.svelte';
import { GripVertical } from 'lucide-svelte';
import { t } from '$lib/i18n';
import type { FavoritesPreferences } from '../types';
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { open } from '@tauri-apps/plugin-dialog';
import Modal from './Modal.svelte';
import Modal from '$lib/discovery-v2/ModalLite.svelte';
import { Heart, Star, Music, Folder, Disc, Library, Headphones, Upload, Eye, EyeOff } from 'lucide-svelte';
import { t } from '$lib/i18n';
import type { PlaylistFolder } from '$lib/stores/playlistFoldersStore';
+2 -2
View File
@@ -22,11 +22,11 @@
context?: GenreFilterContext;
variant?: ButtonVariant;
align?: DropdownAlign;
isOpen?: boolean;
}
let { onFilterChange, context = 'home', variant = 'default', align = 'left' }: Props = $props();
let { onFilterChange, context = 'home', variant = 'default', align = 'left', isOpen = $bindable(false) }: Props = $props();
let isOpen = $state(false);
let buttonEl = $state<HTMLButtonElement | null>(null);
let hasFilter = $state(false);
let selectedGenreName = $state<string | null>(null);
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { ChevronUp, ChevronDown, RotateCcw } from 'lucide-svelte';
import Modal from './Modal.svelte';
import Modal from '$lib/discovery-v2/ModalLite.svelte';
import {
getSettings,
subscribe,
@@ -189,12 +189,6 @@
overflow-x: auto;
overflow-y: hidden;
cursor: grab;
/* Never select text inside carousels: drag-to-scroll would otherwise
highlight titles/artists in the first few pixels before the drag
threshold kicks in. Text is still selectable in the dedicated
album/artist/playlist views. */
user-select: none;
-webkit-user-select: none;
}
.scroll-container.dragging {
@@ -90,7 +90,7 @@
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="modal"
class="modal selectable"
role="dialog"
aria-modal="true"
aria-labelledby="shortcuts-modal-title"
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import Modal from './Modal.svelte';
import Modal from '$lib/discovery-v2/ModalLite.svelte';
import { GripVertical, Eye, EyeOff } from 'lucide-svelte';
import { t } from '$lib/i18n';
import type { LibraryPreferences } from '../types';
+1 -1
View File
@@ -2,7 +2,7 @@
import { invoke } from '@tauri-apps/api/core';
import { t } from '$lib/i18n';
import { Link2 } from 'lucide-svelte';
import Modal from './Modal.svelte';
import Modal from '$lib/discovery-v2/ModalLite.svelte';
interface ResolvedLink {
type: 'OpenAlbum' | 'OpenTrack' | 'OpenArtist' | 'OpenPlaylist';
@@ -18,6 +18,7 @@
import TrackRow from './TrackRow.svelte';
import ImageLightbox from './ImageLightbox.svelte';
import { formatTrackTitle } from '$lib/utils/trackTitle';
import { applyShiftRange, isSelectAllShortcut } from '$lib/utils/multiSelect';
interface LocalTrack {
id: number;
@@ -118,6 +119,20 @@
getFullArtworkUrl: (path?: string) => string;
buildAlbumSections: (tracks: LocalTrack[]) => AlbumSection[];
normalizeArtistName: (name: string) => string;
/**
* One of the parent's selection contexts (tracks tab, albums tab,
* folder tree) is currently active. When this flips to `true` while
* this view has its own local selection mode on, the local
* selection is cleared — light-switch mutex across the parent/child
* boundary so two selections can never be live at the same time.
*/
parentSelectionActive?: boolean;
/**
* Called when the user toggles ON this view's local selection mode.
* Parent uses it to clear its own selection contexts so the
* footer's BulkActionBar reflects a single source of truth.
*/
onSelectionEntered?: () => void;
}
let {
@@ -147,6 +162,8 @@
getFullArtworkUrl,
buildAlbumSections,
normalizeArtistName,
parentSelectionActive = false,
onSelectionEntered,
}: Props = $props();
let trackSearchQuery = $state('');
@@ -191,20 +208,102 @@
function toggleSelectionMode() {
selectionMode = !selectionMode;
if (!selectionMode) selectedTrackIds = new Set();
if (!selectionMode) {
selectedTrackIds = new Set();
selectionAnchorId = null;
} else {
// Notify parent so it can clear its own selection contexts —
// light-switch mutex with the parent (tracks-tab / albums-tab /
// folder-tree). The parent's `clearOtherSelectionContexts`
// handles the cross-context teardown.
onSelectionEntered?.();
}
}
function toggleTrackSelection(trackId: number) {
// Inbound half of the cross-component mutex: when the parent enters
// any of its own selection contexts, our local selection turns off.
// No infinite loop because the toggle above only fires
// `onSelectionEntered` on the ENTRY transition (false -> true).
$effect(() => {
if (parentSelectionActive && selectionMode) {
selectionMode = false;
selectedTrackIds = new Set();
selectionAnchorId = null;
}
});
// Anchor for shift-click range selection. Set by every plain
// (non-shift) click and consumed by the next shift-click. Reset
// whenever selection mode is exited so a future re-entry starts
// from a fresh anchor.
let selectionAnchorId: number | null = null;
/**
* Toggle a single track in the selection, with optional shift-range
* extension. When `shift` is true and an anchor exists, every track
* between the anchor and `trackId` (inclusive, in current visual
* order) is added to the selection — matches the tracks-tab behaviour
* in `LocalLibraryView`. Without shift, this is a plain toggle and
* the clicked track becomes the new anchor.
*/
function toggleTrackSelection(trackId: number, shift: boolean = false) {
if (shift && selectionAnchorId !== null && selectionAnchorId !== trackId) {
const ids = tracks.map((trk) => trk.id);
const lastIndex = ids.indexOf(selectionAnchorId);
const currentIndex = ids.indexOf(trackId);
if (lastIndex !== -1 && currentIndex !== -1) {
selectedTrackIds = applyShiftRange({
current: selectedTrackIds,
ids,
lastIndex,
currentIndex,
});
return;
}
}
const next = new Set(selectedTrackIds);
if (next.has(trackId)) next.delete(trackId);
else next.add(trackId);
selectedTrackIds = next;
selectionAnchorId = trackId;
}
function clearSelection() {
selectedTrackIds = new Set();
selectionAnchorId = null;
}
// Tri-state for the "Select All" checkbox at the top of the track
// list — 'none' / 'partial' / 'all' based on how many of the
// currently visible album tracks are in the selection set.
const selectAllState = $derived(
tracks.length === 0 ? 'none' as const
: selectedTrackIds.size === 0 ? 'none' as const
: tracks.every((trk) => selectedTrackIds.has(trk.id)) ? 'all' as const
: 'partial' as const
);
function toggleSelectAll() {
if (selectAllState === 'all') {
selectedTrackIds = new Set();
} else {
selectedTrackIds = new Set(tracks.map((trk) => trk.id));
}
}
// Ctrl/Cmd+A while selection mode is active selects every visible
// track. No-op when focus is in a text input.
$effect(() => {
if (!selectionMode) return;
const handler = (e: KeyboardEvent) => {
if (!isSelectAllShortcut(e)) return;
e.preventDefault();
selectedTrackIds = new Set(tracks.map((trk) => trk.id));
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
});
/** Resolve the selected IDs back to the full LocalTrack objects so we
* can split local vs Plex rows for the playlist add path. */
function selectedTracks(): LocalTrack[] {
@@ -381,6 +480,17 @@
>
<SquareCheckBig size={18} />
</button>
{#if selectionMode}
<label class="folder-album-select-all" title={$t('actions.selectAll')}>
<input
type="checkbox"
checked={selectAllState === 'all'}
indeterminate={selectAllState === 'partial'}
onchange={toggleSelectAll}
/>
<span>{$t('actions.selectAll')}</span>
</label>
{/if}
<button
type="button"
class="action-btn-circle"
@@ -504,7 +614,7 @@
hideFavorite={true}
selectable={selectionMode}
selected={selectedTrackIds.has(track.id)}
onToggleSelect={() => toggleTrackSelection(track.id)}
onToggleSelect={(e) => toggleTrackSelection(track.id, e.shiftKey)}
onArtistClick={track.artist && track.artist !== album.artist && onArtistClick
? () => onArtistClick?.(track.artist)
: undefined}
@@ -912,4 +1022,26 @@
opacity: 0.5;
cursor: not-allowed;
}
/* Select-all checkbox shown next to the selection-mode toggle in the
folder album header. Matches the visual rhythm of the other inline
toolbar items — small, sits beside the round action buttons rather
than competing with them. */
.folder-album-select-all {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 8px;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
user-select: none;
}
.folder-album-select-all input[type='checkbox'] {
width: 14px;
height: 14px;
accent-color: var(--accent-primary);
cursor: pointer;
}
</style>
@@ -326,7 +326,6 @@
min-height: 22px;
cursor: pointer;
border-radius: 3px;
user-select: none;
color: var(--text-primary);
transition: background 100ms ease;
/* Extend to natural content width when names are long; fill the
@@ -1,5 +1,5 @@
<script lang="ts">
import Modal from './Modal.svelte';
import Modal from '$lib/discovery-v2/ModalLite.svelte';
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { ask } from '@tauri-apps/plugin-dialog';
Loaded 100 of 219 files, more files were not shown because too many files have changed in this diff. Show more