feat(snap): add snap sandbox detection and welcome modal wiring

Detect when QBZ runs inside a Snap sandbox (via SNAP env var) and show
a one-time welcome modal with required plug connection commands. Adds a
Snap Sandbox section in Settings with copyable commands, mirroring the
existing Flatpak pattern.

Backend: snap.rs module, DB table snap_welcome_shown, V2 commands.
Frontend: updatesService, +page.svelte modal queue, SettingsView section.
This commit is contained in:
vicrodh committed 2026-03-02 11:10:50 -06:00
1 parent 8529477778
commit 00675de6b8
11 files changed
+182 -15

No files matched your search

+12
View File
@@ -10449,6 +10449,18 @@ pub fn v2_is_running_in_flatpak() -> bool {
crate::flatpak::is_running_in_flatpak() crate::flatpak::is_running_in_flatpak()
} }
#[tauri::command]
pub fn v2_is_running_in_snap() -> bool {
crate::snap::is_running_in_snap()
}
#[tauri::command]
pub fn v2_mark_snap_welcome_shown(
state: State<'_, crate::updates::UpdatesState>,
) -> Result<(), String> {
crate::updates::mark_snap_welcome_shown(state)
}
#[tauri::command] #[tauri::command]
pub async fn v2_detect_legacy_cached_files( pub async fn v2_detect_legacy_cached_files(
cache_state: State<'_, OfflineCacheState>, cache_state: State<'_, OfflineCacheState>,
+4
View File
@@ -26,6 +26,7 @@ pub mod config;
pub mod credentials; pub mod credentials;
pub mod discogs; pub mod discogs;
pub mod flatpak; pub mod flatpak;
pub mod snap;
pub mod lastfm; pub mod lastfm;
pub mod library; pub mod library;
pub mod listenbrainz; pub mod listenbrainz;
@@ -1183,6 +1184,7 @@ pub fn run() {
flatpak::get_flatpak_help_text, flatpak::get_flatpak_help_text,
config::legal_settings::get_qobuz_tos_accepted, config::legal_settings::get_qobuz_tos_accepted,
updates::has_flatpak_welcome_been_shown, updates::has_flatpak_welcome_been_shown,
updates::has_snap_welcome_been_shown,
lyrics::commands::lyrics_get, lyrics::commands::lyrics_get,
config::favorites_cache::is_track_favorite, config::favorites_cache::is_track_favorite,
commands_v2::v2_set_api_locale, commands_v2::v2_set_api_locale,
@@ -1356,6 +1358,8 @@ pub fn run() {
commands_v2::v2_remote_control_regenerate_token, commands_v2::v2_remote_control_regenerate_token,
commands_v2::v2_remote_control_get_pairing_qr, commands_v2::v2_remote_control_get_pairing_qr,
commands_v2::v2_is_running_in_flatpak, commands_v2::v2_is_running_in_flatpak,
commands_v2::v2_is_running_in_snap,
commands_v2::v2_mark_snap_welcome_shown,
commands_v2::v2_detect_legacy_cached_files, commands_v2::v2_detect_legacy_cached_files,
commands_v2::v2_reco_log_event, commands_v2::v2_reco_log_event,
commands_v2::v2_reco_train_scores, commands_v2::v2_reco_train_scores,
+16
View File
@@ -0,0 +1,16 @@
//! Snap runtime detection and utilities
//!
//! Detects when QBZ is running inside a Snap sandbox
//! and provides sandbox-specific guidance.
use std::env;
/// Check if QBZ is running inside a Snap sandbox
pub fn is_snap() -> bool {
env::var("SNAP").is_ok()
}
#[tauri::command]
pub fn is_running_in_snap() -> bool {
is_snap()
}
+35
View File
@@ -143,6 +143,11 @@ impl UpdatesStore {
CREATE TABLE IF NOT EXISTS flatpak_welcome_shown ( CREATE TABLE IF NOT EXISTS flatpak_welcome_shown (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),
shown_at INTEGER NOT NULL shown_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS snap_welcome_shown (
id INTEGER PRIMARY KEY CHECK (id = 1),
shown_at INTEGER NOT NULL
);", );",
) )
.map_err(|e| format!("Failed to create updates tables: {}", e))?; .map_err(|e| format!("Failed to create updates tables: {}", e))?;
@@ -273,6 +278,26 @@ impl UpdatesStore {
.map_err(|e| format!("Failed to mark flatpak_welcome_shown: {}", e))?; .map_err(|e| format!("Failed to mark flatpak_welcome_shown: {}", e))?;
Ok(()) Ok(())
} }
pub fn has_snap_welcome_been_shown(&self) -> bool {
self.conn
.query_row(
"SELECT 1 FROM snap_welcome_shown WHERE id = 1 LIMIT 1",
[],
|_row| Ok(()),
)
.is_ok()
}
pub fn mark_snap_welcome_shown(&self) -> Result<(), String> {
self.conn
.execute(
"INSERT OR REPLACE INTO snap_welcome_shown (id, shown_at) VALUES (1, ?1)",
params![now_epoch_seconds()],
)
.map_err(|e| format!("Failed to mark snap_welcome_shown: {}", e))?;
Ok(())
}
} }
/// Thread-safe state wrapper /// Thread-safe state wrapper
@@ -543,6 +568,16 @@ pub fn mark_flatpak_welcome_shown(state: tauri::State<UpdatesState>) -> Result<(
state.with_store(|store| store.mark_flatpak_welcome_shown())? state.with_store(|store| store.mark_flatpak_welcome_shown())?
} }
#[tauri::command]
pub fn has_snap_welcome_been_shown(state: tauri::State<UpdatesState>) -> Result<bool, String> {
state.with_store(|store| store.has_snap_welcome_been_shown())
}
#[tauri::command]
pub fn mark_snap_welcome_shown(state: tauri::State<UpdatesState>) -> Result<(), String> {
state.with_store(|store| store.mark_snap_welcome_shown())?
}
#[tauri::command] #[tauri::command]
pub fn get_current_version(state: tauri::State<UpdatesState>) -> String { pub fn get_current_version(state: tauri::State<UpdatesState>) -> String {
state.current_version() state.current_version()
+55 -9
View File
@@ -279,6 +279,9 @@
// Flatpak detection state // Flatpak detection state
let isFlatpak = $state(false); let isFlatpak = $state(false);
let flatpakHelpText = $state(''); let flatpakHelpText = $state('');
// Snap detection state
let isSnap = $state(false);
let isCheckingNetwork = $state(false); let isCheckingNetwork = $state(false);
// Updates state // Updates state
@@ -383,13 +386,13 @@
{ id: 'developer', labelKey: 'settings.developer.title' }, { id: 'developer', labelKey: 'settings.developer.title' },
]; ];
// Navigation section definitions (dynamic: includes Flatpak only when running in Flatpak) // Navigation section definitions (dynamic: includes sandbox sections when detected)
// NOTE: If adding new sections, add them BEFORE Flatpak. Flatpak must always be last. const navSectionDefs = $derived.by(() => {
const navSectionDefs = $derived( const sections = [...navSectionIds];
isFlatpak if (isFlatpak) sections.push({ id: 'flatpak', labelKey: 'nav.flatpak' });
? [...navSectionIds, { id: 'flatpak', labelKey: 'nav.flatpak' }] if (isSnap) sections.push({ id: 'snap', labelKey: 'nav.snap' });
: navSectionIds return sections;
); });
async function handleUpdateCheckOnLaunchToggle(enabled: boolean): Promise<void> { async function handleUpdateCheckOnLaunchToggle(enabled: boolean): Promise<void> {
@@ -1382,8 +1385,9 @@
updatesCurrentVersion = getUpdatesCurrentVersion(); updatesCurrentVersion = getUpdatesCurrentVersion();
}); });
// Detect if running in Flatpak // Detect sandbox environments
loadFlatpakStatus(); loadFlatpakStatus();
loadSnapStatus();
// Check for legacy cached files // Check for legacy cached files
checkLegacyCachedFiles(); checkLegacyCachedFiles();
@@ -2539,6 +2543,14 @@
} }
} }
async function loadSnapStatus() {
try {
isSnap = await invoke<boolean>('v2_is_running_in_snap');
} catch (err) {
console.error('Failed to check Snap status:', err);
}
}
async function loadAudioSettings() { async function loadAudioSettings() {
try { try {
const settings = await invoke<AudioSettings>('v2_get_audio_settings'); const settings = await invoke<AudioSettings>('v2_get_audio_settings');
@@ -5395,8 +5407,42 @@
<LogsModal isOpen={showLogsModal} onClose={() => showLogsModal = false} /> <LogsModal isOpen={showLogsModal} onClose={() => showLogsModal = false} />
<!-- Snap Section (only shown when running in Snap) -->
{#if activeSection === 'snap' && isSnap}
<section class="section snap-section" id="snap">
<h3 class="section-title">Snap Sandbox</h3>
<div class="flatpak-info">
<p class="flatpak-intro">
QBZ is running inside a Snap sandbox. Some audio interfaces need to be connected manually for the best experience.
</p>
<div class="flatpak-guide">
<h4>Required Plug Connections</h4>
<p>Run these commands to enable full audio support:</p>
<div class="copyable-command">
<pre class="code-block">sudo snap connect qbz-player:alsa
sudo snap connect qbz-player:pulseaudio
sudo snap connect qbz-player:pipewire
sudo snap connect qbz-player:mpris</pre>
<button class="copy-btn" onclick={() => copyCommand('snap-required', 'sudo snap connect qbz-player:alsa\nsudo snap connect qbz-player:pulseaudio\nsudo snap connect qbz-player:pipewire\nsudo snap connect qbz-player:mpris')}>
{copiedCommands['snap-required'] ? 'Copied!' : 'Copy'}
</button>
</div>
<h4>Optional (External Drives / NAS)</h4>
<div class="copyable-command">
<pre class="code-block">sudo snap connect qbz-player:removable-media</pre>
<button class="copy-btn" onclick={() => copyCommand('snap-optional', 'sudo snap connect qbz-player:removable-media')}>
{copiedCommands['snap-optional'] ? 'Copied!' : 'Copy'}
</button>
</div>
<p class="flatpak-note">
<strong>Note:</strong> These settings persist across reboots and updates. You only need to run them once, then restart QBZ.
</p>
</div>
</div>
</section>
{/if}
<!-- Flatpak Section (only shown when running in Flatpak) --> <!-- Flatpak Section (only shown when running in Flatpak) -->
<!-- NOTE: Keep this section LAST. If adding new settings sections, add them BEFORE this one. -->
{#if activeSection === 'flatpak' && isFlatpak} {#if activeSection === 'flatpak' && isFlatpak}
<section class="section flatpak-section" id="flatpak"> <section class="section flatpak-section" id="flatpak">
<h3 class="section-title">Flatpak Sandbox</h3> <h3 class="section-title">Flatpak Sandbox</h3>
+1
View File
@@ -20,6 +20,7 @@
"backTo": "Zurück zu", "backTo": "Zurück zu",
"updates": "Updates", "updates": "Updates",
"flatpak": "Flatpak", "flatpak": "Flatpak",
"snap": "Snap",
"purchases": "Einkäufe" "purchases": "Einkäufe"
}, },
"home": { "home": {
+1
View File
@@ -20,6 +20,7 @@
"backTo": "Back to", "backTo": "Back to",
"updates": "Updates", "updates": "Updates",
"flatpak": "Flatpak", "flatpak": "Flatpak",
"snap": "Snap",
"purchases": "Purchases" "purchases": "Purchases"
}, },
"home": { "home": {
+1
View File
@@ -20,6 +20,7 @@
"backTo": "Volver a", "backTo": "Volver a",
"updates": "Actualizaciones", "updates": "Actualizaciones",
"flatpak": "Flatpak", "flatpak": "Flatpak",
"snap": "Snap",
"purchases": "Compras" "purchases": "Compras"
}, },
"home": { "home": {
+1
View File
@@ -20,6 +20,7 @@
"backTo": "Retour à", "backTo": "Retour à",
"updates": "Mises à jour", "updates": "Mises à jour",
"flatpak": "Flatpak", "flatpak": "Flatpak",
"snap": "Snap",
"purchases": "Achats" "purchases": "Achats"
}, },
"home": { "home": {
+34 -3
View File
@@ -144,6 +144,7 @@ function getDevFakeRelease(currentVersion: string): ReleaseInfo | null {
export interface LaunchUpdateDecision { export interface LaunchUpdateDecision {
currentVersion: string; currentVersion: string;
showFlatpakWelcome: boolean; showFlatpakWelcome: boolean;
showSnapWelcome: boolean;
whatsNewRelease: ReleaseInfo | null; whatsNewRelease: ReleaseInfo | null;
updateRelease: ReleaseInfo | null; updateRelease: ReleaseInfo | null;
} }
@@ -173,6 +174,31 @@ export async function markFlatpakWelcomeShown(): Promise<void> {
} }
} }
/**
* Check if running in Snap and if welcome modal has been shown.
*/
async function shouldShowSnapWelcome(): Promise<boolean> {
try {
const isSnap = await invoke<boolean>('v2_is_running_in_snap');
if (!isSnap) return false;
const alreadyShown = await invoke<boolean>('has_snap_welcome_been_shown');
return !alreadyShown;
} catch {
return false;
}
}
/**
* Mark Snap welcome modal as shown.
*/
export async function markSnapWelcomeShown(): Promise<void> {
try {
await invoke('v2_mark_snap_welcome_shown');
} catch (error) {
console.debug('[Updates] Failed to mark snap welcome shown:', error);
}
}
/** /**
* Decide which modals should be shown on launch. * Decide which modals should be shown on launch.
* *
@@ -186,6 +212,7 @@ export async function decideLaunchModals(): Promise<LaunchUpdateDecision> {
return { return {
currentVersion: getCurrentVersion(), currentVersion: getCurrentVersion(),
showFlatpakWelcome: false, showFlatpakWelcome: false,
showSnapWelcome: false,
whatsNewRelease: null, whatsNewRelease: null,
updateRelease: null, updateRelease: null,
}; };
@@ -203,17 +230,21 @@ export async function decideLaunchModals(): Promise<LaunchUpdateDecision> {
const currentVersion = getCurrentVersion(); const currentVersion = getCurrentVersion();
if (getSessionFlag(SESSION_CHECK_DONE_KEY)) { if (getSessionFlag(SESSION_CHECK_DONE_KEY)) {
return { currentVersion, showFlatpakWelcome: false, whatsNewRelease: null, updateRelease: null }; return { currentVersion, showFlatpakWelcome: false, showSnapWelcome: false, whatsNewRelease: null, updateRelease: null };
} }
setSessionFlag(SESSION_CHECK_DONE_KEY); setSessionFlag(SESSION_CHECK_DONE_KEY);
// Collect all modals that need to be shown // Collect all modals that need to be shown
let showFlatpakWelcome = false; let showFlatpakWelcome = false;
let showSnapWelcome = false;
let whatsNewRelease: ReleaseInfo | null = null; let whatsNewRelease: ReleaseInfo | null = null;
let updateRelease: ReleaseInfo | null = null; let updateRelease: ReleaseInfo | null = null;
// Phase 1: Flatpak welcome (highest priority, shown first) // Phase 1: Sandbox welcome (highest priority, shown first)
showFlatpakWelcome = await shouldShowFlatpakWelcome(); showFlatpakWelcome = await shouldShowFlatpakWelcome();
if (!showFlatpakWelcome) {
showSnapWelcome = await shouldShowSnapWelcome();
}
// Phase 2: What's New for current version // Phase 2: What's New for current version
if (prefs.showWhatsNewOnLaunch && currentVersion) { if (prefs.showWhatsNewOnLaunch && currentVersion) {
@@ -254,7 +285,7 @@ export async function decideLaunchModals(): Promise<LaunchUpdateDecision> {
} }
} }
return { currentVersion, showFlatpakWelcome, whatsNewRelease, updateRelease }; return { currentVersion, showFlatpakWelcome, showSnapWelcome, whatsNewRelease, updateRelease };
} }
export async function openReleasePageAndAcknowledge(release: ReleaseInfo): Promise<void> { export async function openReleasePageAndAcknowledge(release: ReleaseInfo): Promise<void> {
+22 -3
View File
@@ -375,6 +375,7 @@
import UpdateReminderModal from '$lib/components/updates/UpdateReminderModal.svelte'; import UpdateReminderModal from '$lib/components/updates/UpdateReminderModal.svelte';
import WhatsNewModal from '$lib/components/updates/WhatsNewModal.svelte'; import WhatsNewModal from '$lib/components/updates/WhatsNewModal.svelte';
import FlatpakWelcomeModal from '$lib/components/updates/FlatpakWelcomeModal.svelte'; import FlatpakWelcomeModal from '$lib/components/updates/FlatpakWelcomeModal.svelte';
import SnapWelcomeModal from '$lib/components/updates/SnapWelcomeModal.svelte';
import KeyboardShortcutsModal from '$lib/components/KeyboardShortcutsModal.svelte'; import KeyboardShortcutsModal from '$lib/components/KeyboardShortcutsModal.svelte';
import KeybindingsSettings from '$lib/components/KeybindingsSettings.svelte'; import KeybindingsSettings from '$lib/components/KeybindingsSettings.svelte';
import LinkResolverModal from '$lib/components/LinkResolverModal.svelte'; import LinkResolverModal from '$lib/components/LinkResolverModal.svelte';
@@ -386,6 +387,7 @@
disableUpdateChecks, disableUpdateChecks,
ignoreReleaseVersion, ignoreReleaseVersion,
markFlatpakWelcomeShown, markFlatpakWelcomeShown,
markSnapWelcomeShown,
openReleasePageAndAcknowledge, openReleasePageAndAcknowledge,
resetLaunchFlow, resetLaunchFlow,
} from '$lib/services/updatesService'; } from '$lib/services/updatesService';
@@ -437,6 +439,7 @@
let isReminderModalOpen = $state(false); let isReminderModalOpen = $state(false);
let isWhatsNewModalOpen = $state(false); let isWhatsNewModalOpen = $state(false);
let isFlatpakWelcomeOpen = $state(false); let isFlatpakWelcomeOpen = $state(false);
let isSnapWelcomeOpen = $state(false);
let updatesLaunchTriggered = $state(false); let updatesLaunchTriggered = $state(false);
let sessionReady = $state(false); let sessionReady = $state(false);
@@ -511,17 +514,21 @@
updatesCurrentVersion = decision.currentVersion; updatesCurrentVersion = decision.currentVersion;
// Store pending modals for sequential display // Store pending modals for sequential display
// Order: Flatpak → What's new → Update available // Order: Flatpak/Snap → What's new → Update available
pendingWhatsNewRelease = decision.whatsNewRelease; pendingWhatsNewRelease = decision.whatsNewRelease;
pendingUpdateRelease = decision.updateRelease; pendingUpdateRelease = decision.updateRelease;
// Show first modal in queue (Flatpak has highest priority) // Show first modal in queue (sandbox welcome has highest priority)
if (decision.showFlatpakWelcome) { if (decision.showFlatpakWelcome) {
isFlatpakWelcomeOpen = true; isFlatpakWelcomeOpen = true;
return; return;
} }
if (decision.showSnapWelcome) {
isSnapWelcomeOpen = true;
return;
}
// No Flatpak modal, try What's New // No sandbox modal, try What's New
showNextModalInQueue(); showNextModalInQueue();
} }
@@ -581,6 +588,13 @@
showNextModalInQueue(); showNextModalInQueue();
} }
function handleSnapWelcomeClose(): void {
isSnapWelcomeOpen = false;
void markSnapWelcomeShown();
// Show next modal in queue
showNextModalInQueue();
}
function handleWhatsNewClose(): void { function handleWhatsNewClose(): void {
isWhatsNewModalOpen = false; isWhatsNewModalOpen = false;
whatsNewRelease = null; whatsNewRelease = null;
@@ -4628,6 +4642,11 @@
onClose={handleFlatpakWelcomeClose} onClose={handleFlatpakWelcomeClose}
/> />
<SnapWelcomeModal
isOpen={isSnapWelcomeOpen}
onClose={handleSnapWelcomeClose}
/>
<!-- Track Info Modal --> <!-- Track Info Modal -->
<TrackInfoModal <TrackInfoModal
isOpen={isTrackInfoOpen} isOpen={isTrackInfoOpen}