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:
cucadmuh
2026-07-11 16:17:17 +03:00
committed by GitHub
parent 1625f4a8be
commit 96530f244e
38 changed files with 1275 additions and 86 deletions
+13
View File
@@ -242,6 +242,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* FLAC, Ogg Vorbis, Opus and Speex files that store their synced lyrics in the `SYNCEDLYRICS` tag showed no embedded lyrics at all. That tag is now read, and it takes priority over the plain `LYRICS` tag as intended.
### Servers — connecting to servers behind a header gate
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1273](https://github.com/Psychotoxical/psysonic/pull/1273)**
* Adding a server that needs a custom HTTP header (Cloudflare Access, Pangolin service tokens) failed with "Connection failed" even though streaming and covers would have worked. Root cause: the connection test ran in the WebView, which sends a header-less CORS preflight the gate rejects before the real request. The test now runs natively for header-carrying servers, so the header rides on the request and the server connects.
* A failed "Add server" used to close the form and leave only a tiny status dot, so it was unclear what went wrong. The form now stays open on failure and shows the reason — the server's own message (e.g. "Wrong username or password"), an HTTP status like `HTTP 403 Forbidden`, or a transport error — and empty server address / username are caught up front with a clear message.
* Even after a gated server connected, browse and detail views that run in the WebView — Main stage, New Releases, Random Albums, Statistics, search, genres, and more — stayed empty, because those `axios` requests still tripped the gate's CORS preflight. Every Subsonic REST call that would carry a gate header now runs natively (the same reqwest path streaming and covers use), so the whole app works behind a header gate, not just playback.
* Playback itself and background prefetch still returned `403` on a gated server: the audio/preload path attached the gate header only when its playback server id happened to match the header registry key, and silently sent nothing otherwise. Header lookup now falls back to matching the request URL against the server's own endpoints (the same fallback covers and Navidrome browse already used), so streaming, prefetch and artist-info fetches carry the header reliably. Endpoint matching only ever hits a configured gated server and still honours the *apply to LAN/public* rule, so non-gated servers are untouched.
* Under the extra native-proxy traffic a gated server now generates, browse calls opened a brand-new connection pool per request, which starved connections and made fast endpoints time out. Proxied requests now reuse a pooled HTTP client, keeping keep-alive across the burst of browse calls.
* On app startup the per-server gate headers were registered with the native layer only after a successful reachability probe and bind — so if that probe was slow or the server looked briefly offline, the registry stayed empty and every native request (streaming, covers, prefetch, artist art) 403'd behind the gate even though the header was configured. Headers are now registered up front, before any probe or bind, so the native layer always has them.
* Covers that hit the gate during the brief window before headers were registered got a `403`, which was treated as "cover missing" and written a 30-minute do-not-retry marker — so on a gated server most covers stayed blank long after the gate started answering. A gate-style `403`/`401` on cover art is now treated as a recoverable hiccup (retried) rather than a permanent miss, and reconnecting a gated server clears those stale markers and re-runs the cover fill so the artwork loads.
* For a server with both a LAN and a public address, whichever answered first after launch stuck for the whole session: if the app started off the LAN it pinned to the public address and never switched back to LAN once you got home (the public address kept answering, so the LAN address was never re-checked). The reachability tick now re-checks the LAN address first with a single attempt, so returning to the LAN upgrades the connection back to local automatically, while staying remote costs just that one probe rather than the full retry wait. The LAN/public badge also refreshes immediately when you switch the active server, instead of staying on the previous server's classification until the next poll.
## [1.49.0] - 2026-06-29
@@ -578,15 +578,7 @@ pub(crate) fn apply_playback_request_headers(
url: &str,
req: reqwest::RequestBuilder,
) -> reqwest::RequestBuilder {
if let Some(reg) = registry {
if let Some(sid) = server_id.filter(|s| !s.is_empty()) {
return reg.apply_for_http_url(sid, url, req);
}
if let Some(ctx) = reg.get_for_server_url(url) {
return psysonic_core::server_http::apply_server_headers_for_http_url(req, &ctx, url);
}
}
req
psysonic_core::server_http::apply_optional_registry_headers(registry, server_id, url, req)
}
/// Custom HTTP headers for reverse-proxy gates — cloned into background download tasks.
@@ -271,32 +271,34 @@ impl ServerHttpRegistry {
None
}
pub fn apply_for_http_url(
/// Resolve a context by `server_ref` first, then fall back to matching the
/// request URL against the registered endpoints. The URL fallback is what
/// keeps gated servers working when a caller passes a stale/foreign ref
/// (e.g. the audio engine's playback server id vs. the index key): endpoint
/// matching only ever hits a registered gated server, and `apply_to` is
/// still enforced downstream, so non-gated servers are never touched.
pub fn resolve_context(
&self,
server_ref: &str,
server_ref: Option<&str>,
full_http_url: &str,
builder: RequestBuilder,
) -> RequestBuilder {
let Some(ctx) = self.get_for_server_ref(server_ref) else {
return builder;
};
apply_server_headers_for_http_url(builder, &ctx, full_http_url)
) -> Option<Arc<ServerHttpContext>> {
if let Some(sid) = server_ref.filter(|s| !s.is_empty()) {
if let Some(ctx) = self.get_for_server_ref(sid) {
return Some(ctx);
}
}
self.get_for_server_url(full_http_url)
}
pub fn apply_for_base_url(
&self,
server_ref: &str,
request_base_url: &str,
builder: RequestBuilder,
) -> RequestBuilder {
let Some(ctx) = self.get_for_server_ref(server_ref) else {
return builder;
};
apply_server_headers(builder, &ctx, request_base_url)
}
}
/// Apply custom headers when `registry` is present — prefers `server_ref`, falls back to URL match.
/// The single entry point for attaching a gated server's custom headers to any
/// native request. Resolves the context by `server_ref` first, then falls back
/// to matching the request URL against a registered gated endpoint; a non-gated
/// server (no match) leaves the builder untouched. Every raw-download call site
/// (streaming, cover art, analysis prefetch, Navidrome auth, offline transfer)
/// and `SubsonicClient::with_registry` funnel through this / `resolve_context`,
/// so gate-header behaviour lives in exactly one place.
pub fn apply_optional_registry_headers(
registry: Option<&ServerHttpRegistry>,
server_ref: Option<&str>,
@@ -304,10 +306,7 @@ pub fn apply_optional_registry_headers(
builder: RequestBuilder,
) -> RequestBuilder {
if let Some(reg) = registry {
if let Some(sid) = server_ref.filter(|s| !s.is_empty()) {
return reg.apply_for_http_url(sid, full_http_url, builder);
}
if let Some(ctx) = reg.get_for_server_url(full_http_url) {
if let Some(ctx) = reg.resolve_context(server_ref, full_http_url) {
return apply_server_headers_for_http_url(builder, &ctx, full_http_url);
}
}
@@ -343,6 +342,40 @@ mod tests {
assert_eq!(pub_.get("X-Gate").map(|v| v.to_str().ok()), Some(Some("secret")));
}
#[test]
fn resolve_context_falls_back_to_url_when_ref_is_stale() {
// Registry keyed by index key with an app-id alias; endpoint is the gate.
let reg = ServerHttpRegistry::new();
reg.sync(ServerHttpContextSyncWire {
server_id: "127.0.0.1".into(),
app_server_id: "uuid-1".into(),
endpoints: vec![ServerHttpEndpointWire {
url: "http://127.0.0.1:8899".into(),
kind: EndpointKind::Local,
}],
custom_headers: vec![CustomHeaderEntryWire {
name: "X-Gate".into(),
value: "tok".into(),
}],
custom_headers_apply_to: Some(CustomHeadersApplyTo::Both),
});
let stream_url = "http://127.0.0.1:8899/rest/stream.view?id=42&u=x&t=y";
// The audio engine passes a playback server id that is neither the index
// key nor the app-id alias — it must still resolve via the request URL.
let ctx = reg
.resolve_context(Some("some-stale-playback-id"), stream_url)
.expect("stale ref must fall back to URL endpoint match");
let headers = headers_for_request_base_url(&ctx, "http://127.0.0.1:8899");
assert_eq!(headers.get("X-Gate").map(|v| v.to_str().ok()), Some(Some("tok")));
// A non-gated server URL never resolves — foreign servers stay untouched.
assert!(reg
.resolve_context(Some("some-stale-playback-id"), "https://other.example/rest/stream.view?id=1")
.is_none());
}
#[test]
fn registry_resolves_app_id_alias() {
let reg = ServerHttpRegistry::new();
@@ -1,7 +1,7 @@
//! Auth + retry + HTTP client for Navidrome's native REST API.
//! Used by every other navidrome submodule for `/auth/*` and `/api/*` calls.
use psysonic_core::server_http::{apply_server_headers_for_http_url, apply_optional_registry_headers, ServerHttpRegistry};
use psysonic_core::server_http::{apply_optional_registry_headers, ServerHttpRegistry};
/// Authenticate with Navidrome's own REST API and return a Bearer token.
pub async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> {
@@ -17,14 +17,14 @@ pub async fn navidrome_token_with_registry(
let client = reqwest::Client::new();
let base = server_url.trim_end_matches('/');
let login_url = format!("{base}/auth/login");
let mut req = client
.post(&login_url)
.json(&serde_json::json!({ "username": username, "password": password }));
if let Some(reg) = registry {
if let Some(ctx) = reg.get_for_server_url(server_url) {
req = apply_server_headers_for_http_url(req, &ctx, &login_url);
}
}
let req = apply_optional_registry_headers(
registry,
None,
&login_url,
client
.post(&login_url)
.json(&serde_json::json!({ "username": username, "password": password })),
);
let resp = req.send().await.map_err(|e| e.to_string())?;
let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
data["token"]
@@ -352,6 +352,47 @@ impl SubsonicClient {
.await
.map_err(|e| SubsonicError::Transport(flatten_reqwest_error(e)))
}
/// Raw WebView-transport bridge: the caller (TypeScript) has already built
/// the *full* query — auth params (`u`/`t`/`s`/`v`/`c`/`f`) plus the
/// endpoint's own args — so this only attaches gate headers + UA and hands
/// the untouched response body back for the frontend to parse. It lets
/// gated servers (Cloudflare Access, Pangolin, …) reach every Subsonic
/// endpoint the WebView would otherwise call over `axios`, where a
/// non-safelisted header trips a CORS preflight the gate rejects.
///
/// `endpoint` is the REST path segment *including* `.view`
/// (e.g. `getAlbumList2.view`). `post_form` sends the params as an
/// `application/x-www-form-urlencoded` body (OpenSubsonic `formPost`, for
/// large multi-`id` calls) instead of a query string.
pub async fn send_raw(
&self,
endpoint: &str,
params: &[(String, String)],
post_form: bool,
) -> Result<String, SubsonicError> {
let url = format!("{}/rest/{endpoint}", self.base_url);
let mut req = if post_form {
self.http.post(&url).form(params)
} else {
self.http.get(&url).query(params)
};
if let Some(ctx) = &self.http_context {
req = apply_server_headers(req, ctx, &self.base_url);
}
let resp = req
.send()
.await
.map_err(|e| SubsonicError::Transport(flatten_reqwest_error(e)))?;
if !resp.status().is_success() {
return Err(SubsonicError::HttpStatus(resp.status()));
}
resp.text()
.await
.map_err(|e| SubsonicError::Transport(flatten_reqwest_error(e)))
}
}
#[derive(Deserialize)]
@@ -661,6 +702,126 @@ mod tests {
test_client(&server.uri()).ping().await.expect("ping must succeed");
}
#[tokio::test(flavor = "multi_thread")]
async fn ping_sends_custom_gate_header_via_http_context() {
// Backs the connect-probe fix (#1216): a per-server gate header
// (Cloudflare Access / Pangolin) must ride on the ping itself. The mock
// only answers when the header is present, so a passing ping proves the
// header was sent on the native request (no WebView CORS preflight).
use psysonic_core::server_http::{CustomHeadersApplyTo, EndpointKind};
use wiremock::matchers::header;
let server = MockServer::start().await;
Mock::given(wm_method("GET"))
.and(wm_path("/rest/ping.view"))
.and(header("CF-Access-Client-Secret", "gate-secret"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subsonic-response": { "status": "ok", "version": "1.16.1" }
})))
.mount(&server)
.await;
let ctx = ServerHttpContext {
endpoints: vec![(server.uri(), EndpointKind::Public)],
headers: vec![("CF-Access-Client-Secret".into(), "gate-secret".into())],
apply_to: CustomHeadersApplyTo::Public,
};
test_client(&server.uri())
.with_http_context(ctx)
.ping()
.await
.expect("ping must carry the gate header to the mounted matcher");
}
#[tokio::test(flavor = "multi_thread")]
async fn ping_without_context_misses_gate_matcher() {
// Same gated mock, but no header context: the request must NOT match, so
// the probe fails — confirming the header (not something else) is what
// unlocks the gated endpoint.
use wiremock::matchers::header;
let server = MockServer::start().await;
Mock::given(wm_method("GET"))
.and(wm_path("/rest/ping.view"))
.and(header("CF-Access-Client-Secret", "gate-secret"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subsonic-response": { "status": "ok" }
})))
.mount(&server)
.await;
let err = test_client(&server.uri()).ping().await.unwrap_err();
assert!(
matches!(err, SubsonicError::HttpStatus(_)),
"gated endpoint without the header should not match the mock (got {err:?})"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn send_raw_get_forwards_query_and_gate_header_and_returns_body() {
// WebView-transport bridge: the frontend passes the full query (auth +
// endpoint args) and the gate header rides via the http context. The
// mock only answers when both the caller's `type` param and the gate
// header are present, and the untouched JSON body is returned verbatim.
use psysonic_core::server_http::{CustomHeadersApplyTo, EndpointKind};
use wiremock::matchers::header;
let server = MockServer::start().await;
Mock::given(wm_method("GET"))
.and(wm_path("/rest/getAlbumList2.view"))
.and(query_param("type", "newest"))
.and(query_param("u", "user"))
.and(header("CF-Access-Client-Secret", "gate-secret"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subsonic-response": { "status": "ok", "albumList2": { "album": [] } }
})))
.mount(&server)
.await;
let ctx = ServerHttpContext {
endpoints: vec![(server.uri(), EndpointKind::Public)],
headers: vec![("CF-Access-Client-Secret".into(), "gate-secret".into())],
apply_to: CustomHeadersApplyTo::Public,
};
let params = vec![
("u".to_string(), "user".to_string()),
("type".to_string(), "newest".to_string()),
];
let body = test_client(&server.uri())
.with_http_context(ctx)
.send_raw("getAlbumList2.view", &params, false)
.await
.expect("gated raw GET must reach the mounted matcher");
assert!(body.contains("albumList2"), "raw body returned verbatim: {body}");
}
#[tokio::test(flavor = "multi_thread")]
async fn send_raw_post_form_sends_params_in_body() {
// OpenSubsonic `formPost` path for large multi-`id` calls: params ride in
// the urlencoded body, not the query string.
use wiremock::matchers::body_string_contains;
let server = MockServer::start().await;
Mock::given(wm_method("POST"))
.and(wm_path("/rest/savePlayQueue.view"))
.and(body_string_contains("id=track-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subsonic-response": { "status": "ok" }
})))
.mount(&server)
.await;
let params = vec![
("u".to_string(), "user".to_string()),
("id".to_string(), "track-1".to_string()),
];
let body = test_client(&server.uri())
.send_raw("savePlayQueue.view", &params, true)
.await
.expect("form-post raw request must match the body matcher");
assert!(body.contains("\"status\": \"ok\"") || body.contains("\"status\":\"ok\""));
}
#[tokio::test(flavor = "multi_thread")]
async fn ping_surfaces_wrong_credentials_as_code_40() {
let server = MockServer::start().await;
+57 -10
View File
@@ -90,14 +90,15 @@ async fn fetch_cover_once(
registry: Option<&ServerHttpRegistry>,
server_ref: Option<&str>,
) -> FetchAttempt {
let mut req = client.get(url);
if let Some(reg) = registry {
if let Some(sid) = server_ref.filter(|s| !s.is_empty()) {
req = reg.apply_for_http_url(sid, url, req);
} else if let Some(ctx) = reg.get_for_server_url(url) {
req = psysonic_core::server_http::apply_server_headers_for_http_url(req, &ctx, url);
}
}
// Single gate-header application point — resolves by `server_ref` first,
// then falls back to matching the request URL against a registered gated
// endpoint. No match (non-gated server) leaves the request untouched.
let req = psysonic_core::server_http::apply_optional_registry_headers(
registry,
server_ref,
url,
client.get(url),
);
let resp = match req.send().await {
Ok(r) => r,
// Connection reset / timeout / DNS — transient under server load.
@@ -111,13 +112,36 @@ async fn fetch_cover_once(
};
}
let msg = format!("cover HTTP {status}");
if status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS {
if cover_http_status_is_transient(status) {
FetchAttempt::Transient(msg)
} else {
FetchAttempt::Permanent(msg)
}
}
/// A cover HTTP status worth retrying — i.e. NOT a permanent "cover missing".
///
/// A `4xx` normally means the art is genuinely absent (`404`/`410`) or the
/// request was malformed (`400`) — permanent, so we never hammer the server for
/// it. The exceptions are gate / throttle statuses: behind a Cloudflare Access
/// or Pangolin gate a `401`/`403` means the gate refused a request whose token
/// wasn't applied (e.g. the per-server header registry hadn't been populated yet
/// at startup), which recovers the moment the header lands. Treating those as
/// transient keeps the short retry loop alive across a brief registry gap
/// instead of writing a 30-minute `.fetch-failed` marker for art that is really
/// there.
fn cover_http_status_is_transient(status: reqwest::StatusCode) -> bool {
status.is_server_error()
|| matches!(
status,
reqwest::StatusCode::UNAUTHORIZED
| reqwest::StatusCode::FORBIDDEN
| reqwest::StatusCode::REQUEST_TIMEOUT
| reqwest::StatusCode::TOO_EARLY
| reqwest::StatusCode::TOO_MANY_REQUESTS
)
}
pub async fn fetch_cover_bytes(
client: &Client,
url: &str,
@@ -145,7 +169,30 @@ pub async fn fetch_cover_bytes(
#[cfg(test)]
mod tests {
use super::build_cover_art_url;
use super::{build_cover_art_url, cover_http_status_is_transient};
use reqwest::StatusCode;
#[test]
fn gate_and_throttle_statuses_are_transient() {
// Behind an auth gate a 401/403 is a token/header hiccup that recovers
// once the per-server header lands — not a missing cover.
assert!(cover_http_status_is_transient(StatusCode::UNAUTHORIZED));
assert!(cover_http_status_is_transient(StatusCode::FORBIDDEN));
assert!(cover_http_status_is_transient(StatusCode::REQUEST_TIMEOUT));
assert!(cover_http_status_is_transient(StatusCode::TOO_EARLY));
assert!(cover_http_status_is_transient(StatusCode::TOO_MANY_REQUESTS));
assert!(cover_http_status_is_transient(StatusCode::BAD_GATEWAY));
assert!(cover_http_status_is_transient(StatusCode::SERVICE_UNAVAILABLE));
}
#[test]
fn genuine_missing_or_bad_request_is_permanent() {
// These mean the art really is absent / the request is wrong — marking
// them failed avoids hammering the server for art that isn't there.
assert!(!cover_http_status_is_transient(StatusCode::NOT_FOUND));
assert!(!cover_http_status_is_transient(StatusCode::GONE));
assert!(!cover_http_status_is_transient(StatusCode::BAD_REQUEST));
}
#[test]
fn cover_url_from_host_root() {
+4
View File
@@ -271,6 +271,8 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
crate::lib_commands::app_api::migration::migration_inspect,
crate::lib_commands::app_api::migration::migration_run,
crate::lib_commands::app_api::network::resolve_host_addresses,
crate::lib_commands::app_api::network::probe_server_connection,
crate::lib_commands::app_api::network::subsonic_proxy_request,
crate::lib_commands::app_api::network::server_http_context_clear,
crate::lib_commands::app_api::network::server_http_context_sync,
crate::lib_commands::app_api::network::server_http_context_sync_all,
@@ -932,6 +934,8 @@ pub fn run() {
migration_inspect,
migration_run,
resolve_host_addresses,
probe_server_connection,
subsonic_proxy_request,
server_http_context_sync,
server_http_context_sync_all,
server_http_context_clear,
+2 -2
View File
@@ -39,8 +39,8 @@ pub(crate) use integration::{
};
pub(crate) use migration::{migration_inspect, migration_run};
pub(crate) use network::{
resolve_host_addresses, server_http_context_clear, server_http_context_sync,
server_http_context_sync_all,
probe_server_connection, resolve_host_addresses, server_http_context_clear,
server_http_context_sync, server_http_context_sync_all, subsonic_proxy_request,
};
// Discord, Navidrome admin, last.fm + radio-browser + CORS proxy, bandsintown,
+259 -2
View File
@@ -5,10 +5,15 @@ use std::collections::HashSet;
use std::sync::Arc;
use psysonic_core::server_http::{ServerHttpContextSyncWire, ServerHttpRegistry};
use psysonic_core::server_http::{ServerHttpContext, ServerHttpContextSyncWire, ServerHttpRegistry};
use psysonic_integration::subsonic::{ServerInfo, SubsonicClient, SubsonicError};
use serde::{Deserialize, Serialize};
use tauri::State;
use tokio::net::lookup_host;
/// Connect-probe timeout — mirrors the WebView axios ping (`subsonic.ts`).
const PROBE_TIMEOUT_SECS: u64 = 15;
/// Resolve a hostname to a deduped list of IP address strings (IPv4 + IPv6).
///
/// Strips a `host:port` suffix before lookup — the form only knows the host.
@@ -95,6 +100,202 @@ pub(crate) fn server_http_context_clear(
Ok(())
}
/// Result of a connect probe — same shape the WebView `pingWithCredentials`
/// path returns (`PingWithCredentialsResult` on the TS side).
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
pub struct ServerProbeResult {
/// `true` when the server answered `ping` with `status="ok"`.
pub ok: bool,
/// Server software family (`navidrome`, …) when advertised.
#[serde(rename = "type")]
pub server_type: Option<String>,
/// Server build version when advertised.
#[serde(rename = "serverVersion")]
pub server_version: Option<String>,
/// Whether the server advertises OpenSubsonic extensions.
#[serde(rename = "openSubsonic")]
pub open_subsonic: bool,
/// 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.
pub error: Option<String>,
}
impl ServerProbeResult {
fn from_info(info: ServerInfo) -> Self {
Self {
ok: true,
server_type: info.server_type,
server_version: info.server_version,
open_subsonic: info.open_subsonic,
error: None,
}
}
fn from_error(err: &SubsonicError) -> Self {
Self {
ok: false,
server_type: None,
server_version: None,
open_subsonic: false,
error: Some(probe_failure_reason(err)),
}
}
}
/// Render a compact, user-facing reason from a probe failure. Subsonic API
/// errors surface the server's own message (e.g. "Wrong username or password");
/// HTTP/transport failures surface the status or flattened transport text. No
/// secrets are ever part of a `SubsonicError`, so this is safe to show + log.
fn probe_failure_reason(err: &SubsonicError) -> String {
match err {
SubsonicError::Api { code, message } => {
let msg = message.trim();
if msg.is_empty() {
format!("server error {code}")
} else {
msg.to_string()
}
}
SubsonicError::HttpStatus(status) => match status.canonical_reason() {
Some(reason) => format!("HTTP {} {reason}", status.as_u16()),
None => format!("HTTP {}", status.as_u16()),
},
SubsonicError::Transport(m) => m.trim().to_string(),
SubsonicError::NotFound => "not found".to_string(),
SubsonicError::Decode(m) => format!("invalid response: {}", m.trim()),
}
}
fn probe_http_client() -> reqwest::Client {
reqwest::Client::builder()
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.timeout(std::time::Duration::from_secs(PROBE_TIMEOUT_SECS))
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}
/// 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.
#[tauri::command]
#[specta::specta]
pub(crate) async fn probe_server_connection(
base_url: String,
username: String,
password: String,
http_context: Option<ServerHttpContextSyncWire>,
) -> Result<ServerProbeResult, String> {
let mut client = SubsonicClient::with_http(base_url, username, password, probe_http_client());
if let Some(wire) = http_context {
client = client.with_http_context(ServerHttpContext::from(wire));
}
match client.server_info().await {
Ok(info) => Ok(ServerProbeResult::from_info(info)),
Err(err) => {
// Unreachable / wrong credentials / non-ok envelope: surface as
// `ok:false` plus a reason the UI can show. Header values are never
// part of `err`, so this is safe to log for diagnostics — the
// gated-server case used to leave no trace at all.
crate::app_deprintln!("[connect-probe] ping failed: {err}");
Ok(ServerProbeResult::from_error(&err))
}
}
}
/// Upper bound on a proxied WebView request so a hung gate can't wedge a
/// command worker forever. The frontend passes its own per-call timeout; we
/// clamp to this ceiling.
const PROXY_MAX_TIMEOUT_SECS: u64 = 120;
/// Proxied requests reuse a small pool of `reqwest::Client`s keyed by their
/// (clamped) timeout in whole seconds. A `reqwest::Client` clone shares the
/// underlying connection pool, so pooling by timeout bucket keeps HTTP
/// keep-alive across the many concurrent browse calls a gated server routes
/// here — rather than opening (and tearing down) a fresh pool per request,
/// which under load starved connections and surfaced as spurious timeouts /
/// `499`s on otherwise-fast endpoints (`getAlbumList2`, `getAlbum`, `ping`).
fn proxy_http_client(timeout_ms: Option<u32>) -> reqwest::Client {
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
let secs = timeout_ms
.map(|ms| u64::from(ms).div_ceil(1000))
.filter(|s| *s > 0)
.unwrap_or(PROBE_TIMEOUT_SECS)
.min(PROXY_MAX_TIMEOUT_SECS);
static POOL: OnceLock<Mutex<HashMap<u64, reqwest::Client>>> = OnceLock::new();
let pool = POOL.get_or_init(|| Mutex::new(HashMap::new()));
let mut guard = pool.lock().unwrap();
guard
.entry(secs)
.or_insert_with(|| {
reqwest::Client::builder()
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.timeout(std::time::Duration::from_secs(secs))
.build()
.unwrap_or_else(|_| reqwest::Client::new())
})
.clone()
}
/// 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.
#[tauri::command]
#[specta::specta]
pub(crate) async fn subsonic_proxy_request(
base_url: String,
endpoint: String,
params: Vec<(String, String)>,
post_form: bool,
timeout_ms: Option<u32>,
http_context: Option<ServerHttpContextSyncWire>,
) -> Result<String, String> {
let mut client = SubsonicClient::with_http(
base_url,
String::new(),
String::new(),
proxy_http_client(timeout_ms),
);
if let Some(wire) = http_context {
client = client.with_http_context(ServerHttpContext::from(wire));
}
client
.send_raw(&endpoint, &params, post_form)
.await
.map_err(|err| {
// Header values / password are never part of `err`, so logging the
// reason is safe and gives gated-server requests a diagnostic trail.
crate::app_deprintln!("[subsonic-proxy] {endpoint} failed: {err}");
probe_failure_reason(&err)
})
}
/// Strip a `:port` suffix. Handles `host:port` and `[ipv6]:port`; leaves
/// bracketed IPv6 with no port (`[::1]`) and bare hosts alone.
fn strip_port(input: &str) -> String {
@@ -120,7 +321,63 @@ fn strip_port(input: &str) -> String {
#[cfg(test)]
mod tests {
use super::strip_port;
use super::{probe_failure_reason, strip_port, ServerProbeResult};
use psysonic_integration::subsonic::{ServerInfo, SubsonicError};
#[test]
fn probe_result_from_info_carries_metadata_and_ok() {
let info = ServerInfo {
server_type: Some("navidrome".into()),
server_version: Some("0.62.0".into()),
api_version: Some("1.16.1".into()),
open_subsonic: true,
};
let r = ServerProbeResult::from_info(info);
assert!(r.ok);
assert_eq!(r.server_type.as_deref(), Some("navidrome"));
assert_eq!(r.server_version.as_deref(), Some("0.62.0"));
assert!(r.open_subsonic);
assert!(r.error.is_none());
}
#[test]
fn probe_result_from_error_is_not_ok_and_carries_reason() {
let r = ServerProbeResult::from_error(&SubsonicError::Api {
code: 40,
message: "Wrong username or password".into(),
});
assert!(!r.ok);
assert!(r.server_type.is_none());
assert!(r.server_version.is_none());
assert!(!r.open_subsonic);
assert_eq!(r.error.as_deref(), Some("Wrong username or password"));
}
#[test]
fn probe_failure_reason_prefers_server_message() {
let reason = probe_failure_reason(&SubsonicError::Api {
code: 10,
message: "missing parameter: 'u'".into(),
});
assert_eq!(reason, "missing parameter: 'u'");
}
#[test]
fn probe_failure_reason_falls_back_to_code_when_message_blank() {
let reason = probe_failure_reason(&SubsonicError::Api {
code: 40,
message: " ".into(),
});
assert_eq!(reason, "server error 40");
}
#[test]
fn probe_failure_reason_renders_http_status() {
let reason = probe_failure_reason(&SubsonicError::HttpStatus(
reqwest::StatusCode::FORBIDDEN,
));
assert_eq!(reason, "HTTP 403 Forbidden");
}
#[test]
fn strips_host_port_pair() {
+1
View File
@@ -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',
);
}
};
+68
View File
@@ -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,
+188 -10
View File
@@ -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
View File
@@ -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 };
}
}
+90 -1
View File
@@ -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}`, {
+25 -5
View File
@@ -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' },
]);
});
});
+5 -1
View File
@@ -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;
+28
View File
@@ -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.
+22
View File
@@ -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).
+47 -2
View File
@@ -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;
+55
View File
@@ -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', () => {
+29 -1
View File
@@ -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)
? [
+27
View File
@@ -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<
+3
View File
@@ -76,6 +76,9 @@ export const settings = {
serverConnecting: 'Свързване…',
serverConnected: 'Връзката е установена!',
serverFailed: 'Връзката се провали.',
serverUrlRequired: 'Адресът на сървъра е задължителен.',
serverUsernameRequired: 'Потребителското име е задължително.',
serverConnectFailedReason: 'Връзката е неуспешна: {{reason}}',
testBtn: 'Тествай връзката',
testingBtn: 'Тестване…',
serverCompatible: 'Създаден за Navidrome. Други съвместими със Subsonic сървъри (Gonic, Airsonic и др.) може да работят с ограничена функционалност, тъй като Psysonic използва много специфични за Navidrome API крайни точки.',
+3
View File
@@ -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.',
+3
View File
@@ -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.',
+3
View File
@@ -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.',
+3
View File
@@ -75,6 +75,9 @@ export const settings = {
serverConnecting: 'Connexion…',
serverConnected: 'Connecté !',
serverFailed: 'Connexion échouée.',
serverUrlRequired: 'Ladresse du serveur est requise.',
serverUsernameRequired: 'Le nom dutilisateur 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 dAPI spécifiques à Navidrome.',
+3
View File
@@ -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.',
+3
View File
@@ -76,6 +76,9 @@ export const settings = {
serverConnecting: 'Connessione in corso…',
serverConnected: 'Connesso!',
serverFailed: 'Connessione non riuscita.',
serverUrlRequired: 'Lindirizzo 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.',
+3
View File
@@ -76,6 +76,9 @@ export const settings = {
serverConnecting: '接続中…',
serverConnected: '接続しました!',
serverFailed: '接続に失敗しました。',
serverUrlRequired: 'サーバーアドレスを入力してください。',
serverUsernameRequired: 'ユーザー名を入力してください。',
serverConnectFailedReason: '接続できませんでした: {{reason}}',
testBtn: '接続をテスト',
testingBtn: 'テスト中…',
serverCompatible: 'Navidrome 向けに作られています。他の Subsonic 互換サーバー (Gonic, Airsonic, …) でも動作する場合がありますが、Psysonic は多くの Navidrome 固有 API エンドポイントを使うため機能が制限されることがあります。',
+3
View File
@@ -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.',
+3
View File
@@ -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.',
+3
View File
@@ -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.',
+3
View File
@@ -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.',
+3
View File
@@ -75,6 +75,9 @@ export const settings = {
serverConnecting: 'Подключение…',
serverConnected: 'Подключено!',
serverFailed: 'Ошибка подключения.',
serverUrlRequired: 'Укажите адрес сервера.',
serverUsernameRequired: 'Укажите имя пользователя.',
serverConnectFailedReason: 'Не удалось подключиться: {{reason}}',
testBtn: 'Проверить',
testingBtn: 'Проверка…',
serverCompatible: 'Разработано для Navidrome. Другие Subsonic-совместимые серверы (Gonic, Airsonic, …) могут работать с ограничениями, так как Psysonic использует много специфичных для Navidrome API-эндпоинтов.',
+3
View File
@@ -75,6 +75,9 @@ export const settings = {
serverConnecting: '正在连接…',
serverConnected: '已连接!',
serverFailed: '连接失败。',
serverUrlRequired: '请填写服务器地址。',
serverUsernameRequired: '请填写用户名。',
serverConnectFailedReason: '无法连接:{{reason}}',
testBtn: '测试连接',
testingBtn: '正在测试…',
serverCompatible: '专为 Navidrome 构建。其他兼容 Subsonic 的服务器(Gonic、Airsonic 等)可能功能受限,因为 Psysonic 使用了许多 Navidrome 特有的 API 端点。',