mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(desktop): connect to self-hosted runtime over LAN http on macOS (#3965)
This commit is contained in:
@@ -11,6 +11,11 @@ allow = [
|
||||
# =========================
|
||||
"core_rpc_url",
|
||||
"core_rpc_token",
|
||||
# `relay_http_rpc` proxies a JSON-RPC POST through the Rust host so the
|
||||
# secure webview can reach a self-hosted runtime on a cleartext LAN IP
|
||||
# without tripping mixed-content/CORS (#3865). Without this allow entry the
|
||||
# invoke is rejected with "Command not allowed by ACL".
|
||||
"relay_http_rpc",
|
||||
# `start_core_process` is invoked by BootCheckGate after the user picks
|
||||
# Local mode, before redux-persist hydrates the rest of the app (#1316).
|
||||
# Without this allow entry the invoke is rejected with "Command not
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! Shared helpers for authenticated calls from the Tauri host to the local core RPC.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::RequestBuilder;
|
||||
use serde::Serialize;
|
||||
|
||||
const CORE_RPC_URL_ENV: &str = "OPENHUMAN_CORE_RPC_URL";
|
||||
pub(crate) fn core_rpc_url_value() -> String {
|
||||
@@ -17,3 +20,125 @@ pub(crate) fn apply_auth(builder: RequestBuilder) -> Result<RequestBuilder, Stri
|
||||
.ok_or_else(|| "core RPC token is not initialized".to_string())?;
|
||||
Ok(builder.header("Authorization", format!("Bearer {token}")))
|
||||
}
|
||||
|
||||
/// Verbatim status + body from an upstream runtime, mirrored back to the
|
||||
/// renderer so it can reuse its existing JSON-RPC envelope parsing.
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct RelayHttpResponse {
|
||||
pub status: u16,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
/// Normalize an optional bearer token into the header value to send, if any.
|
||||
/// A `None` or blank/whitespace token yields `None` so we never emit an empty
|
||||
/// `Authorization` header (local OpenAI-compatible runtimes that need no auth
|
||||
/// would otherwise see a malformed bearer).
|
||||
fn relay_bearer_header(token: Option<&str>) -> Option<String> {
|
||||
token
|
||||
.map(str::trim)
|
||||
.filter(|t| !t.is_empty())
|
||||
.map(|t| format!("Bearer {t}"))
|
||||
}
|
||||
|
||||
/// Redact a relay URL before it lands in a log line or error string: drop the
|
||||
/// query, fragment, and any userinfo (which can carry tokens/credentials),
|
||||
/// keeping just `scheme://host[:port]/path` so transport diagnostics stay
|
||||
/// useful without persisting secrets. Falls back to a coarse sentinel when the
|
||||
/// URL can't be parsed.
|
||||
fn redact_url_for_log(url: &str) -> String {
|
||||
url.parse::<url::Url>()
|
||||
.map(|mut parsed| {
|
||||
parsed.set_query(None);
|
||||
parsed.set_fragment(None);
|
||||
let _ = parsed.set_username("");
|
||||
let _ = parsed.set_password(None);
|
||||
parsed.to_string()
|
||||
})
|
||||
.unwrap_or_else(|_| "<invalid relay url>".to_string())
|
||||
}
|
||||
|
||||
/// POST a JSON-RPC body to an arbitrary self-hosted runtime URL from the Rust
|
||||
/// host instead of the webview.
|
||||
///
|
||||
/// Why this exists (#3865): the desktop webview origin is `tauri://localhost`,
|
||||
/// a *secure context*. Chromium treats `http://127.0.0.1` / `localhost` as
|
||||
/// "potentially trustworthy", so browser `fetch()` to the embedded local core
|
||||
/// works — but a self-hosted runtime on a LAN IP (e.g.
|
||||
/// `http://192.168.1.74:7788`) is plain cleartext from a secure context, so the
|
||||
/// fetch is blocked as mixed content before any request leaves the browser
|
||||
/// ("Failed to fetch", and the runtime never logs a `/rpc` hit) even though the
|
||||
/// endpoint is healthy and reachable from curl/Safari. Issuing the request from
|
||||
/// the Rust host with `reqwest` bypasses the webview's mixed-content / CORS
|
||||
/// restrictions entirely — the same way the shell already talks to the local
|
||||
/// core.
|
||||
///
|
||||
/// Returns the upstream status + body verbatim (including JSON-RPC error
|
||||
/// envelopes and any 4xx/5xx) so the renderer keeps its existing handling; only
|
||||
/// transport-level failures (DNS, connect, timeout) surface as `Err`.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn relay_http_rpc(
|
||||
url: String,
|
||||
token: Option<String>,
|
||||
body: String,
|
||||
) -> Result<RelayHttpResponse, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build HTTP client: {e}"))?;
|
||||
|
||||
let mut builder = client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body);
|
||||
|
||||
let bearer = relay_bearer_header(token.as_deref());
|
||||
if let Some(value) = bearer.as_deref() {
|
||||
builder = builder.header("Authorization", value);
|
||||
}
|
||||
|
||||
let safe_url = redact_url_for_log(&url);
|
||||
log::debug!(
|
||||
"[core_rpc][relay] POST {safe_url} (auth={})",
|
||||
bearer.is_some()
|
||||
);
|
||||
|
||||
let resp = builder
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("request to {safe_url} failed: {e}"))?;
|
||||
let status = resp.status().as_u16();
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("failed to read response body from {safe_url}: {e}"))?;
|
||||
log::debug!(
|
||||
"[core_rpc][relay] ← {safe_url} status={status} body_len={}",
|
||||
text.len()
|
||||
);
|
||||
Ok(RelayHttpResponse { status, body: text })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::relay_bearer_header;
|
||||
|
||||
#[test]
|
||||
fn bearer_header_present_for_real_token() {
|
||||
assert_eq!(
|
||||
relay_bearer_header(Some("tok123")).as_deref(),
|
||||
Some("Bearer tok123")
|
||||
);
|
||||
// Surrounding whitespace is trimmed before formatting.
|
||||
assert_eq!(
|
||||
relay_bearer_header(Some(" tok123 ")).as_deref(),
|
||||
Some("Bearer tok123")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_header_absent_for_missing_or_blank_token() {
|
||||
assert_eq!(relay_bearer_header(None), None);
|
||||
assert_eq!(relay_bearer_header(Some("")), None);
|
||||
assert_eq!(relay_bearer_header(Some(" ")), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3525,6 +3525,10 @@ pub fn run() {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
core_rpc_url,
|
||||
core_rpc_token,
|
||||
// Host-side HTTP relay so the renderer can reach a self-hosted
|
||||
// runtime on a LAN IP that the secure `tauri://localhost` webview
|
||||
// cannot fetch directly (cleartext mixed content). See #3865.
|
||||
core_rpc::relay_http_rpc,
|
||||
overlay_parent_rpc_url,
|
||||
process_diagnostics_list_owned,
|
||||
// `mod artifact_commands;` is `#[cfg(any(target_os = "macos", target_os = "linux"))]`
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Regression tests for #3865 — connecting to a self-hosted runtime on a LAN IP
|
||||
* from the macOS desktop app.
|
||||
*
|
||||
* The desktop webview origin is the secure `tauri://localhost` context, so a
|
||||
* direct `fetch()` to a non-loopback cleartext-http runtime (e.g.
|
||||
* `http://192.168.1.74:7788/rpc`) is blocked as mixed content ("Failed to
|
||||
* fetch") before the request ever leaves the browser. The fix relays such
|
||||
* requests through the Rust host (`relay_http_rpc` Tauri command); loopback and
|
||||
* https URLs keep using the direct fetch path.
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { rpcUrlNeedsShellRelay, testCoreRpcConnection } from '../coreRpcClient';
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: vi.fn(() => true) }));
|
||||
vi.mock('../../utils/tauriCommands/common', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('../../utils/tauriCommands/common')>();
|
||||
return { ...actual, isTauri: vi.fn(() => true) };
|
||||
});
|
||||
vi.mock('../../lib/ai/localCoreAiMemory', () => ({
|
||||
dispatchLocalAiMethod: vi.fn(async () => ({ source: 'local-ai' })),
|
||||
}));
|
||||
|
||||
describe('rpcUrlNeedsShellRelay', () => {
|
||||
test('non-loopback cleartext-http URLs need the shell relay', () => {
|
||||
expect(rpcUrlNeedsShellRelay('http://192.168.1.74:7788/rpc')).toBe(true);
|
||||
expect(rpcUrlNeedsShellRelay('http://10.0.0.5:7788/rpc')).toBe(true);
|
||||
expect(rpcUrlNeedsShellRelay('http://my-nas:7788/rpc')).toBe(true);
|
||||
});
|
||||
|
||||
test('loopback http is fetchable directly (potentially trustworthy)', () => {
|
||||
expect(rpcUrlNeedsShellRelay('http://127.0.0.1:7788/rpc')).toBe(false);
|
||||
expect(rpcUrlNeedsShellRelay('http://localhost:7788/rpc')).toBe(false);
|
||||
expect(rpcUrlNeedsShellRelay('http://[::1]:7788/rpc')).toBe(false);
|
||||
expect(rpcUrlNeedsShellRelay('http://app.localhost:7788/rpc')).toBe(false);
|
||||
});
|
||||
|
||||
test('https is never mixed content, so no relay needed', () => {
|
||||
expect(rpcUrlNeedsShellRelay('https://192.168.1.74:7788/rpc')).toBe(false);
|
||||
expect(rpcUrlNeedsShellRelay('https://core.example.com/rpc')).toBe(false);
|
||||
});
|
||||
|
||||
test('malformed URLs do not trigger the relay', () => {
|
||||
expect(rpcUrlNeedsShellRelay('not a url')).toBe(false);
|
||||
expect(rpcUrlNeedsShellRelay('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('testCoreRpcConnection (self-hosted runtime, #3865)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
|
||||
test('relays through the Rust host for a non-loopback http runtime', async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
body: '{"jsonrpc":"2.0","id":1,"result":{"ok":true}}',
|
||||
});
|
||||
|
||||
const res = await testCoreRpcConnection('http://192.168.1.74:7788/rpc', 'tok123');
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('relay_http_rpc', {
|
||||
url: 'http://192.168.1.74:7788/rpc',
|
||||
token: 'tok123',
|
||||
body: expect.stringContaining('core.ping'),
|
||||
});
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.ok).toBe(true);
|
||||
const json = (await res.json()) as { result: { ok: boolean } };
|
||||
expect(json.result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test('normalizes a base URL without /rpc before relaying', async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce({ status: 200, body: '{}' });
|
||||
|
||||
await testCoreRpcConnection('http://192.168.1.74:7788', 'tok123');
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'relay_http_rpc',
|
||||
expect.objectContaining({ url: 'http://192.168.1.74:7788/rpc' })
|
||||
);
|
||||
});
|
||||
|
||||
test('uses a direct fetch for a loopback runtime (no relay)', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(
|
||||
new Response('{"jsonrpc":"2.0","id":1,"result":{"ok":true}}', { status: 200 })
|
||||
);
|
||||
|
||||
await testCoreRpcConnection('http://127.0.0.1:7788/rpc', 'tok123');
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(invoke).not.toHaveBeenCalledWith('relay_http_rpc', expect.anything());
|
||||
});
|
||||
|
||||
test('surfaces an upstream 401 verbatim through the relay', async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce({ status: 401, body: 'Unauthorized' });
|
||||
|
||||
const res = await testCoreRpcConnection('http://192.168.1.74:7788/rpc', 'bad-token');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -622,7 +622,10 @@ describe('coreRpcClient', () => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, status: 200 } as Response);
|
||||
|
||||
await testCoreRpcConnection('http://example.test:7788/rpc');
|
||||
// Trustworthy localhost http stays on the direct fetch path even in
|
||||
// Tauri (no shell relay), so the bearer header is attached here. A
|
||||
// non-trustworthy LAN host would relay instead — covered separately below.
|
||||
await testCoreRpcConnection('http://127.0.0.1:7788/rpc');
|
||||
|
||||
const requestInit = fetchMock.mock.calls[0][1] as RequestInit;
|
||||
const headers = requestInit.headers as Record<string, string>;
|
||||
@@ -643,6 +646,86 @@ describe('coreRpcClient', () => {
|
||||
expect(response).toBe(probe);
|
||||
expect(response.status).toBe(405);
|
||||
});
|
||||
|
||||
test('relays through the Rust host for non-trustworthy http URLs in Tauri (#3865)', async () => {
|
||||
vi.resetModules();
|
||||
vi.mocked(isTauri).mockReturnValue(true);
|
||||
const invokeMock = vi.mocked(invoke);
|
||||
invokeMock.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'core_rpc_token') return 'deadbeef';
|
||||
if (cmd === 'relay_http_rpc') {
|
||||
return { status: 200, body: '{"jsonrpc":"2.0","id":1,"result":{}}' };
|
||||
}
|
||||
throw new Error(`unexpected command: ${cmd}`);
|
||||
});
|
||||
const { testCoreRpcConnection } = await import('../coreRpcClient');
|
||||
|
||||
const response = await testCoreRpcConnection('http://192.168.1.50:7788/rpc');
|
||||
|
||||
// LAN http can't be fetched cross-origin from the secure tauri webview,
|
||||
// so it must be relayed through the Rust host carrying the bearer token.
|
||||
const relayCall = invokeMock.mock.calls.find(call => call[0] === 'relay_http_rpc');
|
||||
expect(relayCall).toBeDefined();
|
||||
const relayArgs = relayCall![1] as { url: string; token: string | null; body: string };
|
||||
expect(relayArgs.url).toContain('192.168.1.50');
|
||||
expect(relayArgs.token).toBe('deadbeef');
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test('rpcUrlNeedsShellRelay flags only non-trustworthy http URLs', async () => {
|
||||
vi.resetModules();
|
||||
const { rpcUrlNeedsShellRelay } = await import('../coreRpcClient');
|
||||
expect(rpcUrlNeedsShellRelay('http://192.168.1.50:7788/rpc')).toBe(true);
|
||||
expect(rpcUrlNeedsShellRelay('http://127.0.0.1:7788/rpc')).toBe(false);
|
||||
expect(rpcUrlNeedsShellRelay('http://localhost:7788/rpc')).toBe(false);
|
||||
expect(rpcUrlNeedsShellRelay('https://example.test:7788/rpc')).toBe(false);
|
||||
expect(rpcUrlNeedsShellRelay('not a url')).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects with AbortError when the relay signal is already aborted', async () => {
|
||||
vi.resetModules();
|
||||
vi.mocked(isTauri).mockReturnValue(true);
|
||||
vi.mocked(invoke).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'core_rpc_token') return 'deadbeef';
|
||||
if (cmd === 'relay_http_rpc') return { status: 200, body: '{}' };
|
||||
throw new Error(`unexpected command: ${cmd}`);
|
||||
});
|
||||
const { testCoreRpcConnection } = await import('../coreRpcClient');
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
testCoreRpcConnection('http://192.168.1.50:7788/rpc', undefined, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toThrow(/abort/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('callCoreRpc shell relay (#3865)', () => {
|
||||
test('relays via the Rust host for a non-trustworthy http core URL in Tauri', async () => {
|
||||
vi.resetModules();
|
||||
vi.mocked(isTauri).mockReturnValue(true);
|
||||
const invokeMock = vi.mocked(invoke);
|
||||
invokeMock.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'core_rpc_url') return 'http://192.168.1.50:7788/rpc';
|
||||
if (cmd === 'core_rpc_token') return 'deadbeef';
|
||||
if (cmd === 'relay_http_rpc') {
|
||||
return { status: 200, body: '{"jsonrpc":"2.0","id":1,"result":{"ok":true}}' };
|
||||
}
|
||||
throw new Error(`unexpected command: ${cmd}`);
|
||||
});
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
const { callCoreRpc } = await import('../coreRpcClient');
|
||||
|
||||
const result = await callCoreRpc<{ ok: boolean }>({ method: 'openhuman.threads_list' });
|
||||
|
||||
// A LAN http core URL must be relayed through the Rust host (with the
|
||||
// call's abort signal), never fetched cross-origin from the webview.
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(invokeMock.mock.calls.some(call => call[0] === 'relay_http_rpc')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,12 @@ import { CORE_RPC_TIMEOUT_MS, CORE_RPC_URL } from '../utils/config';
|
||||
import { getStoredCoreToken, normalizeRpcUrl, peekStoredRpcUrl } from '../utils/configPersistence';
|
||||
import { redactRpcUrlForLog } from '../utils/redactRpcUrlForLog';
|
||||
import { sanitizeError } from '../utils/sanitize';
|
||||
import { isTauri as coreIsTauri } from '../utils/tauriCommands/common';
|
||||
// The bridge-gap-aware Tauri guard: returns true only when the IPC bridge
|
||||
// (`__TAURI_INTERNALS__.invoke`) is actually wired, not merely when running
|
||||
// under Tauri — so command invokes below (incl. relay_http_rpc) never fire
|
||||
// before the bridge is usable. Prefer this over the bare @tauri-apps/api/core
|
||||
// `isTauri`, which doesn't verify bridge readiness.
|
||||
import { isTauri } from '../utils/tauriCommands/common';
|
||||
import { normalizeRpcMethod } from './rpcMethods';
|
||||
import type { CoreTransport } from './transport/CoreTransport';
|
||||
|
||||
@@ -318,7 +323,7 @@ export async function getCoreRpcUrl(): Promise<string> {
|
||||
return resolvedCoreRpcUrl;
|
||||
}
|
||||
|
||||
if (!coreIsTauri()) {
|
||||
if (!isTauri()) {
|
||||
// Web environment: respect any user-stored URL (including one that
|
||||
// happens to equal the build-time default). `peekStoredRpcUrl` returns
|
||||
// null when nothing is stored, which lets us distinguish "user hasn't
|
||||
@@ -413,7 +418,7 @@ export async function getCoreRpcToken(): Promise<string | null> {
|
||||
return resolvedCoreRpcToken;
|
||||
}
|
||||
|
||||
if (!coreIsTauri()) return null;
|
||||
if (!isTauri()) return null;
|
||||
if (resolvingCoreRpcToken) return resolvingCoreRpcToken;
|
||||
|
||||
resolvingCoreRpcToken = (async () => {
|
||||
@@ -436,6 +441,81 @@ export async function getCoreRpcToken(): Promise<string | null> {
|
||||
return resolvingCoreRpcToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts the webview can reach over cleartext http from its secure
|
||||
* `tauri://localhost` origin. Chromium treats these as "potentially
|
||||
* trustworthy" (W3C secure-contexts), so browser `fetch()` works; any other
|
||||
* host over plain http is mixed content and is blocked.
|
||||
*/
|
||||
function isPotentiallyTrustworthyHost(host: string): boolean {
|
||||
const h = host.replace(/^\[|\]$/g, '').toLowerCase();
|
||||
return h === '127.0.0.1' || h === 'localhost' || h === '::1' || h.endsWith('.localhost');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `rpcUrl` must be relayed through the Rust host instead of a direct
|
||||
* webview `fetch()`.
|
||||
*
|
||||
* The desktop webview origin is `tauri://localhost`, a secure context. Plain
|
||||
* `http://` to a non-loopback host (e.g. a self-hosted runtime at
|
||||
* `http://192.168.1.74:7788`) is active mixed content → Chromium blocks it
|
||||
* before the request leaves the browser ("Failed to fetch"), which is exactly
|
||||
* the #3865 symptom. Loopback http and any https URL are fine to fetch
|
||||
* directly, so only non-loopback http needs the shell relay.
|
||||
*/
|
||||
export function rpcUrlNeedsShellRelay(rpcUrl: string): boolean {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(rpcUrl);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (parsed.protocol !== 'http:') return false;
|
||||
return !isPotentiallyTrustworthyHost(parsed.hostname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a JSON-RPC POST via the Rust host (`relay_http_rpc` Tauri command),
|
||||
* returning a synthesized `Response` so callers reuse their existing
|
||||
* `.ok` / `.status` / `.json()` / `.text()` handling unchanged. Used for
|
||||
* self-hosted runtimes the webview cannot fetch directly (#3865). The Rust
|
||||
* client carries a 30s timeout; the optional `signal` lets the caller's own
|
||||
* timeout/abort still reject the await.
|
||||
*/
|
||||
async function relayRpcViaShell(
|
||||
rpcUrl: string,
|
||||
token: string | null,
|
||||
body: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Response> {
|
||||
const invokePromise = invoke<{ status: number; body: string }>('relay_http_rpc', {
|
||||
url: rpcUrl,
|
||||
token: token ?? null,
|
||||
body,
|
||||
}).then(
|
||||
res =>
|
||||
new Response(res.body, {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
);
|
||||
|
||||
if (!signal) return invokePromise;
|
||||
|
||||
return Promise.race([
|
||||
invokePromise,
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
return;
|
||||
}
|
||||
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe an arbitrary core RPC URL with `core.ping`. Used by the
|
||||
* Welcome page's "Test Connection" affordance to validate a user-entered
|
||||
@@ -457,16 +537,21 @@ export async function testCoreRpcConnection(
|
||||
): Promise<Response> {
|
||||
const rpcUrl = normalizeRpcUrl(url);
|
||||
const token = tokenOverride?.trim() || (await getCoreRpcToken());
|
||||
const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'core.ping', params: {} });
|
||||
|
||||
// Self-hosted runtime on a LAN IP: the secure `tauri://localhost` webview
|
||||
// cannot fetch cleartext http cross-origin (mixed content → "Failed to
|
||||
// fetch"), so relay the request through the Rust host (#3865).
|
||||
if (isTauri() && rpcUrlNeedsShellRelay(rpcUrl)) {
|
||||
coreRpcLog('[rpc] test connection relaying via shell url=%s', redactRpcUrlForLog(rpcUrl));
|
||||
return relayRpcViaShell(rpcUrl, token ?? null, body, init?.signal);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return fetch(rpcUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'core.ping', params: {} }),
|
||||
signal: init?.signal,
|
||||
});
|
||||
return fetch(rpcUrl, { method: 'POST', headers, body, signal: init?.signal });
|
||||
}
|
||||
|
||||
export async function getCoreHttpBaseUrl(): Promise<string> {
|
||||
@@ -545,7 +630,7 @@ export async function callCoreRpc<T>({
|
||||
tokenSource: getStoredCoreToken() ? 'cloud-stored' : 'local-resolved',
|
||||
});
|
||||
}
|
||||
if (coreIsTauri() && !token) {
|
||||
if (isTauri() && !token) {
|
||||
throw new Error('Core RPC token unavailable in Tauri; local RPC auth cannot be satisfied');
|
||||
}
|
||||
|
||||
@@ -563,12 +648,29 @@ export async function callCoreRpc<T>({
|
||||
const timeoutId = setTimeout(() => controller.abort(), effectiveTimeoutMs);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(rpcUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (isTauri() && rpcUrlNeedsShellRelay(rpcUrl)) {
|
||||
// Self-hosted runtime on a LAN IP — the secure `tauri://localhost`
|
||||
// webview can't fetch cleartext http cross-origin (mixed content), so
|
||||
// relay through the Rust host. Loopback (local core) and https stay on
|
||||
// the direct fetch path below. See #3865.
|
||||
coreRpcLog(
|
||||
'[rpc] relaying via shell (non-loopback http) url=%s',
|
||||
redactRpcUrlForLog(rpcUrl)
|
||||
);
|
||||
response = await relayRpcViaShell(
|
||||
rpcUrl,
|
||||
token,
|
||||
JSON.stringify(payload),
|
||||
controller.signal
|
||||
);
|
||||
} else {
|
||||
response = await fetch(rpcUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
}
|
||||
} catch (fetchErr) {
|
||||
if (controller.signal.aborted) {
|
||||
// Throw a fully-classified `CoreRpcError` here so the outer catch
|
||||
|
||||
Reference in New Issue
Block a user