mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
feat(servers): connect and run fully behind a custom-header gate (#1273)
* fix(servers): probe gated servers over native reqwest so custom headers connect Adding a server behind a header gate (Cloudflare Access, Pangolin service tokens) failed at the connect step (#1216). The connect probe ran in the WebView (axios); a non-safelisted header such as Authorization / CF-Access-* makes the browser send a CORS preflight OPTIONS first, and that preflight carries no token — so the gate rejects it and the real request never leaves. Streaming and covers already worked because they go through Rust (reqwest), which never preflights. Route the header-bearing connect probe through a new Tauri command `probe_server_connection` that runs the Subsonic ping over native reqwest with the per-server header context (endpoints + apply rule), reusing the same resolver as the data plane. Header-less servers keep the lightweight WebView path unchanged. - Rust: add `probe_server_connection` (+ `ServerProbeResult`) in app_api/network, register in collect_commands!/generate_handler!, regenerate specta bindings. - Frontend: `pingWithCredentialsForProfile` calls the command when the profile has custom headers; add `serverHttpContextWireForProbe` helper. - Tests: SubsonicClient sends the gate header on ping via http_context (and misses the gated matcher without it); frontend probe routing + WebView fallback; result mapping unit tests. * docs(changelog): note gated-server connect fix (#1272) * fix(servers): keep add form open and surface the reason when connect fails The add-server flow closed the form the instant you pressed Add and, on failure, left only a small status dot — so a bad password, a rejected gate header, or an unreachable host all looked the same ("nothing happened"). - The connect probe now returns a short failure reason (the server's own error message, an HTTP status, or a transport error) via ServerProbeResult and the axios fallback; no secrets or header values are included. - ServersTab keeps the Add form open until the connect test succeeds and shows the reason in a toast on failure (edit flow surfaces it too). - AddServerForm validates the server address and username before probing, with clear per-field messages instead of a silent no-op. - New i18n keys (serverUrlRequired, serverUsernameRequired, serverConnectFailedReason) added across all shipped locales. * fix(servers): route all WebView Subsonic REST through native proxy for gated servers Once a header-gated server (Cloudflare Access, Pangolin, …) connected, every view that still fetched over axios in the WebView came up empty — Main stage, New Releases, Random Albums, Statistics, search, genres — because a non-safelisted gate header makes the WebView send a CORS preflight the gate rejects. Only the Rust-routed paths (streaming, covers, ping) worked. - Add a generic `subsonic_proxy_request` Tauri command backed by `SubsonicClient::send_raw`: it runs the request natively (no preflight), applies the per-server gate header via ServerHttpContext, and returns the untouched JSON body for the WebView to parse as it does an axios response. Supports GET and form-POST (OpenSubsonic formPost) with a clamped timeout. - `api`, `apiWithCredentials`, `apiPostFormWithCredentials` (and thus `apiForServer`/`apiPostFormForServer`) now switch to the native proxy exactly when a request would carry gate headers; header-less servers keep the lightweight WebView axios path unchanged. - Tests: wiremock coverage for `send_raw` (GET gate header + form-POST body); frontend routing tests that gated `api()` calls hit the proxy and header-less ones stay on axios; updated the OpenSubsonic extensions test for the new path. * fix(servers): apply gate header to media paths via URL fallback Streaming, prefetch and artist-info fetches returned 403 on a gated server: apply_for_http_url resolved custom headers strictly by server_ref and attached nothing when the caller's ref (e.g. the audio engine's playback server id) didn't match the registry key. Add a resolve_context helper that falls back to matching the request URL against the server's registered endpoints — the same fallback covers and Navidrome browse already relied on — and route the audio engine, apply_for_http_url and apply_optional_registry_headers through it. Endpoint matching only hits a configured gated server and still honours the apply-to LAN/public rule, so non-gated servers stay untouched. Also pool the native proxy's reqwest clients by timeout bucket so the extra gated-server browse traffic reuses keep-alive connections instead of opening a fresh pool per request (which surfaced as spurious timeouts / 499s on fast endpoints). * fix(servers): register gate headers before probe/bind so native paths work Root cause of the persistent 403s behind a header gate: the native ServerHttpRegistry was populated only at the end of bindIndexedServer, after ensureConnectUrlResolved + the bind session. When that probe was slow, hung, or reported the server briefly offline, the sync never ran, leaving the registry empty — so every Rust-initiated request (stream, cover, prefetch, fanart getArtistInfo2, Navidrome /auth/login) resolved no header and the gate returned 403. The inline-context proxy path kept working, which is why browse succeeded while media/covers failed. Register the header context up front: before the reachability probe in bindIndexedServer, and authoritatively for all servers at the start of bootstrapAllIndexedServers (once React has mounted and IPC is ready). Add concise sync/sync_all diagnostics (endpoint URLs + header count, never values) so gated-server registration is observable in logs. * fix(servers): retry gated-server covers that 403'd during header-registry gap Covers fetched while the native gate-header registry was momentarily empty (e.g. a dev restart before the header sync landed) got a 403, which the cover fetcher classified as a permanent "cover missing" and wrote a 30-minute `.fetch-failed` marker for — so on a gated server most artwork stayed blank long after the gate started answering 200. - Treat a gate-style 401/403 (plus 408/425/429) on cover art as a transient, retryable hiccup rather than a permanent miss, so the short retry loop can ride out a brief registry gap. Genuine 404/410/400 stay permanent. - On (re)bind of a gated server, once its header is registered, clear that server's stale `.fetch-failed` markers and kick a backfill pass so the covers re-download — same rationale as the URL-change retry. * refactor(servers): funnel gate-header application through one helper Collapse the remaining bespoke header-application variants onto the single `apply_optional_registry_headers` / `resolve_context` entry point so gated-server header logic lives in exactly one place: - cover_cache fetch and Navidrome `/auth/login` used older `apply_for_http_url` / `get_for_server_url` + `apply_server_headers_for_http_url` combos; both now call the shared helper. - The audio engine's `apply_playback_request_headers` delegates to it instead of re-implementing the resolve+apply. - Drop the now-unused `ServerHttpRegistry::apply_for_http_url` / `apply_for_base_url` methods. Behaviour is unchanged: a non-gated server (no registry match) leaves every request untouched, exactly as before. This is purely removing duplicate ways to do the same thing so new native paths have one obvious hook. * chore(servers): drop server-http registry debug logging Remove the `[server-http] sync/sync_all` eprintln diagnostics added while tracing the header-registry timing bug. They printed to stderr on every sync (startup + each persist-rehydrate) and echoed endpoint URLs; the behaviour is verified and covered by tests now, so the noise is no longer warranted. The dev-gated `[connect-probe]`/`[subsonic-proxy]` failure logs stay. * docs(changelog): point gated-server entry at PR #1273 * fix(servers): await gate-header sync before probe/bind bindIndexedServer registered the per-server gate headers with a fire-and-forget `void syncServerHttpContextForProfile(...)`, so a direct bind (add / enable a single server) could run the native reachability probe and bind session while the header IPC was still in flight — the registry was empty and native paths 403'd behind the gate, the exact race this branch fixes. Await the sync before probing/binding; the stale-cover retry stays off the critical path. * fix(servers): reclaim LAN from a sticky public endpoint For a dual-address profile, the first endpoint that answered after launch stuck for the whole session: a public sticky endpoint was always tried first and kept answering, so the LAN-first order was never re-run and the connection never upgraded back to LAN. pickReachableBaseUrl now makes a short, no-retry, bounded quick-probe of the higher-priority LAN endpoint before honouring a public sticky entry, so a laptop returning to the LAN upgrades on the next reachability tick while staying remote costs only one bounded probe (never the full retry cushion). * fix(servers): refresh LAN/public badge on active-server switch; credit #1273 The connection badge read the endpoint kind from the last probe and never re-probed when the active server changed, so switching between a LAN-only and a public server left the badge stuck on the old server's classification until the 120-s tick. useConnectionStatus now resets the kind and re-probes when activeServerId changes (mount still deferred to the polling effect). Also adds the settings credits line for the gated-server work (PR #1273). * fix(servers): LAN reclaim uses the probe's own timeout, not a 3s race The reclaim quick-probe raced pingWithCredentialsForProfile against a fixed 3s timer, but the underlying probe (native reqwest for gated servers, axios otherwise) runs on a 15s timeout — so a LAN endpoint answering between 3s and 15s was wrongly treated as unreachable and the session stayed pinned to the public sticky endpoint. Reclaim now does a single, no-retry probe on the LAN endpoint using the normal ping timeout: a slow-but-reachable LAN still upgrades, while a dead one still costs only one attempt (not the full retry cushion) before falling through to the sticky sequence.
This commit is contained in:
@@ -194,6 +194,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Artist detail — fall back to the local library index when getArtist 404s an album-artist id (fixes Random Albums artist links dead-ending at "Artist not found"); album browse + detail cover resolution fall back to the album\'s first track cover for rows synced without one (fixes missing Random Albums tile art) (report: tummydummy, PR #1254)',
|
||||
'Library — follow getAlbum authoritatively for album artist reference so a server-side artist rename heals on resync instead of dead-ending at "Artist not found" (PR #1256)',
|
||||
'Library — prune orphaned identity keys from the multi-library dedup sidecar (library-cluster.db) on rebuild so it no longer bloats with rows for removed/renamed tracks (PR #1255)',
|
||||
'Servers — connect and run fully behind a custom-header gate (Cloudflare Access / Pangolin): native connect probe + REST proxy so browse/playback/covers pass, add-form failure reasons, live LAN/public badge on server switch, and LAN reclaim from a sticky public endpoint (PR #1273)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -226,7 +226,14 @@ export function AddServerForm({
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!form.url.trim()) return;
|
||||
if (!form.url.trim()) {
|
||||
showToast(t('settings.serverUrlRequired'), 4000, 'error');
|
||||
return;
|
||||
}
|
||||
if (!form.username.trim()) {
|
||||
showToast(t('settings.serverUsernameRequired'), 4000, 'error');
|
||||
return;
|
||||
}
|
||||
if (!validateAddresses()) return;
|
||||
|
||||
const headerValidation = validateCustomHeaders(
|
||||
|
||||
@@ -193,8 +193,9 @@ export function ServersTab({
|
||||
};
|
||||
|
||||
const handleAddServer = async (data: Omit<ServerProfile, 'id'>) => {
|
||||
setShowAddForm(false);
|
||||
setPastedServerInvite(null);
|
||||
// Keep the add form open until the connect test actually succeeds — so a
|
||||
// failure can point the user at what's wrong (bad credentials, gate header,
|
||||
// unreachable) instead of silently closing with a tiny status dot.
|
||||
const tempId = '_new';
|
||||
setConnStatus(s => ({ ...s, [tempId]: 'testing' }));
|
||||
try {
|
||||
@@ -233,11 +234,28 @@ export function ServersTab({
|
||||
void syncServerHttpContextForProfile(added);
|
||||
void bootstrapIndexedServer(added);
|
||||
}
|
||||
// Success only: close the form and clear any pasted invite.
|
||||
setShowAddForm(false);
|
||||
setPastedServerInvite(null);
|
||||
} else {
|
||||
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
|
||||
showToast(
|
||||
ping.error
|
||||
? t('settings.serverConnectFailedReason', { reason: ping.error })
|
||||
: t('settings.serverFailed'),
|
||||
6000,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
|
||||
showToast(
|
||||
err instanceof Error
|
||||
? t('settings.serverConnectFailedReason', { reason: err.message })
|
||||
: t('settings.serverFailed'),
|
||||
6000,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -330,8 +348,24 @@ export function ServersTab({
|
||||
scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity, true);
|
||||
}
|
||||
setConnStatus(s => ({ ...s, [id]: ping.ok ? 'ok' : 'error' }));
|
||||
} catch {
|
||||
if (!ping.ok) {
|
||||
showToast(
|
||||
ping.error
|
||||
? t('settings.serverConnectFailedReason', { reason: ping.error })
|
||||
: t('settings.serverFailed'),
|
||||
6000,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
setConnStatus(s => ({ ...s, [id]: 'error' }));
|
||||
showToast(
|
||||
err instanceof Error
|
||||
? t('settings.serverConnectFailedReason', { reason: err.message })
|
||||
: t('settings.serverFailed'),
|
||||
6000,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -497,6 +497,52 @@ export const commands = {
|
||||
* design: a transient DNS hiccup shouldn't block save).
|
||||
*/
|
||||
resolveHostAddresses: (hostname: string) => typedError<string[], string>(__TAURI_INVOKE("resolve_host_addresses", { hostname })),
|
||||
/**
|
||||
* Header-aware connect probe. Runs the Subsonic `ping` over the native
|
||||
* reqwest stack instead of the WebView so that per-server custom headers
|
||||
* (Cloudflare Access / Pangolin service tokens) ride on the request itself.
|
||||
*
|
||||
* The WebView path can't do this behind an auth gate: a custom header like
|
||||
* `Authorization` is not CORS-safelisted, so the browser sends a preflight
|
||||
* `OPTIONS` first — and that preflight carries no token, so the gate rejects
|
||||
* it and the real request never leaves. Native reqwest never preflights, so
|
||||
* the token reaches the origin exactly as it does for streaming / sync.
|
||||
*
|
||||
* `http_context` mirrors `serverHttpContextWireForProfile` for the draft
|
||||
* profile being added/edited (endpoints + headers + apply rule); pass `None`
|
||||
* for a plain probe with no custom headers. Endpoint/apply matching reuses the
|
||||
* same resolver as the data plane, so `base_url` must be the specific endpoint
|
||||
* being probed.
|
||||
*/
|
||||
probeServerConnection: (baseUrl: string, username: string, password: string, httpContext: {
|
||||
serverId: string,
|
||||
appServerId: string,
|
||||
endpoints: ServerHttpEndpointWire[],
|
||||
customHeaders?: CustomHeaderEntryWire[],
|
||||
customHeadersApplyTo?: CustomHeadersApplyTo | null,
|
||||
} | null) => typedError<ServerProbeResult, string>(__TAURI_INVOKE("probe_server_connection", { baseUrl, username, password, httpContext })),
|
||||
/**
|
||||
* WebView-transport bridge for gated servers (Cloudflare Access, Pangolin, …).
|
||||
*
|
||||
* A custom gate header is not CORS-safelisted, so any Subsonic REST call the
|
||||
* WebView makes over `axios`/`fetch` triggers an `OPTIONS` preflight the gate
|
||||
* rejects — breaking browse, search, statistics, and every non-media view.
|
||||
* The frontend routes those calls here whenever it would attach a gate header;
|
||||
* this runs the request natively (no preflight) with the header applied via
|
||||
* the per-server [`ServerHttpContext`], and returns the untouched JSON body
|
||||
* for the WebView to parse exactly as it parses an `axios` response.
|
||||
*
|
||||
* The frontend supplies the *full* query (auth params + endpoint args), so no
|
||||
* credentials are needed here. `endpoint` is the REST segment including
|
||||
* `.view`; `post_form` uses an `application/x-www-form-urlencoded` body.
|
||||
*/
|
||||
subsonicProxyRequest: (baseUrl: string, endpoint: string, params: ([string, string])[], postForm: boolean, timeoutMs: number | null, httpContext: {
|
||||
serverId: string,
|
||||
appServerId: string,
|
||||
endpoints: ServerHttpEndpointWire[],
|
||||
customHeaders?: CustomHeaderEntryWire[],
|
||||
customHeadersApplyTo?: CustomHeadersApplyTo | null,
|
||||
} | null) => typedError<string, string>(__TAURI_INVOKE("subsonic_proxy_request", { baseUrl, endpoint, params, postForm, timeoutMs, httpContext })),
|
||||
serverHttpContextClear: (serverId: string, appServerId: string) => typedError<null, string>(__TAURI_INVOKE("server_http_context_clear", { serverId, appServerId })),
|
||||
serverHttpContextSync: (wire: ServerHttpContextSyncWire) => typedError<null, string>(__TAURI_INVOKE("server_http_context_sync", { wire })),
|
||||
serverHttpContextSyncAll: (entries: ServerHttpContextSyncWire[]) => typedError<null, string>(__TAURI_INVOKE("server_http_context_sync_all", { entries })),
|
||||
@@ -1210,6 +1256,28 @@ export type ServerIndexMapping = {
|
||||
indexKey: string,
|
||||
};
|
||||
|
||||
/**
|
||||
* Result of a connect probe — same shape the WebView `pingWithCredentials`
|
||||
* path returns (`PingWithCredentialsResult` on the TS side).
|
||||
*/
|
||||
export type ServerProbeResult = {
|
||||
/** `true` when the server answered `ping` with `status="ok"`. */
|
||||
ok: boolean,
|
||||
/** Server software family (`navidrome`, …) when advertised. */
|
||||
type: string | null,
|
||||
/** Server build version when advertised. */
|
||||
serverVersion: string | null,
|
||||
/** Whether the server advertises OpenSubsonic extensions. */
|
||||
openSubsonic: boolean,
|
||||
/**
|
||||
* Short human-readable reason when `ok == false` — the server's own error
|
||||
* message, an HTTP status, or a transport error — so the add/edit form can
|
||||
* tell the user *why* it couldn't connect instead of a blank failure.
|
||||
* `None` on success. Never contains header values or the password.
|
||||
*/
|
||||
error: string | null,
|
||||
};
|
||||
|
||||
export type StarredAlbumReconcileItem = {
|
||||
id: string,
|
||||
starredAt: number,
|
||||
|
||||
@@ -446,10 +446,29 @@ describe('pingWithCredentials — explicit URL/credentials path', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('pingWithCredentialsForProfile — custom gate headers', () => {
|
||||
it('sends resolved custom headers on the ping request', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okResponse({ type: 'navidrome' }));
|
||||
await pingWithCredentialsForProfile(
|
||||
describe('pingWithCredentialsForProfile — custom gate headers (native probe)', () => {
|
||||
// Gate-header servers probe over the native reqwest command, not the WebView
|
||||
// axios path, so a non-safelisted header (CF-Access / Pangolin token) can't
|
||||
// trip a CORS preflight the gate would reject. See `probe_server_connection`.
|
||||
type ProbeArgs = {
|
||||
baseUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
httpContext: {
|
||||
endpoints: { url: string; kind: string }[];
|
||||
customHeaders: { name: string; value: string }[];
|
||||
customHeadersApplyTo: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
it('routes header-bearing probes through the native command with the full header context', async () => {
|
||||
let received: ProbeArgs | undefined;
|
||||
onInvoke('probe_server_connection', (args) => {
|
||||
received = args as ProbeArgs;
|
||||
return { ok: true, type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true };
|
||||
});
|
||||
|
||||
const result = await pingWithCredentialsForProfile(
|
||||
{
|
||||
url: 'https://music.example.com',
|
||||
alternateUrl: 'http://192.168.0.10:4533',
|
||||
@@ -460,12 +479,30 @@ describe('pingWithCredentialsForProfile — custom gate headers', () => {
|
||||
},
|
||||
'https://music.example.com',
|
||||
);
|
||||
const config = vi.mocked(axios.get).mock.calls[0]?.[1] as { headers?: Record<string, string> };
|
||||
expect(config.headers?.['CF-Access-Client-Secret']).toBe('gate-secret');
|
||||
|
||||
// No WebView request for gate-header servers.
|
||||
expect(axios.get).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ ok: true, type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true });
|
||||
expect(received?.baseUrl).toBe('https://music.example.com');
|
||||
expect(received?.httpContext?.customHeaders).toEqual([
|
||||
{ name: 'CF-Access-Client-Secret', value: 'gate-secret' },
|
||||
]);
|
||||
expect(received?.httpContext?.customHeadersApplyTo).toBe('public');
|
||||
// Both dual-address endpoints are forwarded so the native resolver applies
|
||||
// the header per-endpoint (public only, here).
|
||||
expect(received?.httpContext?.endpoints).toEqual([
|
||||
{ url: 'http://192.168.0.10:4533', kind: 'local' },
|
||||
{ url: 'https://music.example.com', kind: 'public' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits gate headers when probing the LAN endpoint with applyTo=public', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okResponse({}));
|
||||
it('still probes the LAN endpoint through the native command (header omission delegated to Rust)', async () => {
|
||||
let received: ProbeArgs | undefined;
|
||||
onInvoke('probe_server_connection', (args) => {
|
||||
received = args as ProbeArgs;
|
||||
return { ok: true, type: null, serverVersion: null, openSubsonic: false };
|
||||
});
|
||||
|
||||
await pingWithCredentialsForProfile(
|
||||
{
|
||||
url: 'https://music.example.com',
|
||||
@@ -477,7 +514,148 @@ describe('pingWithCredentialsForProfile — custom gate headers', () => {
|
||||
},
|
||||
'http://192.168.0.10:4533',
|
||||
);
|
||||
const config = vi.mocked(axios.get).mock.calls[0]?.[1] as { headers?: Record<string, string> };
|
||||
expect(config.headers?.['CF-Access-Client-Secret']).toBeUndefined();
|
||||
|
||||
expect(axios.get).not.toHaveBeenCalled();
|
||||
expect(received?.baseUrl).toBe('http://192.168.0.10:4533');
|
||||
});
|
||||
|
||||
it('returns ok=false when the native probe errors', async () => {
|
||||
onInvoke('probe_server_connection', () => {
|
||||
throw new Error('gate rejected');
|
||||
});
|
||||
const r = await pingWithCredentialsForProfile(
|
||||
{
|
||||
url: 'https://music.example.com',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
|
||||
customHeadersApplyTo: 'public',
|
||||
},
|
||||
'https://music.example.com',
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('surfaces the server-supplied failure reason so the add form can show it', async () => {
|
||||
onInvoke('probe_server_connection', () => ({
|
||||
ok: false,
|
||||
type: null,
|
||||
serverVersion: null,
|
||||
openSubsonic: false,
|
||||
error: 'Wrong username or password',
|
||||
}));
|
||||
const r = await pingWithCredentialsForProfile(
|
||||
{
|
||||
url: 'https://music.example.com',
|
||||
username: 'u',
|
||||
password: 'wrong',
|
||||
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
|
||||
customHeadersApplyTo: 'public',
|
||||
},
|
||||
'https://music.example.com',
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toBe('Wrong username or password');
|
||||
});
|
||||
|
||||
it('keeps the WebView axios path for header-less servers', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okResponse({ type: 'navidrome' }));
|
||||
const r = await pingWithCredentialsForProfile(
|
||||
{ url: 'https://music.example.com', username: 'u', password: 'p' },
|
||||
'https://music.example.com',
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(axios.get).toHaveBeenCalledTimes(1);
|
||||
const calledUrl = vi.mocked(axios.get).mock.calls[0]?.[0] as string;
|
||||
expect(calledUrl).toBe('https://music.example.com/rest/ping.view');
|
||||
});
|
||||
|
||||
it('carries the server error message on the header-less axios path too', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(errorResponse('Wrong username or password', 40));
|
||||
const r = await pingWithCredentialsForProfile(
|
||||
{ url: 'https://music.example.com', username: 'u', password: 'wrong' },
|
||||
'https://music.example.com',
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toBe('Wrong username or password');
|
||||
});
|
||||
});
|
||||
|
||||
describe('api() transport routing — gated servers use the native proxy', () => {
|
||||
// Browse / stats / search all funnel through `api()` → axios in the WebView.
|
||||
// For gate-header servers that axios request dies on a CORS preflight, so the
|
||||
// transport must switch to the native `subsonic_proxy_request` command. This
|
||||
// pins that routing decision (and the header-less axios path staying put).
|
||||
type ProxyArgs = {
|
||||
baseUrl: string;
|
||||
endpoint: string;
|
||||
params: [string, string][];
|
||||
postForm: boolean;
|
||||
httpContext: { customHeaders: { name: string; value: string }[] } | null;
|
||||
};
|
||||
|
||||
function addGatedActiveServer() {
|
||||
resetAuthStore();
|
||||
const id = useAuthStore.getState().addServer({
|
||||
name: 'Gated',
|
||||
url: 'https://gated.example.com',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
|
||||
customHeadersApplyTo: 'public',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(id);
|
||||
}
|
||||
|
||||
it('routes getAlbumList2 through the native command with the gate header context', async () => {
|
||||
addGatedActiveServer();
|
||||
let received: ProxyArgs | undefined;
|
||||
onInvoke('subsonic_proxy_request', (args) => {
|
||||
received = args as ProxyArgs;
|
||||
return JSON.stringify({
|
||||
'subsonic-response': { status: 'ok', randomSongs: { song: [] } },
|
||||
});
|
||||
});
|
||||
|
||||
const songs = await getRandomSongs();
|
||||
|
||||
expect(songs).toEqual([]);
|
||||
expect(axios.get).not.toHaveBeenCalled();
|
||||
expect(received?.endpoint).toBe('getRandomSongs.view');
|
||||
expect(received?.baseUrl).toBe('https://gated.example.com');
|
||||
expect(received?.httpContext?.customHeaders).toEqual([
|
||||
{ name: 'CF-Access-Client-Secret', value: 'gate-secret' },
|
||||
]);
|
||||
// Auth params still ride in the forwarded query.
|
||||
expect(received?.params.some(([k]) => k === 'u')).toBe(true);
|
||||
});
|
||||
|
||||
it('propagates a server failure envelope from the proxied body', async () => {
|
||||
addGatedActiveServer();
|
||||
onInvoke('subsonic_proxy_request', () =>
|
||||
JSON.stringify({
|
||||
'subsonic-response': { status: 'failed', error: { code: 40, message: 'Wrong username or password' } },
|
||||
}),
|
||||
);
|
||||
await expect(getRandomSongs()).rejects.toThrow(/wrong username/i);
|
||||
});
|
||||
|
||||
it('keeps the WebView axios path for header-less active servers', async () => {
|
||||
resetAuthStore();
|
||||
const id = useAuthStore.getState().addServer({
|
||||
name: 'Plain', url: 'https://plain.example.com', username: 'u', password: 'p',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(id);
|
||||
vi.mocked(axios.get).mockResolvedValue(okResponse({ randomSongs: { song: [] } }));
|
||||
let proxyCalled = false;
|
||||
onInvoke('subsonic_proxy_request', () => {
|
||||
proxyCalled = true;
|
||||
return JSON.stringify({ 'subsonic-response': { status: 'ok' } });
|
||||
});
|
||||
|
||||
await getRandomSongs();
|
||||
|
||||
expect(axios.get).toHaveBeenCalledTimes(1);
|
||||
expect(proxyCalled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+39
-5
@@ -1,8 +1,9 @@
|
||||
import axios from 'axios';
|
||||
import md5 from 'md5';
|
||||
import { commands } from '@/generated/bindings';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import type { ServerProfile } from '@/store/authStoreTypes';
|
||||
import { headersForServerRequest } from '@/lib/server/serverHttpHeaders';
|
||||
import { headersForServerRequest, serverHttpContextWireForProbe } from '@/lib/server/serverHttpHeaders';
|
||||
import { findServerByIdOrIndexKey } from '@/lib/server/serverLookup';
|
||||
import {
|
||||
type InstantMixProbeResult,
|
||||
@@ -73,10 +74,40 @@ export async function pingWithCredentialsForProfile(
|
||||
>,
|
||||
endpointBaseUrl: string,
|
||||
): Promise<PingWithCredentialsResult> {
|
||||
const base = endpointBaseUrl.startsWith('http')
|
||||
? endpointBaseUrl.replace(/\/$/, '')
|
||||
: `http://${endpointBaseUrl.replace(/\/$/, '')}`;
|
||||
|
||||
// Gate-header servers (Cloudflare Access, Pangolin, …): probe over the native
|
||||
// reqwest stack, not the WebView. A custom `Authorization` / `CF-Access-*`
|
||||
// header is not CORS-safelisted, so the WebView would send a preflight
|
||||
// `OPTIONS` that carries no token — auth gates reject it and the real request
|
||||
// never leaves. Native reqwest never preflights, matching the streaming/sync
|
||||
// paths that already ride these headers. Header-less servers keep the
|
||||
// lightweight WebView path below.
|
||||
const httpContext = serverHttpContextWireForProbe(profile);
|
||||
if (httpContext) {
|
||||
try {
|
||||
const res = await commands.probeServerConnection(base, profile.username, profile.password, httpContext);
|
||||
if (res.status === 'error') {
|
||||
console.warn('[psysonic] pingWithCredentialsForProfile probe failed:', endpointBaseUrl, res.error);
|
||||
return { ok: false, error: res.error };
|
||||
}
|
||||
const data = res.data;
|
||||
return {
|
||||
ok: data.ok,
|
||||
type: data.type ?? undefined,
|
||||
serverVersion: data.serverVersion ?? undefined,
|
||||
openSubsonic: data.openSubsonic,
|
||||
error: data.error ?? undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[psysonic] pingWithCredentialsForProfile probe threw:', endpointBaseUrl, err);
|
||||
return { ok: false, error: err instanceof Error ? err.message : undefined };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const base = endpointBaseUrl.startsWith('http')
|
||||
? endpointBaseUrl.replace(/\/$/, '')
|
||||
: `http://${endpointBaseUrl.replace(/\/$/, '')}`;
|
||||
const salt = secureRandomSalt();
|
||||
const token = md5(profile.password + salt);
|
||||
const resp = await axios.get(`${base}/rest/ping.view`, {
|
||||
@@ -94,15 +125,18 @@ export async function pingWithCredentialsForProfile(
|
||||
});
|
||||
const data = resp.data?.['subsonic-response'];
|
||||
const ok = data?.status === 'ok';
|
||||
const serverMessage =
|
||||
typeof data?.error?.message === 'string' ? (data.error.message as string) : undefined;
|
||||
return {
|
||||
ok,
|
||||
type: typeof data?.type === 'string' ? data.type : undefined,
|
||||
serverVersion: typeof data?.serverVersion === 'string' ? data.serverVersion : undefined,
|
||||
openSubsonic: data?.openSubsonic === true,
|
||||
error: ok ? undefined : serverMessage,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[psysonic] pingWithCredentialsForProfile failed:', endpointBaseUrl, err);
|
||||
return { ok: false };
|
||||
return { ok: false, error: err instanceof Error ? err.message : undefined };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@ import axios from 'axios';
|
||||
import { getLuckyMixLibraryScopeOverride } from '@/lib/library/luckyMixScopeOverride';
|
||||
import md5 from 'md5';
|
||||
import { version } from '@/../package.json';
|
||||
import { commands } from '@/generated/bindings';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import type { ServerProfile } from '@/store/authStoreTypes';
|
||||
import { connectBaseUrlForServer } from '@/lib/server/serverEndpoint';
|
||||
import { headersForServerRequest } from '@/lib/server/serverHttpHeaders';
|
||||
import { headersForServerRequest, serverHttpContextWireForProbe } from '@/lib/server/serverHttpHeaders';
|
||||
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '@/lib/server/serverLookup';
|
||||
|
||||
export const SUBSONIC_CLIENT = `psysonic/${version}`;
|
||||
@@ -75,6 +76,82 @@ export function isHttp414(err: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function httpBaseFromUrl(serverUrl: string): string {
|
||||
return serverUrl.startsWith('http')
|
||||
? serverUrl.replace(/\/$/, '')
|
||||
: `http://${serverUrl.replace(/\/$/, '')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten Subsonic params into ordered `[key, value]` pairs the same way axios
|
||||
* does with `paramsSerializer: { indexes: null }` (arrays repeat the key:
|
||||
* `id=a&id=b`). Undefined / null values are dropped. Used as the wire payload
|
||||
* for the native `subsonic_proxy_request` command.
|
||||
*/
|
||||
function subsonicParamPairs(params: Record<string, unknown>): [string, string][] {
|
||||
const pairs: [string, string][] = [];
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (item === undefined || item === null) continue;
|
||||
pairs.push([key, String(item)]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
pairs.push([key, String(value)]);
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a Subsonic REST call through the native reqwest command instead of the
|
||||
* WebView. Required for gate-header servers (Cloudflare Access, Pangolin): a
|
||||
* non-CORS-safelisted header makes the WebView send an `OPTIONS` preflight the
|
||||
* gate rejects, so browse/search/stats never leave. Native reqwest never
|
||||
* preflights and carries the header via the per-server http context. The raw
|
||||
* JSON body is parsed exactly like an axios response.
|
||||
*/
|
||||
async function requestViaRustProxy<T>(
|
||||
serverUrl: string,
|
||||
endpoint: string,
|
||||
params: Record<string, unknown>,
|
||||
headerProfile: ServerHttpHeaderProfile,
|
||||
timeout: number,
|
||||
postForm: boolean,
|
||||
): Promise<T> {
|
||||
const res = await commands.subsonicProxyRequest(
|
||||
httpBaseFromUrl(serverUrl),
|
||||
endpoint,
|
||||
subsonicParamPairs(params),
|
||||
postForm,
|
||||
timeout,
|
||||
serverHttpContextWireForProbe(headerProfile),
|
||||
);
|
||||
if (res.status === 'error') throw new Error(res.error);
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(res.data);
|
||||
} catch {
|
||||
throw new Error('Invalid response from server (possibly not a Subsonic server)');
|
||||
}
|
||||
return parseSubsonicResponse<T>(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a request to `requestBaseUrl` would carry non-safelisted gate
|
||||
* headers — i.e. it must go through the native proxy, not the WebView. Reuses
|
||||
* `headersForServerRequest` so the endpoint-kind / apply-to logic stays in one
|
||||
* place: an empty header map means the WebView path is safe.
|
||||
*/
|
||||
function requiresNativeTransport(
|
||||
headerProfile: ServerHttpHeaderProfile | undefined,
|
||||
requestBaseUrl: string,
|
||||
): boolean {
|
||||
if (!headerProfile) return false;
|
||||
return Object.keys(headersForServerRequest(headerProfile, requestBaseUrl)).length > 0;
|
||||
}
|
||||
|
||||
export async function apiWithCredentials<T>(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
@@ -85,6 +162,9 @@ export async function apiWithCredentials<T>(
|
||||
headerProfile?: ServerHttpHeaderProfile,
|
||||
): Promise<T> {
|
||||
const params = { ...getAuthParams(username, password), ...extra };
|
||||
if (headerProfile && requiresNativeTransport(headerProfile, serverUrl)) {
|
||||
return requestViaRustProxy<T>(serverUrl, endpoint, params, headerProfile, timeout, false);
|
||||
}
|
||||
const headers = headerProfile ? headersForServerRequest(headerProfile, serverUrl) : {};
|
||||
const resp = await axios.get(`${restBaseFromUrl(serverUrl)}/${endpoint}`, {
|
||||
params,
|
||||
@@ -109,6 +189,9 @@ export async function apiPostFormWithCredentials<T>(
|
||||
headerProfile?: ServerHttpHeaderProfile,
|
||||
): Promise<T> {
|
||||
const params = { ...getAuthParams(username, password), ...extra };
|
||||
if (headerProfile && requiresNativeTransport(headerProfile, serverUrl)) {
|
||||
return requestViaRustProxy<T>(serverUrl, endpoint, params, headerProfile, timeout, true);
|
||||
}
|
||||
const headers = {
|
||||
...(headerProfile ? headersForServerRequest(headerProfile, serverUrl) : {}),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
@@ -193,6 +276,12 @@ export async function api<T>(
|
||||
const { baseUrl, params } = getClient();
|
||||
const server = useAuthStore.getState().getActiveServer();
|
||||
const connectBase = useAuthStore.getState().getBaseUrl();
|
||||
// Gate-header servers: route through the native proxy (no CORS preflight).
|
||||
// `signal` isn't forwarded — the underlying request can't be aborted mid-flight,
|
||||
// but the caller's promise still settles and stale results are ignored upstream.
|
||||
if (server && connectBase && requiresNativeTransport(server, connectBase)) {
|
||||
return requestViaRustProxy<T>(connectBase, endpoint, { ...params, ...extra }, server, timeout, false);
|
||||
}
|
||||
const headers =
|
||||
server && connectBase ? headersForServerRequest(server, connectBase) : {};
|
||||
const resp = await axios.get(`${baseUrl}/${endpoint}`, {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import axios from 'axios';
|
||||
import { onInvoke } from '@/test/mocks/tauri';
|
||||
import {
|
||||
fetchOpenSubsonicExtensionsWithCredentials,
|
||||
hasOpenSubsonicExtension,
|
||||
@@ -70,14 +71,33 @@ describe('fetchOpenSubsonicExtensionsWithCredentials', () => {
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('sends custom gate headers when a header profile is supplied', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okExtensions([]));
|
||||
await fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p', {
|
||||
it('routes through the native proxy with gate headers when a header profile is supplied', async () => {
|
||||
// Gate-header servers can't use the WebView (CORS preflight the gate rejects),
|
||||
// so this must go through the native `subsonic_proxy_request` command with the
|
||||
// header context forwarded — not an axios request.
|
||||
type ProxyArgs = {
|
||||
endpoint: string;
|
||||
httpContext: { customHeaders: { name: string; value: string }[] } | null;
|
||||
};
|
||||
let received: ProxyArgs | undefined;
|
||||
onInvoke('subsonic_proxy_request', (args) => {
|
||||
received = args as ProxyArgs;
|
||||
return JSON.stringify({
|
||||
'subsonic-response': { status: 'ok', openSubsonic: true, openSubsonicExtensions: [] },
|
||||
});
|
||||
});
|
||||
|
||||
const result = await fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p', {
|
||||
url: 'https://music.test',
|
||||
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
|
||||
customHeadersApplyTo: 'public',
|
||||
});
|
||||
const config = vi.mocked(axios.get).mock.calls[0]?.[1] as { headers?: Record<string, string> };
|
||||
expect(config.headers?.['CF-Access-Client-Secret']).toBe('gate-secret');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(axios.get).not.toHaveBeenCalled();
|
||||
expect(received?.endpoint).toBe('getOpenSubsonicExtensions.view');
|
||||
expect(received?.httpContext?.customHeaders).toEqual([
|
||||
{ name: 'CF-Access-Client-Secret', value: 'gate-secret' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -221,7 +221,11 @@ export interface SubsonicDirectory {
|
||||
child: SubsonicDirectoryEntry[];
|
||||
}
|
||||
|
||||
export type PingWithCredentialsResult = SubsonicServerIdentity & { ok: boolean };
|
||||
export type PingWithCredentialsResult = SubsonicServerIdentity & {
|
||||
ok: boolean;
|
||||
/** Short reason when `ok` is false (server message / HTTP status / transport error). */
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export interface RandomSongsFilters {
|
||||
size?: number;
|
||||
|
||||
@@ -93,6 +93,34 @@ describe('useConnectionStatus.isLan', () => {
|
||||
expect(result.current.isLan).toBe(false);
|
||||
});
|
||||
|
||||
it('updates the badge when the active server switches (LAN → public)', async () => {
|
||||
seedDualAddressServer();
|
||||
vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url) =>
|
||||
url === 'http://192.168.0.10'
|
||||
? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true }
|
||||
: { ok: false },
|
||||
);
|
||||
const { result } = renderHook(() => useConnectionStatus());
|
||||
await waitFor(() => expect(result.current.isLan).toBe(true));
|
||||
|
||||
// Switch to a public-only server: the badge must follow, not stay 'local'.
|
||||
const remoteId = useAuthStore.getState().addServer({
|
||||
name: 'Remote',
|
||||
url: 'https://remote.example.com',
|
||||
username: 'tester',
|
||||
password: 'pw',
|
||||
});
|
||||
vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url) =>
|
||||
url === 'https://remote.example.com'
|
||||
? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true }
|
||||
: { ok: false },
|
||||
);
|
||||
act(() => {
|
||||
useAuthStore.getState().setActiveServer(remoteId);
|
||||
});
|
||||
await waitFor(() => expect(result.current.isLan).toBe(false));
|
||||
});
|
||||
|
||||
it('falls back to primary URL classification before the first probe completes', () => {
|
||||
seedDualAddressServer();
|
||||
// Don't resolve the ping — the hook is still in the `checking` state.
|
||||
|
||||
@@ -38,6 +38,8 @@ export function useConnectionStatus() {
|
||||
const [activeEndpointKind, setActiveEndpointKind] = useState<ServerEndpointKind | null>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const prevDevForceOfflineRef = useRef<boolean | null>(null);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const prevActiveServerIdRef = useRef<string | null | undefined>(undefined);
|
||||
|
||||
const check = useCallback(async () => {
|
||||
if (isDevOfflineBrowseForced()) {
|
||||
@@ -93,6 +95,26 @@ export function useConnectionStatus() {
|
||||
setIsRetrying(false);
|
||||
}, [check]);
|
||||
|
||||
// Active server changed (switch server / login / logout): the previous
|
||||
// probe's endpoint kind belongs to the *old* server, so the LAN/public badge
|
||||
// would otherwise stay stuck on it — a LAN-only server keeps reading 'local'
|
||||
// after switching to a public one, and vice versa — until the 120-s tick. Reset
|
||||
// the kind (badge falls back to the new server's own URL classification) and
|
||||
// re-probe now. Mount is skipped: the polling effect already probes on mount.
|
||||
useEffect(() => {
|
||||
if (prevActiveServerIdRef.current === undefined) {
|
||||
prevActiveServerIdRef.current = activeServerId;
|
||||
return;
|
||||
}
|
||||
if (prevActiveServerIdRef.current === activeServerId) return;
|
||||
prevActiveServerIdRef.current = activeServerId;
|
||||
if (perfFlags.disableBackgroundPolling) return;
|
||||
// React Compiler set-state-in-effect rule: reset stale kind, then re-probe.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setActiveEndpointKind(null);
|
||||
void check();
|
||||
}, [activeServerId, check, perfFlags.disableBackgroundPolling]);
|
||||
|
||||
// DEV offline toggle: react to transitions only — the polling effect already
|
||||
// probes on mount; an unconditional check() here doubled probes and ignored
|
||||
// disableBackgroundPolling (PlayerBar tests, perf-flagged runs).
|
||||
|
||||
@@ -8,17 +8,57 @@ import { useAuthStore } from '@/store/authStore';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
import { ensureConnectUrlResolved } from '@/lib/server/serverEndpoint';
|
||||
import { serverIndexKeyForProfile } from '@/lib/server/serverIndexKey';
|
||||
import { syncServerHttpContextForProfile } from '@/lib/server/syncServerHttpContext';
|
||||
import {
|
||||
syncAllServerHttpContexts,
|
||||
syncServerHttpContextForProfile,
|
||||
} from '@/lib/server/syncServerHttpContext';
|
||||
import {
|
||||
libraryCoverBackfillRunFullPass,
|
||||
libraryCoverClearFetchFailures,
|
||||
} from '@/lib/api/coverCache';
|
||||
import { libraryDevEnabled, logLibraryStatus, logLibrarySync, timed } from './libraryDevLog';
|
||||
|
||||
export type BindServerResult = 'bound' | 'offline' | 'error';
|
||||
|
||||
/**
|
||||
* A gated server (Cloudflare Access / Pangolin) whose cover fetches 403'd while
|
||||
* the native header registry was momentarily empty — e.g. a dev restart before
|
||||
* {@link syncServerHttpContextForProfile} landed — wrote 30-minute
|
||||
* `.fetch-failed` markers, so those covers won't retry on their own even after
|
||||
* the gate starts answering. Once the header is (re)registered we drop the
|
||||
* markers and kick a backfill pass so the covers re-download, mirroring the
|
||||
* URL-change retry in `library_cover_backfill_set_base_url`.
|
||||
*/
|
||||
async function retryGatedServerCovers(server: ServerProfile): Promise<void> {
|
||||
if (!server.customHeaders?.length) return;
|
||||
try {
|
||||
const cleared = await libraryCoverClearFetchFailures(serverIndexKeyForProfile(server));
|
||||
if (cleared > 0) void libraryCoverBackfillRunFullPass(true);
|
||||
} catch {
|
||||
/* best-effort — a missing cover cache or offline server is not fatal to bind */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind one server when it participates in the local index (master on, not excluded).
|
||||
*/
|
||||
export async function bindIndexedServer(server: ServerProfile): Promise<BindServerResult> {
|
||||
if (!useLibraryIndexStore.getState().isIndexEnabled(server.id)) return 'error';
|
||||
|
||||
// Register per-server gate headers in the native registry FIRST — before the
|
||||
// reachability probe, the bind session, and any stream / cover / prefetch
|
||||
// request. Those native (reqwest) paths resolve their gate header from the
|
||||
// registry synchronously at call time, so the sync must COMPLETE (awaited)
|
||||
// before we probe/bind — a fire-and-forget sync let the native probe/bind
|
||||
// race an empty registry and 403 behind the gate. `bootstrapAllIndexedServers`
|
||||
// already syncs up front, but a direct `bindIndexedServer` (add / enable one
|
||||
// server) has only this call to lean on.
|
||||
await syncServerHttpContextForProfile(server).catch(() => {});
|
||||
// Header is registered now: clear any stale gate-403 `.fetch-failed` cover
|
||||
// markers so covers that failed during a registry gap re-download. Best-effort
|
||||
// and independent of bind — keep it off the critical path.
|
||||
void retryGatedServerCovers(server);
|
||||
|
||||
// Dual-address: resolve the connect URL once (LAN-first, sticky cached) and
|
||||
// hand that to the Rust bind-session command — Rust then sees the reachable
|
||||
// endpoint instead of the literal primary URL. Single-address profiles fall
|
||||
@@ -50,7 +90,6 @@ export async function bindIndexedServer(server: ServerProfile): Promise<BindServ
|
||||
});
|
||||
logLibraryStatus(server.id, status, 'bind_session');
|
||||
}
|
||||
void syncServerHttpContextForProfile(server);
|
||||
return 'bound';
|
||||
} catch {
|
||||
return 'error';
|
||||
@@ -71,6 +110,12 @@ export async function bootstrapAllIndexedServers(): Promise<Record<string, BindS
|
||||
const lib = useLibraryIndexStore.getState();
|
||||
if (!lib.masterEnabled) return {};
|
||||
const auth = useAuthStore.getState();
|
||||
// Authoritatively (re)populate the native gate-header registry for every saved
|
||||
// server before any bind/probe runs. The persist-rehydrate sync fires very
|
||||
// early and is best-effort; this runs once React has mounted and the Tauri IPC
|
||||
// bridge is ready, so a gated server's headers are present for the reachability
|
||||
// probe, stream, cover and prefetch paths that resolve them from the registry.
|
||||
await syncAllServerHttpContexts(auth.servers).catch(() => {});
|
||||
const active = auth.activeServerId
|
||||
? auth.servers.find(s => s.id === auth.activeServerId) ?? null
|
||||
: null;
|
||||
|
||||
@@ -384,6 +384,61 @@ describe('pickReachableBaseUrl', () => {
|
||||
if (result.ok) expect(result.baseUrl).toBe('https://music.example.com');
|
||||
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
|
||||
});
|
||||
|
||||
it('reclaims LAN from a sticky public endpoint when LAN answers again', async () => {
|
||||
const profile = makeProfile({
|
||||
url: 'https://music.example.com',
|
||||
alternateUrl: 'http://192.168.0.10',
|
||||
});
|
||||
// Boot off-LAN: LAN fails, public answers → public becomes sticky.
|
||||
vi.useFakeTimers();
|
||||
mockDualAddressLanFailPublicOk();
|
||||
const boot = pickReachableBaseUrl(profile);
|
||||
await vi.runAllTimersAsync();
|
||||
await boot;
|
||||
vi.useRealTimers();
|
||||
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
|
||||
|
||||
// Back on the LAN: the quick reclaim probe against the LAN endpoint answers
|
||||
// first, so the connection upgrades without falling back to public.
|
||||
vi.mocked(pingWithCredentialsForProfile).mockReset();
|
||||
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||
const result = await pickReachableBaseUrl(profile);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.baseUrl).toBe('http://192.168.0.10');
|
||||
expect(result.endpoint.kind).toBe('local');
|
||||
}
|
||||
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(pingWithCredentialsForProfile).mock.calls[0]![1]).toBe('http://192.168.0.10');
|
||||
expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10');
|
||||
});
|
||||
|
||||
it('keeps the sticky public endpoint when the LAN reclaim probe still fails', async () => {
|
||||
const profile = makeProfile({
|
||||
url: 'https://music.example.com',
|
||||
alternateUrl: 'http://192.168.0.10',
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
mockDualAddressLanFailPublicOk();
|
||||
const boot = pickReachableBaseUrl(profile);
|
||||
await vi.runAllTimersAsync();
|
||||
await boot;
|
||||
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
|
||||
|
||||
// Still off-LAN: reclaim probe fails fast, sticky public keeps answering.
|
||||
vi.mocked(pingWithCredentialsForProfile).mockClear();
|
||||
const stayPromise = pickReachableBaseUrl(profile);
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await stayPromise;
|
||||
vi.useRealTimers();
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.baseUrl).toBe('https://music.example.com');
|
||||
expect(result.endpoint.kind).toBe('public');
|
||||
}
|
||||
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateReachableEndpointCache', () => {
|
||||
|
||||
@@ -280,6 +280,14 @@ async function pingWithConnectRetries(
|
||||
* list, it's tried first; on failure, the cache entry is cleared and the full
|
||||
* sequence runs.
|
||||
*
|
||||
* LAN reclaim: a sticky *public* endpoint would otherwise pin the whole session
|
||||
* to the public address (public keeps answering, so LAN-first is never retried).
|
||||
* When a higher-priority LAN endpoint is configured, each call first attempts a
|
||||
* single, no-retry probe of it — so a laptop returning to the LAN upgrades back
|
||||
* on the next reachability tick, while staying off-LAN costs only one probe
|
||||
* (its natural timeout) rather than the full retry cushion. The probe uses the
|
||||
* normal ping timeout, so a slow-but-reachable LAN still upgrades.
|
||||
*
|
||||
* Each endpoint is probed with {@link pingWithConnectRetries} (initial ping +
|
||||
* {@link CONNECT_PING_RETRIES} retries, {@link CONNECT_PING_RETRY_DELAY_MS} apart).
|
||||
*
|
||||
@@ -297,8 +305,28 @@ export async function pickReachableBaseUrl(
|
||||
const ordered = serverAddressEndpoints(profile);
|
||||
if (ordered.length === 0) return { ok: false, reason: 'unreachable' };
|
||||
|
||||
// Apply sticky: move the cached endpoint (if still in the list) to the front.
|
||||
const cached = connectCache.get(profile.id);
|
||||
|
||||
// LAN reclaim (see doc): when stuck on a *public* sticky endpoint but a
|
||||
// higher-priority LAN endpoint is configured, try to reclaim LAN first with
|
||||
// a single, no-retry probe. A dead LAN address fails and falls straight
|
||||
// through to the sticky sequence below; a reachable one upgrades the session.
|
||||
const preferred = ordered[0]!;
|
||||
if (
|
||||
cached &&
|
||||
cached !== preferred.url &&
|
||||
preferred.kind === 'local' &&
|
||||
ordered.some(e => e.url === cached)
|
||||
) {
|
||||
const ping = await pingWithCredentialsForProfile(profile, preferred.url);
|
||||
if (ping.ok) {
|
||||
connectCache.set(profile.id, preferred.url);
|
||||
notifyConnectCacheChanged();
|
||||
return { ok: true, baseUrl: preferred.url, endpoint: preferred, ping };
|
||||
}
|
||||
}
|
||||
|
||||
// Apply sticky: move the cached endpoint (if still in the list) to the front.
|
||||
const endpoints =
|
||||
cached && ordered.some(e => e.url === cached)
|
||||
? [
|
||||
|
||||
@@ -141,6 +141,33 @@ export type ServerHttpEndpointWire = {
|
||||
kind: ServerEndpointKind;
|
||||
};
|
||||
|
||||
/**
|
||||
* Header context for the native connect probe (`probe_server_connection`).
|
||||
* Returns `null` when the profile has no custom headers, so the caller keeps
|
||||
* the plain WebView ping path. Unlike {@link serverHttpContextWireForProfile}
|
||||
* this needs no saved-profile id — the probe resolves headers purely from the
|
||||
* endpoint list + apply rule, so `serverId` / `appServerId` are left blank
|
||||
* (the registry is never touched during a probe).
|
||||
*/
|
||||
export function serverHttpContextWireForProbe(
|
||||
profile: Pick<ServerProfile, 'url' | 'alternateUrl' | 'customHeaders' | 'customHeadersApplyTo'>,
|
||||
): {
|
||||
serverId: string;
|
||||
appServerId: string;
|
||||
endpoints: ServerHttpEndpointWire[];
|
||||
customHeaders: CustomHeaderEntry[];
|
||||
customHeadersApplyTo: CustomHeadersApplyTo;
|
||||
} | null {
|
||||
if (!profile.customHeaders?.length) return null;
|
||||
return {
|
||||
serverId: '',
|
||||
appServerId: '',
|
||||
endpoints: serverAddressEndpoints(profile).map(e => ({ url: e.url, kind: e.kind })),
|
||||
customHeaders: profile.customHeaders,
|
||||
customHeadersApplyTo: profile.customHeadersApplyTo ?? DEFAULT_CUSTOM_HEADERS_APPLY_TO,
|
||||
};
|
||||
}
|
||||
|
||||
/** Payload for Rust registry sync — endpoint kinds from TS dual-address layer. */
|
||||
export function serverHttpContextWireForProfile(
|
||||
server: Pick<
|
||||
|
||||
@@ -76,6 +76,9 @@ export const settings = {
|
||||
serverConnecting: 'Свързване…',
|
||||
serverConnected: 'Връзката е установена!',
|
||||
serverFailed: 'Връзката се провали.',
|
||||
serverUrlRequired: 'Адресът на сървъра е задължителен.',
|
||||
serverUsernameRequired: 'Потребителското име е задължително.',
|
||||
serverConnectFailedReason: 'Връзката е неуспешна: {{reason}}',
|
||||
testBtn: 'Тествай връзката',
|
||||
testingBtn: 'Тестване…',
|
||||
serverCompatible: 'Създаден за Navidrome. Други съвместими със Subsonic сървъри (Gonic, Airsonic и др.) може да работят с ограничена функционалност, тъй като Psysonic използва много специфични за Navidrome API крайни точки.',
|
||||
|
||||
@@ -76,6 +76,9 @@ export const settings = {
|
||||
serverConnecting: 'Verbinde…',
|
||||
serverConnected: 'Verbunden!',
|
||||
serverFailed: 'Verbindung fehlgeschlagen.',
|
||||
serverUrlRequired: 'Serveradresse ist erforderlich.',
|
||||
serverUsernameRequired: 'Benutzername ist erforderlich.',
|
||||
serverConnectFailedReason: 'Verbindung fehlgeschlagen: {{reason}}',
|
||||
testBtn: 'Verbindung testen',
|
||||
testingBtn: 'Teste…',
|
||||
serverCompatible: 'Für Navidrome entwickelt. Andere Subsonic-kompatible Server (Gonic, Airsonic, …) funktionieren ggf. eingeschränkt, weil Psysonic viele Navidrome-spezifische API-Endpunkte nutzt.',
|
||||
|
||||
@@ -76,6 +76,9 @@ export const settings = {
|
||||
serverConnecting: 'Connecting…',
|
||||
serverConnected: 'Connected!',
|
||||
serverFailed: 'Connection failed.',
|
||||
serverUrlRequired: 'Server address is required.',
|
||||
serverUsernameRequired: 'Username is required.',
|
||||
serverConnectFailedReason: 'Could not connect: {{reason}}',
|
||||
testBtn: 'Test Connection',
|
||||
testingBtn: 'Testing…',
|
||||
serverCompatible: 'Built for Navidrome. Other Subsonic-compatible servers (Gonic, Airsonic, …) may work with reduced functionality, since Psysonic uses many Navidrome-specific API endpoints.',
|
||||
|
||||
@@ -75,6 +75,9 @@ export const settings = {
|
||||
serverConnecting: 'Conectando…',
|
||||
serverConnected: '¡Conectado!',
|
||||
serverFailed: 'Conexión fallida.',
|
||||
serverUrlRequired: 'La dirección del servidor es obligatoria.',
|
||||
serverUsernameRequired: 'El nombre de usuario es obligatorio.',
|
||||
serverConnectFailedReason: 'No se pudo conectar: {{reason}}',
|
||||
testBtn: 'Probar Conexión',
|
||||
testingBtn: 'Probando…',
|
||||
serverCompatible: 'Diseñado para Navidrome. Otros servidores compatibles con Subsonic (Gonic, Airsonic, …) pueden funcionar con funcionalidad reducida, ya que Psysonic utiliza muchos endpoints específicos de la API de Navidrome.',
|
||||
|
||||
@@ -75,6 +75,9 @@ export const settings = {
|
||||
serverConnecting: 'Connexion…',
|
||||
serverConnected: 'Connecté !',
|
||||
serverFailed: 'Connexion échouée.',
|
||||
serverUrlRequired: 'L’adresse du serveur est requise.',
|
||||
serverUsernameRequired: 'Le nom d’utilisateur est requis.',
|
||||
serverConnectFailedReason: 'Connexion impossible : {{reason}}',
|
||||
testBtn: 'Tester la connexion',
|
||||
testingBtn: 'Test en cours…',
|
||||
serverCompatible: 'Conçu pour Navidrome. Les autres serveurs compatibles Subsonic (Gonic, Airsonic, …) peuvent fonctionner avec des fonctionnalités réduites, car Psysonic utilise de nombreux endpoints d’API spécifiques à Navidrome.',
|
||||
|
||||
@@ -76,6 +76,9 @@ export const settings = {
|
||||
serverConnecting: 'Csatlakozás…',
|
||||
serverConnected: 'Csatlakozva!',
|
||||
serverFailed: 'A kapcsolat sikertelen.',
|
||||
serverUrlRequired: 'A szerver címe kötelező.',
|
||||
serverUsernameRequired: 'A felhasználónév kötelező.',
|
||||
serverConnectFailedReason: 'Nem sikerült csatlakozni: {{reason}}',
|
||||
testBtn: 'Kapcsolat tesztelése',
|
||||
testingBtn: 'Tesztelés…',
|
||||
serverCompatible: 'Navidrome-hoz készült. Más Subsonic-kompatibilis szerverek (Gonic, Airsonic, …) korlátozott funkcionalitással működhetnek, mivel a Psysonic számos Navidrome-specifikus API-végpontot használ.',
|
||||
|
||||
@@ -76,6 +76,9 @@ export const settings = {
|
||||
serverConnecting: 'Connessione in corso…',
|
||||
serverConnected: 'Connesso!',
|
||||
serverFailed: 'Connessione non riuscita.',
|
||||
serverUrlRequired: 'L’indirizzo del server è obbligatorio.',
|
||||
serverUsernameRequired: 'Il nome utente è obbligatorio.',
|
||||
serverConnectFailedReason: 'Impossibile connettersi: {{reason}}',
|
||||
testBtn: 'Testa connessione',
|
||||
testingBtn: 'Test in corso…',
|
||||
serverCompatible: 'Progettato per Navidrome. Altri server compatibili con Subsonic (Gonic, Airsonic, …) potrebbero funzionare con funzionalità ridotte, poiché Psysonic utilizza molti endpoint API specifici di Navidrome.',
|
||||
|
||||
@@ -76,6 +76,9 @@ export const settings = {
|
||||
serverConnecting: '接続中…',
|
||||
serverConnected: '接続しました!',
|
||||
serverFailed: '接続に失敗しました。',
|
||||
serverUrlRequired: 'サーバーアドレスを入力してください。',
|
||||
serverUsernameRequired: 'ユーザー名を入力してください。',
|
||||
serverConnectFailedReason: '接続できませんでした: {{reason}}',
|
||||
testBtn: '接続をテスト',
|
||||
testingBtn: 'テスト中…',
|
||||
serverCompatible: 'Navidrome 向けに作られています。他の Subsonic 互換サーバー (Gonic, Airsonic, …) でも動作する場合がありますが、Psysonic は多くの Navidrome 固有 API エンドポイントを使うため機能が制限されることがあります。',
|
||||
|
||||
@@ -75,6 +75,9 @@ export const settings = {
|
||||
serverConnecting: 'Kobler til…',
|
||||
serverConnected: 'Tilkoblet!',
|
||||
serverFailed: 'Tilkobling mislyktes.',
|
||||
serverUrlRequired: 'Serveradresse er påkrevd.',
|
||||
serverUsernameRequired: 'Brukernavn er påkrevd.',
|
||||
serverConnectFailedReason: 'Kunne ikke koble til: {{reason}}',
|
||||
testBtn: 'Test tilkobling',
|
||||
testingBtn: 'Tester…',
|
||||
serverCompatible: 'Laget for Navidrome. Andre Subsonic-kompatible servere (Gonic, Airsonic, …) kan fungere med begrenset funksjonalitet, fordi Psysonic bruker mange Navidrome-spesifikke API-endepunkter.',
|
||||
|
||||
@@ -75,6 +75,9 @@ export const settings = {
|
||||
serverConnecting: 'Verbinden…',
|
||||
serverConnected: 'Verbonden!',
|
||||
serverFailed: 'Verbinding mislukt.',
|
||||
serverUrlRequired: 'Serveradres is verplicht.',
|
||||
serverUsernameRequired: 'Gebruikersnaam is verplicht.',
|
||||
serverConnectFailedReason: 'Kan geen verbinding maken: {{reason}}',
|
||||
testBtn: 'Verbinding testen',
|
||||
testingBtn: 'Testen…',
|
||||
serverCompatible: 'Gemaakt voor Navidrome. Andere Subsonic-compatibele servers (Gonic, Airsonic, …) kunnen met beperkte functionaliteit werken, omdat Psysonic veel Navidrome-specifieke API-endpoints gebruikt.',
|
||||
|
||||
@@ -76,6 +76,9 @@ export const settings = {
|
||||
serverConnecting: 'Łączenie…',
|
||||
serverConnected: 'Połączono!',
|
||||
serverFailed: 'Połączenie nieudane.',
|
||||
serverUrlRequired: 'Adres serwera jest wymagany.',
|
||||
serverUsernameRequired: 'Nazwa użytkownika jest wymagana.',
|
||||
serverConnectFailedReason: 'Nie można połączyć: {{reason}}',
|
||||
testBtn: 'Sprawdź połączenie',
|
||||
testingBtn: 'Sprawdzanie…',
|
||||
serverCompatible: 'Stworzony dla Navidrome. Inne serwery komponatybilne z Subsonic (Gonic, Airsonic, …) mogę działać, ale z ograniczoną funkcjonalnością, gdyż Psysonic korzysta z wielu endpointów API charakterystycznych dla Navidrome.',
|
||||
|
||||
@@ -75,6 +75,9 @@ export const settings = {
|
||||
serverConnecting: 'Se conectează…',
|
||||
serverConnected: 'Conectat!',
|
||||
serverFailed: 'Conexiune eșuată.',
|
||||
serverUrlRequired: 'Adresa serverului este obligatorie.',
|
||||
serverUsernameRequired: 'Numele de utilizator este obligatoriu.',
|
||||
serverConnectFailedReason: 'Conexiune eșuată: {{reason}}',
|
||||
testBtn: 'Testează Conexiunea',
|
||||
testingBtn: 'Se testează…',
|
||||
serverCompatible: 'Făcut pentru Navidrome. Alte servere compatibile cu Subsonic (Gonic, Airsonic, …) pot rula cu funcționalitate redusă, deoarece Psysonic folosește multe endpointuri API specifice Navidrome.',
|
||||
|
||||
@@ -75,6 +75,9 @@ export const settings = {
|
||||
serverConnecting: 'Подключение…',
|
||||
serverConnected: 'Подключено!',
|
||||
serverFailed: 'Ошибка подключения.',
|
||||
serverUrlRequired: 'Укажите адрес сервера.',
|
||||
serverUsernameRequired: 'Укажите имя пользователя.',
|
||||
serverConnectFailedReason: 'Не удалось подключиться: {{reason}}',
|
||||
testBtn: 'Проверить',
|
||||
testingBtn: 'Проверка…',
|
||||
serverCompatible: 'Разработано для Navidrome. Другие Subsonic-совместимые серверы (Gonic, Airsonic, …) могут работать с ограничениями, так как Psysonic использует много специфичных для Navidrome API-эндпоинтов.',
|
||||
|
||||
@@ -75,6 +75,9 @@ export const settings = {
|
||||
serverConnecting: '正在连接…',
|
||||
serverConnected: '已连接!',
|
||||
serverFailed: '连接失败。',
|
||||
serverUrlRequired: '请填写服务器地址。',
|
||||
serverUsernameRequired: '请填写用户名。',
|
||||
serverConnectFailedReason: '无法连接:{{reason}}',
|
||||
testBtn: '测试连接',
|
||||
testingBtn: '正在测试…',
|
||||
serverCompatible: '专为 Navidrome 构建。其他兼容 Subsonic 的服务器(Gonic、Airsonic 等)可能功能受限,因为 Psysonic 使用了许多 Navidrome 特有的 API 端点。',
|
||||
|
||||
Reference in New Issue
Block a user