fix(connection): ignore spurious navigator.onLine offline hint in Tauri (#1234)

This commit is contained in:
cucadmuh
2026-07-04 20:56:31 +03:00
committed by GitHub
parent bf0e73ce6c
commit 5e075b2c36
12 changed files with 159 additions and 12 deletions
+7
View File
@@ -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. * 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. * 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 ## [1.49.0] - 2026-06-29
+1
View File
@@ -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)', '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)', '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)', '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)',
], ],
}, },
{ {
+2 -1
View File
@@ -1,4 +1,5 @@
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { isNavigatorOfflineHint } from '@/lib/network/navigatorOnlineHint';
import type { CoverServerScope } from './types'; import type { CoverServerScope } from './types';
/** Per-server reachability — active/playback use navigator + configured server */ /** Per-server reachability — active/playback use navigator + configured server */
@@ -6,7 +7,7 @@ export function coverServerReachable(scope: CoverServerScope): boolean {
if (scope.kind === 'server') { if (scope.kind === 'server') {
return !!scope.url && !!scope.username; return !!scope.url && !!scope.username;
} }
if (typeof navigator !== 'undefined' && !navigator.onLine) return false; if (isNavigatorOfflineHint()) return false;
const active = useAuthStore.getState().getActiveServer(); const active = useAuthStore.getState().getActiveServer();
const baseUrl = useAuthStore.getState().getBaseUrl(); const baseUrl = useAuthStore.getState().getBaseUrl();
return !!(active && baseUrl); return !!(active && baseUrl);
@@ -4,11 +4,12 @@ import {
} from '@/store/devOfflineBrowseStore'; } from '@/store/devOfflineBrowseStore';
import { useConnectionStatus } from '@/lib/hooks/useConnectionStatus'; import { useConnectionStatus } from '@/lib/hooks/useConnectionStatus';
import { isActiveServerReachable } from '@/lib/network/activeServerReachability'; import { isActiveServerReachable } from '@/lib/network/activeServerReachability';
import { isNavigatorOfflineHint } from '@/lib/network/navigatorOnlineHint';
/** True when browse/detail pages should use local-bytes-only data sources. */ /** True when browse/detail pages should use local-bytes-only data sources. */
export function isOfflineBrowseActive(): boolean { export function isOfflineBrowseActive(): boolean {
if (isDevOfflineBrowseForced()) return true; if (isDevOfflineBrowseForced()) return true;
if (typeof navigator !== 'undefined' && !navigator.onLine) return true; if (isNavigatorOfflineHint()) return true;
return !isActiveServerReachable(); return !isActiveServerReachable();
} }
@@ -22,6 +23,6 @@ export function useOfflineBrowseActive(): boolean {
useConnectionStatus(); useConnectionStatus();
if (import.meta.env.DEV && devForceOffline) return true; if (import.meta.env.DEV && devForceOffline) return true;
if (typeof navigator !== 'undefined' && !navigator.onLine) return true; if (isNavigatorOfflineHint()) return true;
return !isActiveServerReachable(); return !isActiveServerReachable();
} }
@@ -11,6 +11,10 @@ vi.mock('@/lib/api/subsonicStarRating', () => ({
import { usePlayerStore } from '@/features/playback/store/playerStore'; import { usePlayerStore } from '@/features/playback/store/playerStore';
import type { Track } from '@/lib/media/trackTypes'; import type { Track } from '@/lib/media/trackTypes';
import {
resetActiveServerConnectionSnapshot,
setActiveServerReachable,
} from '@/lib/network/activeServerReachability';
import { queueSongStar, queueSongRating, _resetPendingStarSyncForTest } from '@/features/playback/store/pendingStarSync'; import { queueSongStar, queueSongRating, _resetPendingStarSyncForTest } from '@/features/playback/store/pendingStarSync';
import { import {
getCachedTrack, getCachedTrack,
@@ -26,6 +30,8 @@ const track = (id: string): Track => ({
describe('pendingStarSync', () => { describe('pendingStarSync', () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
resetActiveServerConnectionSnapshot();
setActiveServerReachable(true);
starMock.mockReset().mockResolvedValue(undefined); starMock.mockReset().mockResolvedValue(undefined);
unstarMock.mockReset().mockResolvedValue(undefined); unstarMock.mockReset().mockResolvedValue(undefined);
setRatingMock.mockReset().mockResolvedValue(undefined); setRatingMock.mockReset().mockResolvedValue(undefined);
@@ -75,6 +81,19 @@ describe('pendingStarSync', () => {
expect(usePlayerStore.getState().starredOverrides.t1).toBe(true); // override survives (no rollback) 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 () => { it('passes serverId through to star/unstar for cross-server favorites', async () => {
queueSongStar('t1', true, 'srv-b'); queueSongStar('t1', true, 'srv-b');
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
@@ -1,6 +1,7 @@
import { setRating, star, unstar } from '@/lib/api/subsonicStarRating'; import { setRating, star, unstar } from '@/lib/api/subsonicStarRating';
import { usePlayerStore } from '@/features/playback/store/playerStore'; import { usePlayerStore } from '@/features/playback/store/playerStore';
import { patchCachedTrack } from '@/features/playback/store/queueTrackResolver'; 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). * 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). * 1. Set the override optimistically (instant UI).
* 2. Retry the Subsonic API (`star` / `unstar` / `setRating`) with exponential * 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 * 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. * the in-memory `Track`. F3 index patch-on-use runs in the API layer.
* (Ratings clear on success; see `onRatingSuccess`.) * (Ratings clear on success; see `onRatingSuccess`.)
@@ -42,8 +44,8 @@ function armListeners(): void {
const flushAll = () => { const flushAll = () => {
for (const k of pending.keys()) schedule(k, 0); for (const k of pending.keys()) schedule(k, 0);
}; };
window.addEventListener('online', flushAll);
window.addEventListener('focus', flushAll); window.addEventListener('focus', flushAll);
onActiveServerBecameReachable(flushAll);
} }
function schedule(k: string, delayMs: number): void { function schedule(k: string, delayMs: number): void {
+51
View File
@@ -17,6 +17,11 @@ vi.mock('@/lib/perf/perfFlags', () => ({
usePerfProbeFlags: () => ({ disableBackgroundPolling: false }), usePerfProbeFlags: () => ({ disableBackgroundPolling: false }),
})); }));
const isTauri = vi.fn(() => false);
vi.mock('@tauri-apps/api/core', () => ({
isTauri: () => isTauri(),
}));
import { pingWithCredentialsForProfile } from '@/lib/api/subsonic'; import { pingWithCredentialsForProfile } from '@/lib/api/subsonic';
import { useDevOfflineBrowseStore } from '@/features/offline'; import { useDevOfflineBrowseStore } from '@/features/offline';
import { resetActiveServerConnectionSnapshot, setConnectionStatus } from '@/lib/network/activeServerReachability'; import { resetActiveServerConnectionSnapshot, setConnectionStatus } from '@/lib/network/activeServerReachability';
@@ -27,6 +32,7 @@ beforeEach(() => {
resetActiveServerConnectionSnapshot(); resetActiveServerConnectionSnapshot();
invalidateReachableEndpointCache(); invalidateReachableEndpointCache();
useDevOfflineBrowseStore.getState().setForceOffline(false); useDevOfflineBrowseStore.getState().setForceOffline(false);
isTauri.mockReturnValue(false);
vi.mocked(pingWithCredentialsForProfile).mockReset(); 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', () => { describe('useConnectionStatus shared status', () => {
it('keeps all hook instances in sync when connection status changes', () => { it('keeps all hook instances in sync when connection status changes', () => {
const shell = renderHook(() => useConnectionStatus()); const shell = renderHook(() => useConnectionStatus());
+8 -1
View File
@@ -15,6 +15,7 @@ import {
subscribeConnectionStatus, subscribeConnectionStatus,
type ConnectionStatus, type ConnectionStatus,
} from '@/lib/network/activeServerReachability'; } from '@/lib/network/activeServerReachability';
import { isNavigatorOfflineHint } from '@/lib/network/navigatorOnlineHint';
import { usePerfProbeFlags } from '@/lib/perf/perfFlags'; import { usePerfProbeFlags } from '@/lib/perf/perfFlags';
import { import {
isDevOfflineBrowseForced, isDevOfflineBrowseForced,
@@ -52,7 +53,7 @@ export function useConnectionStatus() {
return; return;
} }
if (!navigator.onLine) { if (isNavigatorOfflineHint()) {
setActiveServerReachable(false); setActiveServerReachable(false);
setConnectionStatus('disconnected'); setConnectionStatus('disconnected');
return; return;
@@ -150,8 +151,14 @@ export function useConnectionStatus() {
check(); check();
}; };
const handleOffline = () => { const handleOffline = () => {
// WebKitGTK can spuriously fire `offline` while HTTP still works — re-probe
// on desktop instead of hard-disconnecting (see navigatorOnlineHint).
if (isNavigatorOfflineHint()) {
setActiveServerReachable(false); setActiveServerReachable(false);
setConnectionStatus('disconnected'); setConnectionStatus('disconnected');
return;
}
void check();
}; };
window.addEventListener('online', handleOnline); window.addEventListener('online', handleOnline);
+3 -2
View File
@@ -1,4 +1,5 @@
import { isDevOfflineBrowseForced } from '@/store/devOfflineBrowseStore'; import { isDevOfflineBrowseForced } from '@/store/devOfflineBrowseStore';
import { isNavigatorOfflineHint } from '@/lib/network/navigatorOnlineHint';
export type ConnectionStatus = 'connected' | 'disconnected' | 'checking'; export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
@@ -53,9 +54,9 @@ export function resetActiveServerConnectionSnapshot(): void {
connectionStatus = 'checking'; 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 { export function isActiveServerReachable(): boolean {
if (isDevOfflineBrowseForced()) return false; if (isDevOfflineBrowseForced()) return false;
if (typeof navigator !== 'undefined' && !navigator.onLine) return false; if (isNavigatorOfflineHint()) return false;
return activeServerReachable === true; return activeServerReachable === true;
} }
@@ -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);
});
});
+21
View File
@@ -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;
}
+3 -2
View File
@@ -3,6 +3,7 @@ import { hasLocalPlaybackUrl } from '@/store/localPlaybackResolve';
import { isDevOfflineBrowseForced } from '@/store/devOfflineBrowseStore'; import { isDevOfflineBrowseForced } from '@/store/devOfflineBrowseStore';
import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup'; import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup';
import { isActiveServerReachable } from '@/lib/network/activeServerReachability'; import { isActiveServerReachable } from '@/lib/network/activeServerReachability';
import { isNavigatorOfflineHint } from '@/lib/network/navigatorOnlineHint';
function isSameServerProfile(a: string, b: string): boolean { function isSameServerProfile(a: string, b: string): boolean {
if (!a || !b) return false; if (!a || !b) return false;
@@ -18,7 +19,7 @@ function isSameServerProfile(a: string, b: string): boolean {
export function isSubsonicServerReachable(serverId: string): boolean { export function isSubsonicServerReachable(serverId: string): boolean {
if (!serverId) return false; if (!serverId) return false;
if (isDevOfflineBrowseForced()) return false; if (isDevOfflineBrowseForced()) return false;
if (typeof navigator !== 'undefined' && !navigator.onLine) return false; if (isNavigatorOfflineHint()) return false;
const activeId = useAuthStore.getState().activeServerId; const activeId = useAuthStore.getState().activeServerId;
if (!activeId || isSameServerProfile(serverId, activeId)) { if (!activeId || isSameServerProfile(serverId, activeId)) {
return isActiveServerReachable(); return isActiveServerReachable();
@@ -34,7 +35,7 @@ export function isSubsonicServerReachable(serverId: string): boolean {
export function shouldAttemptSubsonicForServer(serverId: string, trackId?: string): boolean { export function shouldAttemptSubsonicForServer(serverId: string, trackId?: string): boolean {
if (!serverId) return false; if (!serverId) return false;
if (isDevOfflineBrowseForced()) 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; if (trackId && hasLocalPlaybackUrl(trackId, serverId)) return false;
return isSubsonicServerReachable(serverId); return isSubsonicServerReachable(serverId);
} }