diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 987cad3408..f0ca586a45 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -23,6 +23,7 @@ import type { ClientSettings, DesktopTheme, DesktopAppBranding, + DesktopJsonHttpRequest, DesktopServerExposureMode, DesktopServerExposureState, DesktopUpdateChannel, @@ -99,6 +100,7 @@ const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secr const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; +const REQUEST_JSON_HTTP_CHANNEL = "desktop:request-json-http"; const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); const STATE_DIR = Path.join(BASE_DIR, "userdata"); const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); @@ -256,6 +258,52 @@ function resolveDesktopDevServerUrl(): string { return devServerUrl; } +function normalizeDesktopJsonHttpRequest( + rawRequest: unknown, +): Required> & + Pick { + if (typeof rawRequest !== "object" || rawRequest === null) { + throw new Error("Invalid desktop HTTP request payload."); + } + + const { url, method, headers, body } = rawRequest as DesktopJsonHttpRequest; + if (typeof url !== "string" || url.trim().length === 0) { + throw new Error("Invalid desktop HTTP request URL."); + } + + const parsedUrl = new URL(url); + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + throw new Error("Desktop HTTP requests only support http and https URLs."); + } + + if (method !== undefined && method !== "GET" && method !== "POST") { + throw new Error("Desktop HTTP requests only support GET and POST."); + } + + if (body !== undefined && (method ?? "GET") !== "POST") { + throw new Error("Desktop HTTP request body is only allowed with POST method."); + } + + if (headers !== undefined) { + if (typeof headers !== "object" || headers === null || Array.isArray(headers)) { + throw new Error("Invalid desktop HTTP request headers."); + } + + for (const [headerName, headerValue] of Object.entries(headers)) { + if (typeof headerValue !== "string") { + throw new Error(`Invalid desktop HTTP request header '${headerName}'.`); + } + } + } + + return { + url: parsedUrl.toString(), + method: method ?? "GET", + ...(headers !== undefined ? { headers } : {}), + ...(body !== undefined ? { body } : {}), + }; +} + function backendChildEnv(): NodeJS.ProcessEnv { const env = { ...process.env }; delete env.T3CODE_PORT; @@ -1666,6 +1714,22 @@ function registerIpcHandlers(): void { return nextState; }); + ipcMain.removeHandler(REQUEST_JSON_HTTP_CHANNEL); + ipcMain.handle(REQUEST_JSON_HTTP_CHANNEL, async (_event, rawRequest: unknown) => { + const request = normalizeDesktopJsonHttpRequest(rawRequest); + const response = await fetch(request.url, { + method: request.method, + ...(request.headers !== undefined ? { headers: request.headers } : {}), + ...(request.body !== undefined ? { body: JSON.stringify(request.body) } : {}), + }); + const bodyText = await response.text(); + return { + status: response.status, + ok: response.ok, + bodyText, + }; + }); + ipcMain.removeHandler(PICK_FOLDER_CHANNEL); ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => { const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index a675604872..60fe092296 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -24,6 +24,7 @@ const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secr const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; +const REQUEST_JSON_HTTP_CHANNEL = "desktop:request-json-http"; contextBridge.exposeInMainWorld("desktopBridge", { getAppBranding: () => { @@ -53,6 +54,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL), setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), + requestJsonHttp: (request) => ipcRenderer.invoke(REQUEST_JSON_HTTP_CHANNEL, request), pickFolder: (options) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 4381215464..f87a08ad70 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -307,6 +307,7 @@ const createDesktopBridgeStub = (overrides?: { endpointUrl: mode === "network-accessible" ? "http://192.168.1.44:3773" : null, advertisedHost: mode === "network-accessible" ? "192.168.1.44" : null, })), + requestJsonHttp: vi.fn().mockRejectedValue(new Error("requestJsonHttp not implemented")), pickFolder: vi.fn().mockResolvedValue(null), confirm: vi.fn().mockResolvedValue(false), setTheme: vi.fn().mockResolvedValue(undefined), diff --git a/apps/web/src/environments/remote/api.test.ts b/apps/web/src/environments/remote/api.test.ts index bbff762412..eaaf4adcc7 100644 --- a/apps/web/src/environments/remote/api.test.ts +++ b/apps/web/src/environments/remote/api.test.ts @@ -18,6 +18,7 @@ beforeEach(() => { location: { origin: "https://app.example.com", }, + desktopBridge: undefined, }, }); vi.restoreAllMocks(); @@ -228,6 +229,48 @@ describe("remote environment api", () => { }), ).resolves.toBe("wss://remote.example.com/?wsToken=ws-token"); }); + + it("uses the desktop bridge for remote descriptor requests inside Electron", async () => { + const requestJsonHttp = vi.fn().mockResolvedValue({ + status: 200, + ok: true, + bodyText: JSON.stringify({ + environmentId: "environment-remote", + label: "Remote environment", + platform: { + os: "linux", + arch: "x64", + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, + }), + }); + const fetchMock = vi.fn(); + globalThis.fetch = fetchMock as typeof fetch; + Object.assign(window, { + desktopBridge: { + requestJsonHttp, + }, + }); + + await expect( + fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: "https://remote.example.com/", + }), + ).resolves.toMatchObject({ + environmentId: "environment-remote", + label: "Remote environment", + }); + + expect(requestJsonHttp).toHaveBeenCalledWith({ + url: "https://remote.example.com/.well-known/t3/environment", + method: "GET", + headers: {}, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); }); afterEach(() => { diff --git a/apps/web/src/environments/remote/api.ts b/apps/web/src/environments/remote/api.ts index 50b593975d..cb3af57a94 100644 --- a/apps/web/src/environments/remote/api.ts +++ b/apps/web/src/environments/remote/api.ts @@ -2,6 +2,7 @@ import type { AuthBearerBootstrapResult, AuthSessionState, AuthWebSocketTokenResult, + DesktopBridge, ExecutionEnvironmentDescriptor, } from "@t3tools/contracts"; @@ -52,16 +53,36 @@ async function fetchRemoteJson(input: { readonly body?: unknown; }): Promise { const requestUrl = remoteEndpointUrl(input.httpBaseUrl, input.pathname); - let response: Response; + const headers = { + ...(input.body !== undefined ? { "content-type": "application/json" } : {}), + ...(input.bearerToken ? { authorization: `Bearer ${input.bearerToken}` } : {}), + }; + let responseBodyText: string; + let responseOk: boolean; + let responseStatus: number; try { - response = await fetch(requestUrl, { - method: input.method ?? "GET", - headers: { - ...(input.body !== undefined ? { "content-type": "application/json" } : {}), - ...(input.bearerToken ? { authorization: `Bearer ${input.bearerToken}` } : {}), - }, - ...(input.body !== undefined ? { body: JSON.stringify(input.body) } : {}), - }); + const desktopBridge: DesktopBridge | undefined = + typeof window !== "undefined" ? window.desktopBridge : undefined; + if (desktopBridge?.requestJsonHttp) { + const response = await desktopBridge.requestJsonHttp({ + url: requestUrl, + method: input.method ?? "GET", + headers, + ...(input.body !== undefined ? { body: input.body } : {}), + }); + responseBodyText = response.bodyText; + responseOk = response.ok; + responseStatus = response.status; + } else { + const response = await fetch(requestUrl, { + method: input.method ?? "GET", + headers, + ...(input.body !== undefined ? { body: JSON.stringify(input.body) } : {}), + }); + responseBodyText = await response.text(); + responseOk = response.ok; + responseStatus = response.status; + } } catch (error) { throw new Error( `Failed to fetch remote auth endpoint ${requestUrl} (${(error as Error).message}).`, @@ -69,17 +90,17 @@ async function fetchRemoteJson(input: { ); } - if (!response.ok) { + if (!responseOk) { throw new RemoteEnvironmentAuthHttpError( await readRemoteAuthErrorMessage( - response, - `Remote auth request failed (${response.status}).`, + new Response(responseBodyText, { status: responseStatus }), + `Remote auth request failed (${responseStatus}).`, ), - response.status, + responseStatus, ); } - return (await response.json()) as T; + return JSON.parse(responseBodyText) as T; } export async function bootstrapRemoteBearerSession(input: { diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 06b163137b..0ce9374267 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -179,6 +179,9 @@ function makeDesktopBridge(overrides: Partial = {}): DesktopBridg endpointUrl: null, advertisedHost: null, }), + requestJsonHttp: async () => { + throw new Error("requestJsonHttp not implemented in test"); + }, pickFolder: async () => null, confirm: async () => true, setTheme: async () => undefined, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index c2d6813301..8d6f158e2f 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -145,6 +145,19 @@ export interface PickFolderOptions { initialPath?: string | null; } +export interface DesktopJsonHttpRequest { + url: string; + method?: "GET" | "POST"; + headers?: Readonly>; + body?: unknown; +} + +export interface DesktopJsonHttpResponse { + status: number; + ok: boolean; + bodyText: string; +} + export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; @@ -159,6 +172,7 @@ export interface DesktopBridge { removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; getServerExposureState: () => Promise; setServerExposureMode: (mode: DesktopServerExposureMode) => Promise; + requestJsonHttp: (request: DesktopJsonHttpRequest) => Promise; pickFolder: (options?: PickFolderOptions) => Promise; confirm: (message: string) => Promise; setTheme: (theme: DesktopTheme) => Promise;