diff --git a/CHANGELOG.md b/CHANGELOG.md index 16981a83..23b2812d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Gapless auto-advance no longer leaves the playbar on the previous track; missed `audio:track_switched` is reconciled from engine position with seek guards so backward seek is not treated as a gapless switch. * Local library index stores `replayGainPeak` (migration 015) so anti-clipping peak is available on index-first paths without an extra network round-trip. +### Connection — ignore spurious offline hint on desktop + +**By [@cucadmuh](https://github.com/cucadmuh), reported by mikmik on the Psysonic Discord, PR [#1234](https://github.com/Psychotoxical/psysonic/pull/1234)** + +* Desktop builds no longer get stuck showing "offline" when WebKitGTK leaves `navigator.onLine` stuck at `false` while the server is actually reachable — the app now confirms with a real server probe instead of trusting that hint, so browse and playback keep working. Web builds are unchanged. +* Pending favorite/rating sync now flushes when the server actually becomes reachable again, rather than relying on a browser `online` event that may never fire on desktop. + ## [1.49.0] - 2026-06-29 diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 1820e396..3d78a8f6 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -180,6 +180,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Timeline play history — session buffer + play_session bootstrap across queue replace; pin current to top; history replay inserts in-place (PR #1204)', 'Playback — ReplayGain index prefetch, gapless playbar sync, library replayGainPeak column, live RG refresh after sync (PR #1231)', 'Artists browse — album vs track credit mode toggle, starred favorites in both modes, persisted credit mode, SQL letter-bucket filter (PR #1232)', + 'Connection — ignore spurious WebKitGTK navigator.onLine offline hint on desktop; confirm via server probe; flush pending favorite/rating sync on real reachability (report: mikmik on Psysonic Discord, PR #1234)', ], }, { diff --git a/src/cover/reachability.ts b/src/cover/reachability.ts index 3de9d1ea..8110dad1 100644 --- a/src/cover/reachability.ts +++ b/src/cover/reachability.ts @@ -1,4 +1,5 @@ import { useAuthStore } from '../store/authStore'; +import { isNavigatorOfflineHint } from '@/lib/network/navigatorOnlineHint'; import type { CoverServerScope } from './types'; /** Per-server reachability — active/playback use navigator + configured server */ @@ -6,7 +7,7 @@ export function coverServerReachable(scope: CoverServerScope): boolean { if (scope.kind === 'server') { return !!scope.url && !!scope.username; } - if (typeof navigator !== 'undefined' && !navigator.onLine) return false; + if (isNavigatorOfflineHint()) return false; const active = useAuthStore.getState().getActiveServer(); const baseUrl = useAuthStore.getState().getBaseUrl(); return !!(active && baseUrl); diff --git a/src/features/offline/utils/offlineBrowseMode.ts b/src/features/offline/utils/offlineBrowseMode.ts index 4e408039..ea96a387 100644 --- a/src/features/offline/utils/offlineBrowseMode.ts +++ b/src/features/offline/utils/offlineBrowseMode.ts @@ -4,11 +4,12 @@ import { } from '@/store/devOfflineBrowseStore'; import { useConnectionStatus } from '@/lib/hooks/useConnectionStatus'; import { isActiveServerReachable } from '@/lib/network/activeServerReachability'; +import { isNavigatorOfflineHint } from '@/lib/network/navigatorOnlineHint'; /** True when browse/detail pages should use local-bytes-only data sources. */ export function isOfflineBrowseActive(): boolean { if (isDevOfflineBrowseForced()) return true; - if (typeof navigator !== 'undefined' && !navigator.onLine) return true; + if (isNavigatorOfflineHint()) return true; return !isActiveServerReachable(); } @@ -22,6 +23,6 @@ export function useOfflineBrowseActive(): boolean { useConnectionStatus(); if (import.meta.env.DEV && devForceOffline) return true; - if (typeof navigator !== 'undefined' && !navigator.onLine) return true; + if (isNavigatorOfflineHint()) return true; return !isActiveServerReachable(); } diff --git a/src/features/playback/store/pendingStarSync.test.ts b/src/features/playback/store/pendingStarSync.test.ts index c6bf9e38..e5935f76 100644 --- a/src/features/playback/store/pendingStarSync.test.ts +++ b/src/features/playback/store/pendingStarSync.test.ts @@ -11,6 +11,10 @@ vi.mock('@/lib/api/subsonicStarRating', () => ({ import { usePlayerStore } from '@/features/playback/store/playerStore'; import type { Track } from '@/lib/media/trackTypes'; +import { + resetActiveServerConnectionSnapshot, + setActiveServerReachable, +} from '@/lib/network/activeServerReachability'; import { queueSongStar, queueSongRating, _resetPendingStarSyncForTest } from '@/features/playback/store/pendingStarSync'; import { getCachedTrack, @@ -26,6 +30,8 @@ const track = (id: string): Track => ({ describe('pendingStarSync', () => { beforeEach(() => { vi.useFakeTimers(); + resetActiveServerConnectionSnapshot(); + setActiveServerReachable(true); starMock.mockReset().mockResolvedValue(undefined); unstarMock.mockReset().mockResolvedValue(undefined); setRatingMock.mockReset().mockResolvedValue(undefined); @@ -75,6 +81,19 @@ describe('pendingStarSync', () => { expect(usePlayerStore.getState().starredOverrides.t1).toBe(true); // override survives (no rollback) }); + it('flushes pending stars when the active server becomes reachable', async () => { + starMock.mockRejectedValue(new Error('offline')); + queueSongStar('t1', true); + await vi.advanceTimersByTimeAsync(0); + expect(starMock).toHaveBeenCalledTimes(1); + + setActiveServerReachable(false); + setActiveServerReachable(true); + await vi.advanceTimersByTimeAsync(0); + + expect(starMock.mock.calls.length).toBeGreaterThan(1); + }); + it('passes serverId through to star/unstar for cross-server favorites', async () => { queueSongStar('t1', true, 'srv-b'); await vi.runAllTimersAsync(); diff --git a/src/features/playback/store/pendingStarSync.ts b/src/features/playback/store/pendingStarSync.ts index 2e9edf06..3f715fc1 100644 --- a/src/features/playback/store/pendingStarSync.ts +++ b/src/features/playback/store/pendingStarSync.ts @@ -1,6 +1,7 @@ import { setRating, star, unstar } from '@/lib/api/subsonicStarRating'; import { usePlayerStore } from '@/features/playback/store/playerStore'; import { patchCachedTrack } from '@/features/playback/store/queueTrackResolver'; +import { onActiveServerBecameReachable } from '@/lib/network/activeServerReachability'; /** * F4 — pending-sync for **song** star + rating (spec §6.5 / R7-18). @@ -11,7 +12,8 @@ import { patchCachedTrack } from '@/features/playback/store/queueTrackResolver'; * * 1. Set the override optimistically (instant UI). * 2. Retry the Subsonic API (`star` / `unstar` / `setRating`) with exponential - * backoff; flush immediately on `online` / window focus. + * backoff; flush immediately when the active server becomes reachable again + * (`onActiveServerBecameReachable`) or on window focus. * 3. On **star** success: KEEP the override — list views read it — and patch * the in-memory `Track`. F3 index patch-on-use runs in the API layer. * (Ratings clear on success; see `onRatingSuccess`.) @@ -42,8 +44,8 @@ function armListeners(): void { const flushAll = () => { for (const k of pending.keys()) schedule(k, 0); }; - window.addEventListener('online', flushAll); window.addEventListener('focus', flushAll); + onActiveServerBecameReachable(flushAll); } function schedule(k: string, delayMs: number): void { diff --git a/src/lib/hooks/useConnectionStatus.test.ts b/src/lib/hooks/useConnectionStatus.test.ts index 09f89df4..5554de0e 100644 --- a/src/lib/hooks/useConnectionStatus.test.ts +++ b/src/lib/hooks/useConnectionStatus.test.ts @@ -17,6 +17,11 @@ vi.mock('@/lib/perf/perfFlags', () => ({ usePerfProbeFlags: () => ({ disableBackgroundPolling: false }), })); +const isTauri = vi.fn(() => false); +vi.mock('@tauri-apps/api/core', () => ({ + isTauri: () => isTauri(), +})); + import { pingWithCredentialsForProfile } from '@/lib/api/subsonic'; import { useDevOfflineBrowseStore } from '@/features/offline'; import { resetActiveServerConnectionSnapshot, setConnectionStatus } from '@/lib/network/activeServerReachability'; @@ -27,6 +32,7 @@ beforeEach(() => { resetActiveServerConnectionSnapshot(); invalidateReachableEndpointCache(); useDevOfflineBrowseStore.getState().setForceOffline(false); + isTauri.mockReturnValue(false); vi.mocked(pingWithCredentialsForProfile).mockReset(); }); @@ -190,6 +196,51 @@ describe('useConnectionStatus DEV offline toggle', () => { }); }); +describe('useConnectionStatus navigator.onLine (Tauri)', () => { + it('probes the server when navigator.onLine is false inside Tauri', async () => { + isTauri.mockReturnValue(true); + Object.defineProperty(navigator, 'onLine', { configurable: true, value: false }); + + seedDualAddressServer(); + vi.mocked(pingWithCredentialsForProfile).mockResolvedValue({ + ok: true, + type: 'navidrome', + serverVersion: '0.62.0', + openSubsonic: true, + }); + + const { result } = renderHook(() => useConnectionStatus()); + await waitFor(() => expect(result.current.status).toBe('connected')); + expect(vi.mocked(pingWithCredentialsForProfile).mock.calls.length).toBeGreaterThanOrEqual(1); + }); + + it('re-probes on offline event in Tauri instead of disconnecting', async () => { + isTauri.mockReturnValue(true); + Object.defineProperty(navigator, 'onLine', { configurable: true, value: false }); + + seedDualAddressServer(); + vi.mocked(pingWithCredentialsForProfile).mockResolvedValue({ + ok: true, + type: 'navidrome', + serverVersion: '0.62.0', + openSubsonic: true, + }); + + const { result } = renderHook(() => useConnectionStatus()); + await waitFor(() => expect(result.current.status).toBe('connected')); + const callsAfterMount = vi.mocked(pingWithCredentialsForProfile).mock.calls.length; + + await act(async () => { + window.dispatchEvent(new Event('offline')); + }); + + await waitFor(() => + expect(vi.mocked(pingWithCredentialsForProfile).mock.calls.length).toBeGreaterThan(callsAfterMount), + ); + expect(result.current.status).toBe('connected'); + }); +}); + describe('useConnectionStatus shared status', () => { it('keeps all hook instances in sync when connection status changes', () => { const shell = renderHook(() => useConnectionStatus()); diff --git a/src/lib/hooks/useConnectionStatus.ts b/src/lib/hooks/useConnectionStatus.ts index 3f068883..d1314f28 100644 --- a/src/lib/hooks/useConnectionStatus.ts +++ b/src/lib/hooks/useConnectionStatus.ts @@ -15,6 +15,7 @@ import { subscribeConnectionStatus, type ConnectionStatus, } from '@/lib/network/activeServerReachability'; +import { isNavigatorOfflineHint } from '@/lib/network/navigatorOnlineHint'; import { usePerfProbeFlags } from '@/lib/perf/perfFlags'; import { isDevOfflineBrowseForced, @@ -52,7 +53,7 @@ export function useConnectionStatus() { return; } - if (!navigator.onLine) { + if (isNavigatorOfflineHint()) { setActiveServerReachable(false); setConnectionStatus('disconnected'); return; @@ -150,8 +151,14 @@ export function useConnectionStatus() { check(); }; const handleOffline = () => { - setActiveServerReachable(false); - setConnectionStatus('disconnected'); + // WebKitGTK can spuriously fire `offline` while HTTP still works — re-probe + // on desktop instead of hard-disconnecting (see navigatorOnlineHint). + if (isNavigatorOfflineHint()) { + setActiveServerReachable(false); + setConnectionStatus('disconnected'); + return; + } + void check(); }; window.addEventListener('online', handleOnline); diff --git a/src/lib/network/activeServerReachability.ts b/src/lib/network/activeServerReachability.ts index 7bbb5fc0..51c1ca94 100644 --- a/src/lib/network/activeServerReachability.ts +++ b/src/lib/network/activeServerReachability.ts @@ -1,4 +1,5 @@ import { isDevOfflineBrowseForced } from '@/store/devOfflineBrowseStore'; +import { isNavigatorOfflineHint } from '@/lib/network/navigatorOnlineHint'; export type ConnectionStatus = 'connected' | 'disconnected' | 'checking'; @@ -53,9 +54,9 @@ export function resetActiveServerConnectionSnapshot(): void { connectionStatus = 'checking'; } -/** True only when the browser is online and the last active-server probe succeeded. */ +/** True only when no navigator offline hint applies and the last active-server probe succeeded. */ export function isActiveServerReachable(): boolean { if (isDevOfflineBrowseForced()) return false; - if (typeof navigator !== 'undefined' && !navigator.onLine) return false; + if (isNavigatorOfflineHint()) return false; return activeServerReachable === true; } diff --git a/src/lib/network/navigatorOnlineHint.test.ts b/src/lib/network/navigatorOnlineHint.test.ts new file mode 100644 index 00000000..2853cfde --- /dev/null +++ b/src/lib/network/navigatorOnlineHint.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; + +const isTauri = vi.fn(() => false); + +vi.mock('@tauri-apps/api/core', () => ({ + isTauri: () => isTauri(), +})); + +import { isNavigatorOfflineHint } from './navigatorOnlineHint'; + +describe('isNavigatorOfflineHint', () => { + beforeEach(() => { + isTauri.mockReturnValue(false); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('returns false in Tauri even when navigator.onLine is false', () => { + isTauri.mockReturnValue(true); + vi.stubGlobal('navigator', { onLine: false }); + expect(isNavigatorOfflineHint()).toBe(false); + }); + + it('returns true in non-Tauri when navigator.onLine is false (offline hint applies)', () => { + vi.stubGlobal('navigator', { onLine: false }); + expect(isNavigatorOfflineHint()).toBe(true); + }); + + it('returns false when navigator.onLine is true', () => { + vi.stubGlobal('navigator', { onLine: true }); + expect(isNavigatorOfflineHint()).toBe(false); + }); +}); diff --git a/src/lib/network/navigatorOnlineHint.ts b/src/lib/network/navigatorOnlineHint.ts new file mode 100644 index 00000000..8b653772 --- /dev/null +++ b/src/lib/network/navigatorOnlineHint.ts @@ -0,0 +1,21 @@ +import { isTauri } from '@tauri-apps/api/core'; + +/** + * Whether callers should treat `navigator.onLine === false` as an offline signal. + * + * Returns `true` only in non-Tauri hosts when `navigator.onLine` is false. + * WebKitGTK inside Tauri often leaves `navigator.onLine === false` even when + * HTTP to the user's Subsonic/Navidrome server works (ping, search, playback). + * Desktop builds must not trust that hint — use Subsonic probes instead. + * + * @see https://github.com/orgs/tauri-apps/discussions/9269 + */ +export function isNavigatorOfflineHint(): boolean { + if (typeof navigator === 'undefined') return false; + try { + if (isTauri()) return false; + } catch { + /* isTauri unavailable in some test harnesses — fall through */ + } + return !navigator.onLine; +} diff --git a/src/lib/network/subsonicNetworkGuard.ts b/src/lib/network/subsonicNetworkGuard.ts index bd1bcb02..7cf2f789 100644 --- a/src/lib/network/subsonicNetworkGuard.ts +++ b/src/lib/network/subsonicNetworkGuard.ts @@ -3,6 +3,7 @@ import { hasLocalPlaybackUrl } from '@/store/localPlaybackResolve'; import { isDevOfflineBrowseForced } from '@/store/devOfflineBrowseStore'; import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup'; import { isActiveServerReachable } from '@/lib/network/activeServerReachability'; +import { isNavigatorOfflineHint } from '@/lib/network/navigatorOnlineHint'; function isSameServerProfile(a: string, b: string): boolean { if (!a || !b) return false; @@ -18,7 +19,7 @@ function isSameServerProfile(a: string, b: string): boolean { export function isSubsonicServerReachable(serverId: string): boolean { if (!serverId) return false; if (isDevOfflineBrowseForced()) return false; - if (typeof navigator !== 'undefined' && !navigator.onLine) return false; + if (isNavigatorOfflineHint()) return false; const activeId = useAuthStore.getState().activeServerId; if (!activeId || isSameServerProfile(serverId, activeId)) { return isActiveServerReachable(); @@ -34,7 +35,7 @@ export function isSubsonicServerReachable(serverId: string): boolean { export function shouldAttemptSubsonicForServer(serverId: string, trackId?: string): boolean { if (!serverId) return false; if (isDevOfflineBrowseForced()) return false; - if (typeof navigator !== 'undefined' && !navigator.onLine) return false; + if (isNavigatorOfflineHint()) return false; if (trackId && hasLocalPlaybackUrl(trackId, serverId)) return false; return isSubsonicServerReachable(serverId); }