mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
fix(connection): ignore spurious navigator.onLine offline hint in Tauri (#1234)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user