merge(pre-release): sync qconnect-poc-v3 with latest local integration state
cx
This commit is contained in:
445 files changed
+50564
-28577
No files matched your search
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Image Cache Service
|
||||
*
|
||||
* Proxies Qobuz image URLs through the Rust backend cache.
|
||||
* Uses an in-memory map to avoid repeated invoke calls for the same URL.
|
||||
* When cache is disabled, returns the original URL immediately.
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
|
||||
// In-memory URL map: original URL -> resolved URL (cached path or original)
|
||||
const resolvedUrls = new Map<string, string>();
|
||||
|
||||
// Track pending requests to avoid duplicate downloads
|
||||
const pendingRequests = new Map<string, Promise<string>>();
|
||||
|
||||
// Whether the backend cache is enabled (loaded once, updated on settings change)
|
||||
let cacheEnabled: boolean | null = null;
|
||||
|
||||
/**
|
||||
* Resolve an image URL through the backend cache.
|
||||
* Returns a file:// URL if cached, or the original URL if cache is disabled.
|
||||
*/
|
||||
export async function getCachedImageUrl(url: string): Promise<string> {
|
||||
if (!url || url.startsWith('file://') || url.startsWith('asset://')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// Check in-memory cache first
|
||||
const cached = resolvedUrls.get(url);
|
||||
if (cached) return cached;
|
||||
|
||||
// Check if there's already a pending request for this URL
|
||||
const pending = pendingRequests.get(url);
|
||||
if (pending) return pending;
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const result = await invoke<string>('v2_get_cached_image', { url });
|
||||
// Convert file:// paths to Tauri asset URLs for WebView access
|
||||
let resolved: string;
|
||||
if (result.startsWith('file://')) {
|
||||
const filePath = result.slice(7); // Remove file:// prefix
|
||||
resolved = convertFileSrc(filePath);
|
||||
} else {
|
||||
resolved = result;
|
||||
}
|
||||
resolvedUrls.set(url, resolved);
|
||||
return resolved;
|
||||
} catch {
|
||||
// On failure, use original URL
|
||||
resolvedUrls.set(url, url);
|
||||
return url;
|
||||
} finally {
|
||||
pendingRequests.delete(url);
|
||||
}
|
||||
})();
|
||||
|
||||
pendingRequests.set(url, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the in-memory URL map (e.g., when cache is cleared from settings).
|
||||
*/
|
||||
export function clearResolvedUrls(): void {
|
||||
resolvedUrls.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload a batch of image URLs into the cache.
|
||||
* Fires and forgets — doesn't block rendering.
|
||||
*/
|
||||
export function preloadImages(urls: string[]): void {
|
||||
for (const url of urls) {
|
||||
if (url && !resolvedUrls.has(url) && !url.startsWith('file://') && !url.startsWith('asset://')) {
|
||||
getCachedImageUrl(url).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,178 +1,233 @@
|
||||
/**
|
||||
* MiniPlayer Mode Service
|
||||
* Mini Player Window Service
|
||||
*
|
||||
* Handles switching between normal and miniplayer modes by resizing
|
||||
* the main window instead of creating a separate window.
|
||||
*
|
||||
* Uses localStorage to persist original window state across navigation.
|
||||
* New strategy:
|
||||
* - Keep main window mounted/alive
|
||||
* - Open mini player as a second Tauri window
|
||||
* - Hide/show + focus windows without route remount in main
|
||||
*/
|
||||
|
||||
import { getCurrentWindow, PhysicalSize, PhysicalPosition } from '@tauri-apps/api/window';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Window, getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
import {
|
||||
getMiniPlayerState,
|
||||
initMiniPlayerState,
|
||||
setMiniPlayerAlwaysOnTop as setMiniPlayerAlwaysOnTopState,
|
||||
setMiniPlayerOpen,
|
||||
setMiniPlayerSurface,
|
||||
type MiniPlayerSurface
|
||||
} from '$lib/stores/uiStore';
|
||||
import { getUseSystemTitleBar } from '$lib/stores/titleBarStore';
|
||||
import { getUserItem } from '$lib/utils/userStorage';
|
||||
|
||||
// Miniplayer dimensions
|
||||
const MINIPLAYER_WIDTH = 400;
|
||||
const MINIPLAYER_HEIGHT = 200;
|
||||
const MINI_PLAYER_LABEL = 'miniplayer';
|
||||
const MAIN_WINDOW_LABEL = 'main';
|
||||
const MINI_PLAYER_DEFAULT_VIEW_KEY = 'qbz-miniplayer-default-view';
|
||||
const MINI_PLAYER_SURFACE_OPTIONS: MiniPlayerSurface[] = ['micro', 'compact', 'artwork', 'queue', 'lyrics'];
|
||||
let isClosingAppFromMiniPlayer = false;
|
||||
|
||||
// Original app minimum size (from tauri.conf.json)
|
||||
const ORIGINAL_MIN_WIDTH = 800;
|
||||
const ORIGINAL_MIN_HEIGHT = 600;
|
||||
|
||||
// LocalStorage key for persisting window state
|
||||
const STORAGE_KEY = 'miniplayer_original_state';
|
||||
|
||||
interface OriginalState {
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
maximized: boolean;
|
||||
function isMiniPlayerSurface(value: string): value is MiniPlayerSurface {
|
||||
return MINI_PLAYER_SURFACE_OPTIONS.includes(value as MiniPlayerSurface);
|
||||
}
|
||||
|
||||
function saveOriginalState(state: OriginalState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
console.log('[MiniPlayer] Saved state to localStorage:', state);
|
||||
} catch (e) {
|
||||
console.error('[MiniPlayer] Failed to save state:', e);
|
||||
function resolveInitialSurface(requestedSurface?: MiniPlayerSurface): MiniPlayerSurface | null {
|
||||
if (requestedSurface) {
|
||||
return requestedSurface;
|
||||
}
|
||||
}
|
||||
|
||||
function loadOriginalState(): OriginalState | null {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
if (data) {
|
||||
const state = JSON.parse(data) as OriginalState;
|
||||
console.log('[MiniPlayer] Loaded state from localStorage:', state);
|
||||
return state;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[MiniPlayer] Failed to load state:', e);
|
||||
const configuredDefault = getUserItem(MINI_PLAYER_DEFAULT_VIEW_KEY);
|
||||
if (!configuredDefault || configuredDefault === 'remember') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isMiniPlayerSurface(configuredDefault)) {
|
||||
return configuredDefault;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearOriginalState(): void {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
console.log('[MiniPlayer] Cleared state from localStorage');
|
||||
} catch (e) {
|
||||
console.error('[MiniPlayer] Failed to clear state:', e);
|
||||
}
|
||||
export interface AlwaysOnTopResult {
|
||||
applied: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter miniplayer mode - resize window and navigate to miniplayer route
|
||||
*/
|
||||
export async function enterMiniplayerMode(): Promise<void> {
|
||||
console.log('[MiniPlayer] Entering miniplayer mode...');
|
||||
function normalizeRoute(url: string): string {
|
||||
// adapter-static fallback setup expects the app shell entrypoint.
|
||||
// route segment is resolved by the SPA router after bootstrap.
|
||||
return url.startsWith('/') ? url : `/${url}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const window = getCurrentWindow();
|
||||
async function waitForWindowCreation(windowRef: WebviewWindow): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let done = false;
|
||||
|
||||
// Store current window state
|
||||
const maximized = await window.isMaximized();
|
||||
const size = await window.innerSize();
|
||||
const position = await window.innerPosition();
|
||||
|
||||
// Save to localStorage so it survives navigation
|
||||
saveOriginalState({
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
maximized
|
||||
void windowRef.once('tauri://created', () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
resolve();
|
||||
});
|
||||
|
||||
// If maximized, unmaximize first
|
||||
if (maximized) {
|
||||
await window.unmaximize();
|
||||
void windowRef.once<string>('tauri://error', (event) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
reject(new Error(String(event.payload)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getOrCreateMiniPlayerWindow(): Promise<Window> {
|
||||
const existing = await Window.getByLabel(MINI_PLAYER_LABEL);
|
||||
if (existing) return existing;
|
||||
|
||||
const miniState = getMiniPlayerState();
|
||||
const geometry = miniState.geometry;
|
||||
|
||||
const miniWindow = new WebviewWindow(MINI_PLAYER_LABEL, {
|
||||
url: normalizeRoute('/miniplayer'),
|
||||
title: 'QBZ Mini Player',
|
||||
width: geometry.width,
|
||||
height: geometry.height,
|
||||
x: geometry.x,
|
||||
y: geometry.y,
|
||||
minWidth: 340,
|
||||
minHeight: 57,
|
||||
resizable: true,
|
||||
decorations: getUseSystemTitleBar(),
|
||||
alwaysOnTop: miniState.alwaysOnTop,
|
||||
transparent: true,
|
||||
focus: true
|
||||
});
|
||||
|
||||
await waitForWindowCreation(miniWindow);
|
||||
return miniWindow;
|
||||
}
|
||||
|
||||
async function focusMainWindow(): Promise<void> {
|
||||
const mainWindow = await Window.getByLabel(MAIN_WINDOW_LABEL);
|
||||
if (!mainWindow) return;
|
||||
await mainWindow.show();
|
||||
await mainWindow.unminimize();
|
||||
await mainWindow.setFocus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter mini player mode:
|
||||
* create/show mini player window and hide main window.
|
||||
*/
|
||||
export async function enterMiniplayerMode(surface?: MiniPlayerSurface): Promise<void> {
|
||||
initMiniPlayerState();
|
||||
|
||||
const initialSurface = resolveInitialSurface(surface);
|
||||
if (initialSurface) {
|
||||
setMiniPlayerSurface(initialSurface);
|
||||
}
|
||||
|
||||
try {
|
||||
const currentWindow = getCurrentWindow();
|
||||
const miniWindow = await getOrCreateMiniPlayerWindow();
|
||||
|
||||
await miniWindow.show();
|
||||
await miniWindow.unminimize();
|
||||
await miniWindow.setFocus();
|
||||
|
||||
// Hide main window only after mini is visible to avoid a blank app moment.
|
||||
if (currentWindow.label === MAIN_WINDOW_LABEL) {
|
||||
await currentWindow.hide();
|
||||
} else {
|
||||
const mainWindow = await Window.getByLabel(MAIN_WINDOW_LABEL);
|
||||
if (mainWindow) await mainWindow.hide();
|
||||
}
|
||||
|
||||
// IMPORTANT: Remove minimum size constraint first, then set size
|
||||
console.log('[MiniPlayer] Removing min size constraint...');
|
||||
await window.setMinSize(new PhysicalSize(MINIPLAYER_WIDTH, MINIPLAYER_HEIGHT));
|
||||
|
||||
// Small delay to ensure min size is applied before setting size
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
console.log('[MiniPlayer] Setting size to', MINIPLAYER_WIDTH, 'x', MINIPLAYER_HEIGHT);
|
||||
await window.setSize(new PhysicalSize(MINIPLAYER_WIDTH, MINIPLAYER_HEIGHT));
|
||||
|
||||
await window.setResizable(true);
|
||||
// Note: decorations stay false - app uses custom title bar (CSD)
|
||||
await window.setAlwaysOnTop(true);
|
||||
|
||||
// Navigate to miniplayer route
|
||||
await goto('/miniplayer');
|
||||
|
||||
console.log('[MiniPlayer] Entered miniplayer mode');
|
||||
setMiniPlayerOpen(true);
|
||||
console.info('[MiniPlayer] Entered mini player mode');
|
||||
} catch (err) {
|
||||
console.error('[MiniPlayer] Failed to enter miniplayer mode:', err);
|
||||
console.error('[MiniPlayer] Failed to enter mini player mode:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit miniplayer mode - restore original window state
|
||||
* Exit mini player mode:
|
||||
* hide mini player window and show/focus main window.
|
||||
*/
|
||||
export async function exitMiniplayerMode(): Promise<void> {
|
||||
console.log('[MiniPlayer] exitMiniplayerMode called');
|
||||
|
||||
// Load original state from localStorage
|
||||
const originalState = loadOriginalState();
|
||||
console.log('[MiniPlayer] Original state:', originalState);
|
||||
|
||||
try {
|
||||
const window = getCurrentWindow();
|
||||
|
||||
// Restore window properties
|
||||
await window.setAlwaysOnTop(false);
|
||||
// Note: decorations stay false - app uses custom title bar (CSD)
|
||||
await window.setResizable(true);
|
||||
|
||||
// Restore minimum size constraint
|
||||
console.log('[MiniPlayer] Restoring min size constraint...');
|
||||
await window.setMinSize(new PhysicalSize(ORIGINAL_MIN_WIDTH, ORIGINAL_MIN_HEIGHT));
|
||||
|
||||
// Restore size
|
||||
if (originalState) {
|
||||
console.log('[MiniPlayer] Restoring size:', originalState.width, 'x', originalState.height);
|
||||
await window.setSize(new PhysicalSize(originalState.width, originalState.height));
|
||||
|
||||
console.log('[MiniPlayer] Restoring position:', originalState.x, ',', originalState.y);
|
||||
await window.setPosition(new PhysicalPosition(originalState.x, originalState.y));
|
||||
|
||||
if (originalState.maximized) {
|
||||
console.log('[MiniPlayer] Restoring maximized state');
|
||||
await window.maximize();
|
||||
}
|
||||
} else {
|
||||
// Fallback to default size
|
||||
console.log('[MiniPlayer] No original state, using defaults');
|
||||
await window.setSize(new PhysicalSize(1280, 800));
|
||||
const miniWindow = await Window.getByLabel(MINI_PLAYER_LABEL);
|
||||
if (miniWindow) {
|
||||
await miniWindow.hide();
|
||||
}
|
||||
|
||||
// Clear saved state
|
||||
clearOriginalState();
|
||||
await focusMainWindow();
|
||||
|
||||
// Navigate back to main
|
||||
console.log('[MiniPlayer] Navigating to /');
|
||||
await goto('/');
|
||||
|
||||
console.log('[MiniPlayer] Exited miniplayer mode');
|
||||
setMiniPlayerOpen(false);
|
||||
console.info('[MiniPlayer] Restored main window');
|
||||
} catch (err) {
|
||||
console.error('[MiniPlayer] Failed to exit miniplayer mode:', err);
|
||||
console.error('[MiniPlayer] Failed to restore main window:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set miniplayer always on top
|
||||
* Best-effort always-on-top toggle.
|
||||
*
|
||||
* On some Wayland compositors this can be ignored or denied.
|
||||
*/
|
||||
export async function setMiniplayerAlwaysOnTop(alwaysOnTop: boolean): Promise<void> {
|
||||
export async function setMiniplayerAlwaysOnTop(alwaysOnTop: boolean): Promise<AlwaysOnTopResult> {
|
||||
try {
|
||||
const window = getCurrentWindow();
|
||||
await window.setAlwaysOnTop(alwaysOnTop);
|
||||
const miniWindow = await Window.getByLabel(MINI_PLAYER_LABEL);
|
||||
if (!miniWindow) {
|
||||
setMiniPlayerAlwaysOnTopState(alwaysOnTop);
|
||||
return { applied: false, reason: 'mini_window_not_open' };
|
||||
}
|
||||
|
||||
await miniWindow.setAlwaysOnTop(alwaysOnTop);
|
||||
setMiniPlayerAlwaysOnTopState(alwaysOnTop);
|
||||
return { applied: true };
|
||||
} catch (err) {
|
||||
console.error('[MiniPlayer] Failed to set always on top:', err);
|
||||
// Wayland or WM policy may reject always-on-top requests.
|
||||
setMiniPlayerAlwaysOnTopState(false);
|
||||
return {
|
||||
applied: false,
|
||||
reason: err instanceof Error ? err.message : String(err)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeMiniplayerWindow(): Promise<void> {
|
||||
await exitMiniplayerMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the app from the mini player.
|
||||
*
|
||||
* Triggers the main window's close handler which either:
|
||||
* - Hides all windows to tray (if close-to-tray is enabled)
|
||||
* - Quits the app (if close-to-tray is disabled)
|
||||
*/
|
||||
export async function closeAppFromMiniplayer(): Promise<void> {
|
||||
if (isClosingAppFromMiniPlayer) {
|
||||
return;
|
||||
}
|
||||
|
||||
isClosingAppFromMiniPlayer = true;
|
||||
|
||||
try {
|
||||
const miniWindow = await Window.getByLabel(MINI_PLAYER_LABEL);
|
||||
if (miniWindow) {
|
||||
// Make the shutdown path single-window first to reduce teardown races.
|
||||
await miniWindow.hide();
|
||||
}
|
||||
setMiniPlayerOpen(false);
|
||||
|
||||
await focusMainWindow();
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
|
||||
const mainWindow = await Window.getByLabel(MAIN_WINDOW_LABEL);
|
||||
if (mainWindow) {
|
||||
await mainWindow.close();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MiniPlayer] Failed to close app:', err);
|
||||
} finally {
|
||||
isClosingAppFromMiniPlayer = false;
|
||||
}
|
||||
}
|
||||
@@ -185,7 +185,8 @@ export async function playTrack(
|
||||
} else {
|
||||
const result = await invoke<PlayTrackResult>('v2_play_track', {
|
||||
trackId: track.id,
|
||||
quality: getStreamingQuality()
|
||||
quality: getStreamingQuality(),
|
||||
durationSecs: track.duration ? Math.round(track.duration) : null
|
||||
});
|
||||
|
||||
// Update track format based on actual stream format_id from Qobuz
|
||||
|
||||
@@ -91,7 +91,8 @@ export async function getFormats(
|
||||
export async function downloadAlbum(
|
||||
albumId: string,
|
||||
formatId: number,
|
||||
destination: string
|
||||
destination: string,
|
||||
qualityDir: string = ''
|
||||
): Promise<void> {
|
||||
if (USE_MOCK) {
|
||||
// In mock mode we just fire and forget the SSE stream
|
||||
@@ -103,14 +104,16 @@ export async function downloadAlbum(
|
||||
return invoke<void>('v2_purchases_download_album', {
|
||||
albumId,
|
||||
formatId,
|
||||
destination
|
||||
destination,
|
||||
qualityDir
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadTrack(
|
||||
trackId: number,
|
||||
formatId: number,
|
||||
destination: string
|
||||
destination: string,
|
||||
qualityDir: string = ''
|
||||
): Promise<string> {
|
||||
if (USE_MOCK) {
|
||||
await fetch(`${MOCK_URL}/purchases/download/track/${trackId}`, {
|
||||
@@ -121,7 +124,8 @@ export async function downloadTrack(
|
||||
return invoke<string>('v2_purchases_download_track', {
|
||||
trackId,
|
||||
formatId,
|
||||
destination
|
||||
destination,
|
||||
qualityDir
|
||||
});
|
||||
}
|
||||
|
||||
@@ -135,12 +139,14 @@ export async function getDownloadedTrackIds(): Promise<Set<number>> {
|
||||
export async function markTrackDownloaded(
|
||||
trackId: number,
|
||||
albumId: string | undefined,
|
||||
filePath: string
|
||||
filePath: string,
|
||||
formatId: number = 0
|
||||
): Promise<void> {
|
||||
return invoke<void>('v2_purchases_mark_downloaded', {
|
||||
trackId,
|
||||
albumId: albumId ?? null,
|
||||
filePath
|
||||
filePath,
|
||||
formatId
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ async function sendQconnectQueueCommandWithAdmission(
|
||||
// ============ Queue Builders ============
|
||||
|
||||
export function buildQueueTrackFromQobuz(track: QobuzTrack): BackendQueueTrack {
|
||||
const artwork = track.album?.image?.large || track.album?.image?.thumbnail || track.album?.image?.small || '';
|
||||
const artwork = track.album?.image?.small || track.album?.image?.thumbnail || track.album?.image?.large || '';
|
||||
|
||||
// Log if track is not streamable (for debugging unavailable tracks)
|
||||
if (track.streamable === false) {
|
||||
@@ -143,7 +143,8 @@ export function buildQueueTrackFromQobuz(track: QobuzTrack): BackendQueueTrack {
|
||||
album_id: track.album?.id || null,
|
||||
artist_id: track.performer?.id ?? null,
|
||||
streamable: track.streamable ?? true,
|
||||
source: 'qobuz'
|
||||
source: 'qobuz',
|
||||
parental_warning: track.parental_warning ?? false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -174,7 +175,8 @@ export function buildQueueTrackFromAlbumTrack(
|
||||
album_id: track.albumId || albumId || null,
|
||||
artist_id: track.artistId ?? artistId ?? null,
|
||||
streamable: track.streamable ?? true,
|
||||
source: 'qobuz'
|
||||
source: 'qobuz',
|
||||
parental_warning: track.parental_warning ?? false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -198,7 +200,8 @@ export function buildQueueTrackFromPlaylistTrack(track: PlaylistTrack): BackendQ
|
||||
album_id: track.albumId || null,
|
||||
artist_id: track.artistId ?? null,
|
||||
streamable: track.streamable ?? true,
|
||||
source: 'qobuz'
|
||||
source: 'qobuz',
|
||||
parental_warning: track.parental_warning ?? false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -492,7 +495,8 @@ export function buildQueueTrackFromDisplayTrack(track: DisplayTrack): BackendQue
|
||||
is_local: track.isLocal ?? false,
|
||||
album_id: track.albumId || null,
|
||||
artist_id: track.artistId ?? null,
|
||||
source: track.isLocal ? 'local' : 'qobuz'
|
||||
source: track.isLocal ? 'local' : 'qobuz',
|
||||
parental_warning: track.parental_warning ?? false
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ const DEV_FAKE_BODY_KEY = 'qbz-updates-dev-fake-body';
|
||||
|
||||
let sessionId = '';
|
||||
let launchFlowStarted = false;
|
||||
let sessionIdSequence = 0;
|
||||
|
||||
/** Reset launch flow state on logout so re-login can re-evaluate. */
|
||||
export function resetLaunchFlow(): void {
|
||||
@@ -40,7 +41,19 @@ function ensureSessionId(): string {
|
||||
sessionId = 'server';
|
||||
return sessionId;
|
||||
}
|
||||
sessionId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
let entropyPart = `${Date.now()}-${sessionIdSequence++}`;
|
||||
if (typeof crypto !== 'undefined') {
|
||||
if (typeof crypto.randomUUID === 'function') {
|
||||
entropyPart = crypto.randomUUID();
|
||||
} else if (typeof crypto.getRandomValues === 'function') {
|
||||
const buffer = new Uint8Array(12);
|
||||
crypto.getRandomValues(buffer);
|
||||
entropyPart = Array.from(buffer, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
}
|
||||
|
||||
sessionId = `${Date.now()}-${entropyPart}`;
|
||||
try {
|
||||
localStorage.setItem(SESSION_ID_KEY, sessionId);
|
||||
} catch {
|
||||
@@ -131,6 +144,7 @@ function getDevFakeRelease(currentVersion: string): ReleaseInfo | null {
|
||||
export interface LaunchUpdateDecision {
|
||||
currentVersion: string;
|
||||
showFlatpakWelcome: boolean;
|
||||
showSnapWelcome: boolean;
|
||||
whatsNewRelease: ReleaseInfo | null;
|
||||
updateRelease: ReleaseInfo | null;
|
||||
}
|
||||
@@ -160,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.
|
||||
*
|
||||
@@ -173,6 +212,7 @@ export async function decideLaunchModals(): Promise<LaunchUpdateDecision> {
|
||||
return {
|
||||
currentVersion: getCurrentVersion(),
|
||||
showFlatpakWelcome: false,
|
||||
showSnapWelcome: false,
|
||||
whatsNewRelease: null,
|
||||
updateRelease: null,
|
||||
};
|
||||
@@ -190,17 +230,21 @@ export async function decideLaunchModals(): Promise<LaunchUpdateDecision> {
|
||||
const currentVersion = getCurrentVersion();
|
||||
|
||||
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);
|
||||
|
||||
// Collect all modals that need to be shown
|
||||
let showFlatpakWelcome = false;
|
||||
let showSnapWelcome = false;
|
||||
let whatsNewRelease: 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();
|
||||
if (!showFlatpakWelcome) {
|
||||
showSnapWelcome = await shouldShowSnapWelcome();
|
||||
}
|
||||
|
||||
// Phase 2: What's New for current version
|
||||
if (prefs.showWhatsNewOnLaunch && currentVersion) {
|
||||
@@ -241,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> {
|
||||
|
||||
Reference in new issue
Block a user