Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
ClientSettings,
DesktopTheme,
DesktopAppBranding,
DesktopJsonHttpRequest,
DesktopServerExposureMode,
DesktopServerExposureState,
DesktopUpdateChannel,
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -256,6 +258,52 @@ function resolveDesktopDevServerUrl(): string {
return devServerUrl;
}

function normalizeDesktopJsonHttpRequest(
rawRequest: unknown,
): Required<Pick<DesktopJsonHttpRequest, "url" | "method">> &
Pick<DesktopJsonHttpRequest, "headers" | "body"> {
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 } : {}),
};
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
}

function backendChildEnv(): NodeJS.ProcessEnv {
const env = { ...process.env };
delete env.T3CODE_PORT;
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => {
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
43 changes: 43 additions & 0 deletions apps/web/src/environments/remote/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ beforeEach(() => {
location: {
origin: "https://app.example.com",
},
desktopBridge: undefined,
},
});
vi.restoreAllMocks();
Expand Down Expand Up @@ -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(() => {
Expand Down
49 changes: 35 additions & 14 deletions apps/web/src/environments/remote/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
AuthBearerBootstrapResult,
AuthSessionState,
AuthWebSocketTokenResult,
DesktopBridge,
ExecutionEnvironmentDescriptor,
} from "@t3tools/contracts";

Expand Down Expand Up @@ -52,34 +53,54 @@ async function fetchRemoteJson<T>(input: {
readonly body?: unknown;
}): Promise<T> {
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}).`,
{ cause: error },
);
}

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: {
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/localApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,9 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg
endpointUrl: null,
advertisedHost: null,
}),
requestJsonHttp: async () => {
throw new Error("requestJsonHttp not implemented in test");
},
pickFolder: async () => null,
confirm: async () => true,
setTheme: async () => undefined,
Expand Down
14 changes: 14 additions & 0 deletions packages/contracts/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,19 @@ export interface PickFolderOptions {
initialPath?: string | null;
}

export interface DesktopJsonHttpRequest {
url: string;
method?: "GET" | "POST";
headers?: Readonly<Record<string, string>>;
body?: unknown;
}

export interface DesktopJsonHttpResponse {
status: number;
ok: boolean;
bodyText: string;
}

export interface DesktopBridge {
getAppBranding: () => DesktopAppBranding | null;
getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null;
Expand All @@ -159,6 +172,7 @@ export interface DesktopBridge {
removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise<void>;
getServerExposureState: () => Promise<DesktopServerExposureState>;
setServerExposureMode: (mode: DesktopServerExposureMode) => Promise<DesktopServerExposureState>;
requestJsonHttp: (request: DesktopJsonHttpRequest) => Promise<DesktopJsonHttpResponse>;
pickFolder: (options?: PickFolderOptions) => Promise<string | null>;
confirm: (message: string) => Promise<boolean>;
setTheme: (theme: DesktopTheme) => Promise<void>;
Expand Down
Loading