diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts index e09d7c279..da048ea3f 100644 --- a/src/lib/fetch.ts +++ b/src/lib/fetch.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { isTauri } from '@/lib/platform' +import { getCapabilities, isTauri } from '@/lib/platform' import { getLocalSetting } from '@/stores/local-settings-store' /** @@ -17,8 +17,15 @@ export const fetch = async (input: RequestInfo | URL, init?: RequestInit): Promi } if (getLocalSetting('isNativeFetchEnabled')) { - const { fetch: tauriFetch } = await import('@tauri-apps/plugin-http') - return tauriFetch(input, init) + // The "Use Native Fetch" dev setting can only be toggled on in builds + // compiled with `--features native_fetch`; guard against stale `true` + // values from prior builds so we don't invoke an unregistered plugin + // ("plugin http not found"). + const { native_fetch: nativeFetchAvailable } = await getCapabilities() + if (nativeFetchAvailable) { + const { fetch: tauriFetch } = await import('@tauri-apps/plugin-http') + return tauriFetch(input, init) + } } return globalThis.fetch(input, init) diff --git a/src/lib/proxy-fetch.test.ts b/src/lib/proxy-fetch.test.ts index 0a1c39f79..d3656de1f 100644 --- a/src/lib/proxy-fetch.test.ts +++ b/src/lib/proxy-fetch.test.ts @@ -67,7 +67,7 @@ describe('createProxyFetch — Hosted mode', () => { }) describe('createProxyFetch — Standalone (Tauri) mode', () => { - it('calls Tauri fetch directly without rewriting headers when toggle is off (default)', async () => { + it('calls Tauri fetch directly without rewriting headers when toggle is off and native_fetch is available', async () => { const tauriFetchMock = mock(async () => new Response('tauri-direct', { status: 200 })) const proxyFetch = createProxyFetch({ @@ -75,6 +75,7 @@ describe('createProxyFetch — Standalone (Tauri) mode', () => { isStandalone: () => true, tauriFetch: tauriFetchMock as unknown as typeof fetch, getProxyEnabled: () => false, + getNativeFetchCapability: () => Promise.resolve(true), }) await proxyFetch('https://example.com/api', { @@ -88,6 +89,28 @@ describe('createProxyFetch — Standalone (Tauri) mode', () => { const h = new Headers(calledInit.headers) expect(h.get('authorization')).toBe('Bearer abc') }) + + it('falls back to the hosted proxy when native_fetch is not compiled into the build', async () => { + const tauriFetchMock = mock(async () => new Response('should-not-be-called', { status: 500 })) + const hostedFetchMock = mock(async () => new Response('hosted', { status: 200 })) + + const proxyFetch = createProxyFetch({ + cloudUrl: 'http://localhost:8000/v1', + isStandalone: () => true, + tauriFetch: tauriFetchMock as unknown as typeof fetch, + fetchImpl: hostedFetchMock as unknown as typeof fetch, + getProxyEnabled: () => false, + getNativeFetchCapability: () => Promise.resolve(false), + }) + + await proxyFetch('https://example.com/api', { method: 'GET' }) + + expect(tauriFetchMock).toHaveBeenCalledTimes(0) + expect(hostedFetchMock).toHaveBeenCalledTimes(1) + const [hostedReq] = hostedFetchMock.mock.calls[0] as unknown as [Request] + expect(hostedReq.url).toBe('http://localhost:8000/v1/proxy') + expect(hostedReq.headers.get('x-proxy-target-url')).toBe('https://example.com/api') + }) }) describe('createProxyFetch — proxy_enabled toggle', () => { diff --git a/src/lib/proxy-fetch.ts b/src/lib/proxy-fetch.ts index aadd56fd8..eb8d5ee98 100644 --- a/src/lib/proxy-fetch.ts +++ b/src/lib/proxy-fetch.ts @@ -27,7 +27,7 @@ import { } from '@shared/proxy-protocol' import { encodeWsBearer, wsBearerSubprotocolPrefix, wsCarrierSubprotocol } from '@shared/ws-bearer' import { getAuthToken } from './auth-token' -import { isTauri } from './platform' +import { getCapabilities, isTauri } from './platform' const defaultIsStandalone = isTauri const defaultReadProxyEnabled = (): string | null => @@ -157,16 +157,30 @@ export type ProxyFetchOptions = { * in `src/ai/fetch.ts`) pass a getter that reads the `proxy_enabled` localStorage * key + derives the effective value per platform. */ getProxyEnabled?: () => boolean + /** Whether `tauri-plugin-http` is registered in the current build. Defaults + * to `getCapabilities()` (memoised) — tests inject a stub. When false, the + * Tauri-direct path is unreachable (the plugin's JS shim would throw + * "plugin http not found"), so we fall through to the hosted proxy even on + * Tauri with `proxy_enabled=false`. Universal Proxy (THU-467) is the + * intended path; this guard makes the toggle a no-op in builds that ship + * without the native fetch capability. */ + getNativeFetchCapability?: () => Promise } /** Build a fetch implementation that hides Hosted/Standalone mode from callers. */ export const createProxyFetch = (options: ProxyFetchOptions): FetchFn => { const proxyUrl = `${options.cloudUrl.replace(/\/$/, '')}/proxy` + const readNativeFetchCapability = + options.getNativeFetchCapability ?? (() => getCapabilities().then((c) => c.native_fetch)) const proxyFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { const standalone = (options.isStandalone ?? isTauri)() const proxyEnabled = options.getProxyEnabled?.() ?? true - if (standalone && !proxyEnabled) { - // Standalone + toggle off: hit the upstream directly through Tauri's HTTP plugin. + if (standalone && !proxyEnabled && (await readNativeFetchCapability())) { + // Standalone + toggle off + plugin compiled in: hit the upstream directly + // through Tauri's HTTP plugin. Builds without `--features native_fetch` + // fall through to the hosted proxy (THU-467 Universal Proxy is the + // canonical path; the Tauri-direct branch is opt-in for builds that + // want to keep the user's IP off our backend). const tFetch = options.tauriFetch ?? (tauriFetch as unknown as typeof fetch) return tFetch(input as RequestInfo, init ?? {}) as unknown as Response }