Add Flatpak welcome modal and sequential modal priority system

- Add FlatpakWelcomeModal component shown once for Flatpak users
- Add persistence for Flatpak welcome shown state (updates.db)
- Implement sequential modal priority: Flatpak > What's new > Update available
- Add Flatpak section to Settings navigation (only when running in Flatpak)
- Clean up duplicate Flatpak section and invalid style block in SettingsView
- Add copyable command styling for Flatpak section
This commit is contained in:
vicrodh committed 2026-01-27 04:37:17 -06:00
1 parent 4ff21312b2
commit 561f7487d0
6 files changed
+353 -144

No files matched your search

+2
View File
@@ -771,6 +771,8 @@ pub fn run() {
updates::is_release_ignored,
updates::has_whats_new_been_shown,
updates::mark_whats_new_shown,
updates::has_flatpak_welcome_been_shown,
updates::mark_flatpak_welcome_shown,
updates::get_current_version,
updates::check_for_updates,
updates::fetch_release_for_version,
+35
View File
@@ -123,6 +123,11 @@ impl UpdatesStore {
CREATE TABLE IF NOT EXISTS whats_new_shown (
version TEXT PRIMARY KEY,
shown_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS flatpak_welcome_shown (
id INTEGER PRIMARY KEY CHECK (id = 1),
shown_at INTEGER NOT NULL
);",
)
.map_err(|e| format!("Failed to create updates tables: {}", e))?;
@@ -233,6 +238,26 @@ impl UpdatesStore {
.map_err(|e| format!("Failed to mark whats_new_shown: {}", e))?;
Ok(())
}
pub fn has_flatpak_welcome_been_shown(&self) -> bool {
self.conn
.query_row(
"SELECT 1 FROM flatpak_welcome_shown WHERE id = 1 LIMIT 1",
[],
|_row| Ok(()),
)
.is_ok()
}
pub fn mark_flatpak_welcome_shown(&self) -> Result<(), String> {
self.conn
.execute(
"INSERT OR REPLACE INTO flatpak_welcome_shown (id, shown_at) VALUES (1, ?1)",
params![now_epoch_seconds()],
)
.map_err(|e| format!("Failed to mark flatpak_welcome_shown: {}", e))?;
Ok(())
}
}
/// Thread-safe state wrapper
@@ -448,6 +473,16 @@ pub fn mark_whats_new_shown(version: String, state: tauri::State<UpdatesState>)
state.with_store(|store| store.mark_whats_new_shown(&version))
}
#[tauri::command]
pub fn has_flatpak_welcome_been_shown(state: tauri::State<UpdatesState>) -> bool {
state.with_store(|store| store.has_flatpak_welcome_been_shown())
}
#[tauri::command]
pub fn mark_flatpak_welcome_shown(state: tauri::State<UpdatesState>) -> Result<(), String> {
state.with_store(|store| store.mark_flatpak_welcome_shown())
}
#[tauri::command]
pub fn get_current_version(state: tauri::State<UpdatesState>) -> String {
state.current_version()
@@ -0,0 +1,116 @@
<script lang="ts">
import Modal from '../Modal.svelte';
import { Package } from 'lucide-svelte';
interface Props {
isOpen: boolean;
onClose: () => void;
}
let { isOpen, onClose }: Props = $props();
</script>
<div class="flatpak-welcome-modal">
<Modal
{isOpen}
onClose={onClose}
title="Running in Flatpak"
maxWidth="520px"
>
<div class="modal-content">
<div class="icon-container">
<Package size={48} strokeWidth={1.5} />
</div>
<p class="intro">
QBZ detected it's running inside a <strong>Flatpak sandbox</strong>.
There are some considerations to ensure the best experience.
</p>
<div class="info-box">
<h4>What you should know:</h4>
<ul>
<li>Access to external music folders (NAS, network mounts) requires manual permission grants</li>
<li>Some audio features may behave differently due to sandbox restrictions</li>
<li>The <strong>Settings</strong> page includes Flatpak-specific commands you can copy and run</li>
</ul>
</div>
<p class="note">
Check the <strong>Flatpak Sandbox</strong> section in Settings for detailed instructions and copy-paste commands.
</p>
</div>
{#snippet footer()}
<div class="footer-actions">
<button class="btn btn-primary" type="button" onclick={onClose}>
Got it
</button>
</div>
{/snippet}
</Modal>
</div>
<style>
.modal-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.icon-container {
display: flex;
justify-content: center;
color: var(--accent-primary, #4285f4);
margin-bottom: 8px;
}
.intro {
margin: 0;
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
text-align: center;
}
.info-box {
background: var(--bg-tertiary);
border-radius: 8px;
padding: 16px;
}
.info-box h4 {
margin: 0 0 12px 0;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.info-box ul {
margin: 0;
padding: 0 0 0 20px;
display: flex;
flex-direction: column;
gap: 8px;
}
.info-box li {
font-size: 12px;
line-height: 1.5;
color: var(--text-secondary);
}
.note {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--text-muted);
text-align: center;
}
.footer-actions {
display: flex;
width: 100%;
justify-content: flex-end;
}
</style>
+87 -98
View File
@@ -162,10 +162,11 @@
let integrationsSection: HTMLElement;
let updatesSection: HTMLElement;
let storageSection: HTMLElement;
let flatpakSection: HTMLElement;
let activeSection = $state('audio');
// Navigation section definitions (static, refs resolved at click/scroll time)
const navSectionDefs = [
// Base navigation sections (always present)
const baseNavSectionDefs = [
{ id: 'audio', label: 'Audio' },
{ id: 'playback', label: 'Playback' },
{ id: 'offline', label: 'Offline' },
@@ -177,6 +178,14 @@
{ id: 'storage', label: 'Storage' },
];
// Navigation section definitions (dynamic: includes Flatpak only when running in Flatpak)
// NOTE: If adding new sections, add them BEFORE Flatpak. Flatpak must always be last.
const navSectionDefs = $derived(
isFlatpak
? [...baseNavSectionDefs, { id: 'flatpak', label: 'Flatpak' }]
: baseNavSectionDefs
);
// Get section element by id (resolved at call time, not definition time)
function getSectionEl(id: string): HTMLElement | undefined {
switch (id) {
@@ -189,6 +198,7 @@
case 'integrations': return integrationsSection;
case 'updates': return updatesSection;
case 'storage': return storageSection;
case 'flatpak': return flatpakSection;
default: return undefined;
}
}
@@ -2081,96 +2091,6 @@
</div>
</section>
<!-- Flatpak Section (only shown when running in Flatpak) -->
{#if isFlatpak}
<section class="section flatpak-section">
<h3 class="section-title">Flatpak Sandbox</h3>
<div class="flatpak-info">
<p class="flatpak-intro">
QBZ is running inside a Flatpak sandbox. For offline libraries on NAS, network mounts, or external disks, direct filesystem access is required.
</p>
<div class="flatpak-guide">
<h4>Grant Filesystem Access</h4>
<p>Use <strong>Flatseal</strong> (GUI) or run this command for each folder you want to add:</p>
<div class="copyable-command">
<pre class="code-block">flatpak override --user --filesystem=/path/to/your/music com.blitzfc.qbz</pre>
<button class="copy-btn" onclick={() => copyCommand('fs-basic', 'flatpak override --user --filesystem=/path/to/your/music com.blitzfc.qbz')}>
{copiedCommands['fs-basic'] ? 'Copied!' : 'Copy'}
</button>
</div>
<h4>Examples</h4>
<div class="copyable-command">
<pre class="code-block"># CIFS / Samba mount
flatpak override --user --filesystem=/mnt/nas com.blitzfc.qbz
# SSHFS mount
flatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz
# Custom folder (edit as needed)
flatpak override --user --filesystem=/home/USUARIO/Música com.blitzfc.qbz</pre>
<button class="copy-btn" onclick={() => copyCommand('fs-examples', `# CIFS / Samba mount\nflatpak override --user --filesystem=/mnt/nas com.blitzfc.qbz\n\n# SSHFS mount\nflatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz\n\n# Custom folder (edit as needed)\nflatpak override --user --filesystem=/home/USUARIO/Música com.blitzfc.qbz`)}>
{copiedCommands['fs-examples'] ? 'Copied!' : 'Copy'}
</button>
</div>
<p class="flatpak-note">
<strong>Note:</strong> This setting is persistent and survives reboots and updates.<br />
<strong>Tip:</strong> You can repeat the command for as many folders as you need.
</p>
</div>
<div class="flatpak-guide" style="margin-top:2em;">
<h4>Chromecast &amp; DLNA Device Discovery</h4>
<p>
To detect Chromecast and DLNA devices on your network, you must grant network sharing permissions to the app:
</p>
<div class="copyable-command">
<pre class="code-block">flatpak override --user --share=network com.blitzfc.qbz</pre>
<button class="copy-btn" onclick={() => copyCommand('network', 'flatpak override --user --share=network com.blitzfc.qbz')}>
{copiedCommands['network'] ? 'Copied!' : 'Copy'}
</button>
</div>
<p class="flatpak-note">
<strong>Note:</strong> Without this, device discovery will not work.<br />
You only need to do this once.
</p>
</div>
</div>
</section>
{/if}
<style>
.copyable-command {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 8px;
}
.copyable-command .code-block {
margin: 0;
font-size: 13px;
background: var(--bg-tertiary);
border-radius: 6px;
padding: 8px 12px;
user-select: all;
min-width: 0;
flex: 1;
white-space: pre-wrap;
word-break: break-all;
}
.copy-btn {
background: var(--accent-primary);
color: white;
border: none;
border-radius: 6px;
padding: 6px 14px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.copy-btn:hover {
background: var(--accent-secondary);
}
</style>
<!-- Library Section -->
<section class="section" bind:this={librarySection}>
<h3 class="section-title">{$t('settings.library.title')}</h3>
@@ -2501,8 +2421,9 @@ flatpak override --user --filesystem=/home/USUARIO/Música com.blitzfc.qbz</pre>
</section>
<!-- Flatpak Section (only shown when running in Flatpak) -->
<!-- NOTE: Keep this section LAST. If adding new settings sections, add them BEFORE this one. -->
{#if isFlatpak}
<section class="section flatpak-section">
<section class="section flatpak-section" id="flatpak" bind:this={flatpakSection}>
<h3 class="section-title">Flatpak Sandbox</h3>
<div class="flatpak-info">
<p class="flatpak-intro">
@@ -2510,16 +2431,46 @@ flatpak override --user --filesystem=/home/USUARIO/Música com.blitzfc.qbz</pre>
</p>
<div class="flatpak-guide">
<h4>Grant Filesystem Access</h4>
<p>Use <strong>Flatseal</strong> (GUI) or run this command:</p>
<pre class="code-block">flatpak override --user --filesystem=/path/to/music com.blitzfc.qbz</pre>
<p>Use <strong>Flatseal</strong> (GUI) or run this command for each folder you want to add:</p>
<div class="copyable-command">
<pre class="code-block">flatpak override --user --filesystem=/path/to/your/music com.blitzfc.qbz</pre>
<button class="copy-btn" onclick={() => copyCommand('fs-basic', 'flatpak override --user --filesystem=/path/to/your/music com.blitzfc.qbz')}>
{copiedCommands['fs-basic'] ? 'Copied!' : 'Copy'}
</button>
</div>
<h4>Examples</h4>
<pre class="code-block"># CIFS / Samba mount
<div class="copyable-command">
<pre class="code-block"># CIFS / Samba mount
flatpak override --user --filesystem=/mnt/nas com.blitzfc.qbz
# SSHFS mount
flatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz</pre>
flatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz
# Custom folder (edit as needed)
flatpak override --user --filesystem=/home/USUARIO/Música com.blitzfc.qbz</pre>
<button class="copy-btn" onclick={() => copyCommand('fs-examples', `# CIFS / Samba mount\nflatpak override --user --filesystem=/mnt/nas com.blitzfc.qbz\n\n# SSHFS mount\nflatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz\n\n# Custom folder (edit as needed)\nflatpak override --user --filesystem=/home/USUARIO/Música com.blitzfc.qbz`)}>
{copiedCommands['fs-examples'] ? 'Copied!' : 'Copy'}
</button>
</div>
<p class="flatpak-note">
<strong>Note:</strong> This setting is persistent and survives reboots and updates.
<strong>Note:</strong> This setting is persistent and survives reboots and updates.<br />
<strong>Tip:</strong> You can repeat the command for as many folders as you need.
</p>
</div>
<div class="flatpak-guide" style="margin-top:2em;">
<h4>Chromecast &amp; DLNA Device Discovery</h4>
<p>
To detect Chromecast and DLNA devices on your network, you must grant network sharing permissions to the app:
</p>
<div class="copyable-command">
<pre class="code-block">flatpak override --user --share=network com.blitzfc.qbz</pre>
<button class="copy-btn" onclick={() => copyCommand('network', 'flatpak override --user --share=network com.blitzfc.qbz')}>
{copiedCommands['network'] ? 'Copied!' : 'Copy'}
</button>
</div>
<p class="flatpak-note">
<strong>Note:</strong> Without this, device discovery will not work.<br />
You only need to do this once.
</p>
</div>
</div>
@@ -3312,6 +3263,44 @@ flatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz</pre>
cursor: pointer;
border: none;
}
/* Flatpak copyable command styling */
.copyable-command {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 8px;
}
.copyable-command .code-block {
margin: 0;
font-size: 13px;
background: var(--bg-tertiary);
border-radius: 6px;
padding: 8px 12px;
user-select: all;
min-width: 0;
flex: 1;
white-space: pre-wrap;
word-break: break-all;
}
.copy-btn {
background: var(--accent-primary);
color: white;
border: none;
border-radius: 6px;
padding: 6px 14px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
flex-shrink: 0;
}
.copy-btn:hover {
background: var(--accent-secondary);
}
</style>
<MigrationModal
+67 -41
View File
@@ -1,3 +1,4 @@
import { invoke } from '@tauri-apps/api/core';
import { openUrl } from '@tauri-apps/plugin-opener';
import {
acknowledgeRelease,
@@ -122,23 +123,51 @@ function getDevFakeRelease(currentVersion: string): ReleaseInfo | null {
export interface LaunchUpdateDecision {
currentVersion: string;
updateRelease: ReleaseInfo | null;
showFlatpakWelcome: boolean;
whatsNewRelease: ReleaseInfo | null;
updateRelease: ReleaseInfo | null;
}
/**
* Decide which modal (if any) should be shown on launch.
* Check if running in Flatpak and if welcome modal has been shown.
*/
async function shouldShowFlatpakWelcome(): Promise<boolean> {
try {
const isFlatpak = await invoke<boolean>('is_running_in_flatpak');
if (!isFlatpak) return false;
const alreadyShown = await invoke<boolean>('has_flatpak_welcome_been_shown');
return !alreadyShown;
} catch {
return false;
}
}
/**
* Mark Flatpak welcome modal as shown.
*/
export async function markFlatpakWelcomeShown(): Promise<void> {
try {
await invoke('mark_flatpak_welcome_shown');
} catch (error) {
console.debug('[Updates] Failed to mark flatpak welcome shown:', error);
}
}
/**
* Decide which modals should be shown on launch.
*
* Priority:
* 1) Update available modal
* 2) What's New modal
* Priority (shown sequentially, one after another):
* 1) Flatpak welcome modal (only for Flatpak users, once ever)
* 2) What's New modal (once per version)
* 3) Update available modal (when newer version exists)
*/
export async function decideLaunchModals(): Promise<LaunchUpdateDecision> {
if (launchFlowStarted) {
return {
currentVersion: getCurrentVersion(),
updateRelease: null,
showFlatpakWelcome: false,
whatsNewRelease: null,
updateRelease: null,
};
}
launchFlowStarted = true;
@@ -150,38 +179,17 @@ export async function decideLaunchModals(): Promise<LaunchUpdateDecision> {
const currentVersion = getCurrentVersion();
if (getSessionFlag(SESSION_CHECK_DONE_KEY)) {
return { currentVersion, updateRelease: null, whatsNewRelease: null };
return { currentVersion, showFlatpakWelcome: false, whatsNewRelease: null, updateRelease: null };
}
setSessionFlag(SESSION_CHECK_DONE_KEY);
// Phase 1: update available
if (prefs.checkOnLaunch) {
const devFake = getDevFakeRelease(currentVersion);
if (devFake) {
const devScopedKey = `${SESSION_UPDATE_SHOWN_KEY}:${devFake.version}`;
if (!getSessionFlag(devScopedKey)) {
setSessionFlag(devScopedKey);
return {
currentVersion,
updateRelease: devFake,
whatsNewRelease: null,
};
}
}
// Collect all modals that need to be shown
let showFlatpakWelcome = false;
let whatsNewRelease: ReleaseInfo | null = null;
let updateRelease: ReleaseInfo | null = null;
const result = await checkForUpdates('launch');
if (result.status === 'update_available' && result.release) {
const scopedUpdateKey = `${SESSION_UPDATE_SHOWN_KEY}:${result.release.version}`;
if (!getSessionFlag(scopedUpdateKey)) {
setSessionFlag(scopedUpdateKey);
return {
currentVersion,
updateRelease: result.release,
whatsNewRelease: null,
};
}
}
}
// Phase 1: Flatpak welcome (highest priority, shown first)
showFlatpakWelcome = await shouldShowFlatpakWelcome();
// Phase 2: What's New for current version
if (prefs.showWhatsNewOnLaunch && currentVersion) {
@@ -192,19 +200,37 @@ export async function decideLaunchModals(): Promise<LaunchUpdateDecision> {
const release = await fetchReleaseForVersion(currentVersion);
if (release) {
setSessionFlag(scopedWhatsNewKey);
// Persist "shown once per version" at the time we decide to show it.
await markWhatsNewShown(currentVersion);
return {
currentVersion,
updateRelease: null,
whatsNewRelease: release,
};
whatsNewRelease = release;
}
}
}
}
return { currentVersion, updateRelease: null, whatsNewRelease: null };
// Phase 3: Update available (lowest priority, shown last)
if (prefs.checkOnLaunch) {
const devFake = getDevFakeRelease(currentVersion);
if (devFake) {
const devScopedKey = `${SESSION_UPDATE_SHOWN_KEY}:${devFake.version}`;
if (!getSessionFlag(devScopedKey)) {
setSessionFlag(devScopedKey);
updateRelease = devFake;
}
}
if (!updateRelease) {
const result = await checkForUpdates('launch');
if (result.status === 'update_available' && result.release) {
const scopedUpdateKey = `${SESSION_UPDATE_SHOWN_KEY}:${result.release.version}`;
if (!getSessionFlag(scopedUpdateKey)) {
setSessionFlag(scopedUpdateKey);
updateRelease = result.release;
}
}
}
}
return { currentVersion, showFlatpakWelcome, whatsNewRelease, updateRelease };
}
export async function openReleasePageAndAcknowledge(release: ReleaseInfo): Promise<void> {
+46 -5
View File
@@ -301,11 +301,13 @@
import UpdateAvailableModal from '$lib/components/updates/UpdateAvailableModal.svelte';
import UpdateReminderModal from '$lib/components/updates/UpdateReminderModal.svelte';
import WhatsNewModal from '$lib/components/updates/WhatsNewModal.svelte';
import FlatpakWelcomeModal from '$lib/components/updates/FlatpakWelcomeModal.svelte';
import type { ReleaseInfo } from '$lib/stores/updatesStore';
import {
decideLaunchModals,
disableUpdateChecks,
ignoreReleaseVersion,
markFlatpakWelcomeShown,
openReleasePageAndAcknowledge,
} from '$lib/services/updatesService';
@@ -342,8 +344,13 @@
let isUpdateModalOpen = $state(false);
let isReminderModalOpen = $state(false);
let isWhatsNewModalOpen = $state(false);
let isFlatpakWelcomeOpen = $state(false);
let updatesLaunchTriggered = $state(false);
// Sequential modal queue: Flatpak → What's new → Update available
let pendingWhatsNewRelease = $state<ReleaseInfo | null>(null);
let pendingUpdateRelease = $state<ReleaseInfo | null>(null);
// Album, Artist and Label data are fetched, so kept local
let selectedAlbum = $state<AlbumDetail | null>(null);
let selectedArtist = $state<ArtistDetail | null>(null);
@@ -370,15 +377,35 @@
const decision = await decideLaunchModals();
updatesCurrentVersion = decision.currentVersion;
if (decision.updateRelease) {
updateRelease = decision.updateRelease;
isUpdateModalOpen = true;
// Store pending modals for sequential display
// Order: Flatpak → What's new → Update available
pendingWhatsNewRelease = decision.whatsNewRelease;
pendingUpdateRelease = decision.updateRelease;
// Show first modal in queue (Flatpak has highest priority)
if (decision.showFlatpakWelcome) {
isFlatpakWelcomeOpen = true;
return;
}
if (decision.whatsNewRelease) {
whatsNewRelease = decision.whatsNewRelease;
// No Flatpak modal, try What's New
showNextModalInQueue();
}
function showNextModalInQueue(): void {
// What's New has second priority
if (pendingWhatsNewRelease) {
whatsNewRelease = pendingWhatsNewRelease;
pendingWhatsNewRelease = null;
isWhatsNewModalOpen = true;
return;
}
// Update Available has lowest priority
if (pendingUpdateRelease) {
updateRelease = pendingUpdateRelease;
pendingUpdateRelease = null;
isUpdateModalOpen = true;
}
}
@@ -414,9 +441,18 @@
void disableUpdateChecks();
}
function handleFlatpakWelcomeClose(): void {
isFlatpakWelcomeOpen = false;
void markFlatpakWelcomeShown();
// Show next modal in queue
showNextModalInQueue();
}
function handleWhatsNewClose(): void {
isWhatsNewModalOpen = false;
whatsNewRelease = null;
// Show next modal in queue
showNextModalInQueue();
}
$effect(() => {
@@ -3119,6 +3155,11 @@
/>
{/if}
<FlatpakWelcomeModal
isOpen={isFlatpakWelcomeOpen}
onClose={handleFlatpakWelcomeClose}
/>
<!-- Track Info Modal -->
<TrackInfoModal
isOpen={isTrackInfoOpen}