Skip to content
Closed
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
4 changes: 3 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@
"electron-updater": "^6.8.9",
"ffi-rs": "1.3.2",
"playwright-core": "1.60.0",
"react-grab": "^0.1.32"
"react-grab": "^0.1.32",
"ws": "8.21.3"
},
"devDependencies": {
"@effect/vitest": "catalog:",
"@types/node": "catalog:",
"@types/ws": "8.18.1",
"acorn": "8.16.0",
"cross-env": "^10.1.0",
"electron-builder": "26.15.6",
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/electron/ElectronProtocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ describe("ElectronProtocol", () => {
assert.equal(yield* Effect.promise(() => response.text()), "ok");
assert.include(
response.headers.get("content-security-policy") ?? "",
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://clerk.t3.codes https://challenges.cloudflare.com",
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob: https://clerk.t3.codes https://challenges.cloudflare.com",
);
assert.include(
response.headers.get("content-security-policy") ?? "",
Expand Down Expand Up @@ -235,7 +235,7 @@ describe("ElectronProtocol", () => {
}).pipe(Effect.provide(protocolLayer)),
);

it("keeps executable sources host-restricted while allowing runtime network resources", () => {
it("allows authenticated installed modules without permitting arbitrary network scripts", () => {
const policy = ElectronProtocol.makeDesktopContentSecurityPolicy({
scheme: "t3code",
targetOrigin: new URL("http://127.0.0.1:3773/"),
Expand All @@ -252,6 +252,7 @@ describe("ElectronProtocol", () => {
"'self'",
"'unsafe-inline'",
"'wasm-unsafe-eval'",
"blob:",
"https://clerk.t3.codes",
"https://challenges.cloudflare.com",
]);
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/electron/ElectronProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat
"'self'",
"'unsafe-inline'",
"'wasm-unsafe-eval'",
// Installed trusted modules arrive through authenticated environment HTTP.
"blob:",
...(clerkOrigin ? [clerkOrigin] : []),
"https://challenges.cloudflare.com",
];
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
for (const previewMethod of PreviewIpc.methods) {
yield* ipc.handle(previewMethod);
}
yield* ipc.handle(PreviewIpc.frameHubEndpoint);
yield* ipc.handle(PreviewIpc.listBrowserImportSources);
yield* ipc.handle(PreviewIpc.importBrowserCookies);
});
4 changes: 4 additions & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,12 @@ export const PREVIEW_REFRESH_CHANNEL = "desktop:preview-refresh";
export const PREVIEW_ZOOM_IN_CHANNEL = "desktop:preview-zoom-in";
export const PREVIEW_ZOOM_OUT_CHANNEL = "desktop:preview-zoom-out";
export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom";
export const PREVIEW_SET_ZOOM_FACTOR_CHANNEL = "desktop:preview-set-zoom-factor";
export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload";
export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme";
export const PREVIEW_SET_AUDIO_MUTED_CHANNEL = "desktop:preview-set-audio-muted";
export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools";
export const PREVIEW_CLOSE_DEVTOOLS_CHANNEL = "desktop:preview-close-devtools";
export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies";
export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache";
export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config";
Expand All @@ -91,6 +93,7 @@ export const PREVIEW_SET_ANNOTATION_THEME_CHANNEL = "desktop:preview-set-annotat
export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element";
export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element";
export const PREVIEW_CAPTURE_SCREENSHOT_CHANNEL = "desktop:preview-capture-screenshot";
export const PREVIEW_CAPTURE_PAGE_IMAGE_CHANNEL = "desktop:preview-capture-page-image";
export const PREVIEW_REVEAL_ARTIFACT_CHANNEL = "desktop:preview-reveal-artifact";
export const PREVIEW_COPY_ARTIFACT_CHANNEL = "desktop:preview-copy-artifact";
export const PREVIEW_PICTURE_IN_PICTURE_OPEN_CHANNEL = "desktop:preview-pip-open";
Expand All @@ -104,6 +107,7 @@ export const PREVIEW_AUTOMATION_PRESS_CHANNEL = "desktop:preview-automation-pres
export const PREVIEW_AUTOMATION_SCROLL_CHANNEL = "desktop:preview-automation-scroll";
export const PREVIEW_AUTOMATION_EVALUATE_CHANNEL = "desktop:preview-automation-evaluate";
export const PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL = "desktop:preview-automation-wait-for";
export const PREVIEW_FRAME_HUB_CHANNEL = "desktop:preview-frame-hub";
export const PREVIEW_RECORDING_START_CHANNEL = "desktop:preview-recording-start";
export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop";
export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save";
Expand Down
41 changes: 41 additions & 0 deletions apps/desktop/src/ipc/methods/clientSettings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts";
import { it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { expect, vi } from "vite-plus/test";

const windows = vi.hoisted(() =>
[1, 2].map((id) => ({
isDestroyed: () => false,
webContents: { id, send: vi.fn() },
})),
);
vi.mock("electron", () => ({ BrowserWindow: { getAllWindows: () => windows } }));

import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts";
import * as DesktopSnapShot from "../../snapShot/DesktopSnapShot.ts";
import { setClientSettings } from "./clientSettings.ts";

it.effect("a save is written once and sent to no window, so none saves again in reply", () => {
const written: unknown[] = [];
const settings = {
...DEFAULT_CLIENT_SETTINGS,
browserProfiles: [{ id: "work", name: "Client work", kind: "persistent" as const }],
};
return Effect.gen(function* () {
yield* setClientSettings.handler(settings, { sender: { id: 1 } } as never);
expect(written).toEqual([settings]);
for (const window of windows) expect(window.webContents.send).not.toHaveBeenCalled();
}).pipe(
Effect.provide(
Layer.mergeAll(
Layer.succeed(DesktopClientSettings.DesktopClientSettings, {
set: (value: unknown) => Effect.sync(() => void written.push(value)),
} as unknown as DesktopClientSettings.DesktopClientSettings["Service"]),
Layer.succeed(DesktopSnapShot.DesktopSnapShot, {
configure: () => Effect.void,
} as unknown as DesktopSnapShot.DesktopSnapShot["Service"]),
),
),
);
});
41 changes: 41 additions & 0 deletions apps/desktop/src/ipc/methods/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,47 @@ describe("preview IPC methods", () => {
}).pipe(Effect.provideService(BrowserImport.BrowserImport, browserImport));
});

effectIt.effect(
"clears the guest's own partition for the native menu and the engine-host profile command",
() => {
const loaded: Array<string> = [];
const cleared: Array<ReadonlyArray<string> | undefined> = [];
const partitionOf = (scope: string, persistent?: boolean, namespace?: string) =>
`persist:${namespace ?? "env"}:${scope}:${String(persistent)}`;
const manager = PreviewManager.PreviewManager.of({
getBrowserSession: (scope: string, persistent?: boolean, namespace?: string) =>
Effect.sync(() => {
loaded.push(partitionOf(scope, persistent, namespace));
}),
getBrowserPartition: (scope: string, persistent?: boolean, namespace?: string) =>
Effect.succeed(partitionOf(scope, persistent, namespace)),
clearCache: (partitions?: ReadonlyArray<string>) =>
Effect.sync(() => {
cleared.push(partitions);
}),
} as unknown as PreviewManager.PreviewManager["Service"]);
const { scope, persistent } = PreviewIpc.resolvePartitionScope(
"env-1",
DEFAULT_BROWSER_PROFILE_ID,
);
const guestPartition = partitionOf(scope, persistent, undefined);

return Effect.gen(function* () {
// Native "Clear cache": the thread's environment and the tab's profile.
yield* PreviewIpc.clearCache.handler({
environmentId: "env-1",
profileId: DEFAULT_BROWSER_PROFILE_ID,
});
// Plugin "Clear cache": the engine host's environment and the named profile.
yield* PreviewIpc.clearCache.handler({ environmentId: "env-1", profileId: "default" });

expect(cleared).toEqual([[guestPartition], [guestPartition]]);
// The partition is loaded before the clear walks the session map.
expect(loaded).toEqual([guestPartition, guestPartition]);
}).pipe(Effect.provideService(PreviewManager.PreviewManager, manager));
},
);

effectIt.effect("rejects invalid webContents ids before resolving the preview service", () =>
Effect.map(
PreviewIpc.registerWebview
Expand Down
47 changes: 47 additions & 0 deletions apps/desktop/src/ipc/methods/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import {
DesktopPreviewRecordingArtifactSchema,
DesktopPreviewRecordingSaveInputSchema,
DesktopPreviewRegisterWebviewInputSchema,
DesktopPreviewPageImageSchema,
DesktopPreviewScreenshotArtifactSchema,
DesktopPreviewSetAudioMutedInputSchema,
DesktopPreviewSetZoomFactorInputSchema,
DesktopPreviewSetColorSchemeInputSchema,
BrowserImportResult,
BrowserImportSource,
Expand All @@ -35,6 +37,7 @@ import * as NodeURL from "node:url";

import * as ElectronWindow from "../../electron/ElectronWindow.ts";
import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts";
import * as BrowserFrameHub from "../../preview/FrameHub.ts";
import * as PreviewManager from "../../preview/Manager.ts";
import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts";
import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts";
Expand Down Expand Up @@ -152,6 +155,15 @@ export const resetZoom = tabMethod(
"desktop.ipc.preview.resetZoom",
(manager, tabId) => manager.resetZoom(tabId),
);
export const setZoomFactor = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_SET_ZOOM_FACTOR_CHANNEL,
payload: DesktopPreviewSetZoomFactorInputSchema,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.preview.setZoomFactor")(function* ({ tabId, zoomFactor }) {
const manager = yield* PreviewManager.PreviewManager;
yield* manager.setZoomFactor(tabId, zoomFactor);
}),
});
export const hardReload = tabMethod(
IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL,
"desktop.ipc.preview.hardReload",
Expand Down Expand Up @@ -180,6 +192,11 @@ export const openDevTools = tabMethod(
"desktop.ipc.preview.openDevTools",
(manager, tabId) => manager.openDevTools(tabId),
);
export const closeDevTools = tabMethod(
IpcChannels.PREVIEW_CLOSE_DEVTOOLS_CHANNEL,
"desktop.ipc.preview.closeDevTools",
(manager, tabId) => manager.closeDevTools(tabId),
);
export const cancelPickElement = tabMethod(
IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL,
"desktop.ipc.preview.cancelPickElement",
Expand Down Expand Up @@ -372,6 +389,16 @@ export const captureScreenshot = DesktopIpc.makeIpcMethod({
}),
});

export const capturePageImage = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_CAPTURE_PAGE_IMAGE_CHANNEL,
payload: DesktopPreviewTabInputSchema,
result: DesktopPreviewPageImageSchema,
handler: Effect.fn("desktop.ipc.preview.capturePageImage")(function* ({ tabId }) {
const manager = yield* PreviewManager.PreviewManager;
return yield* manager.capturePageImage(tabId);
}),
});

export const revealArtifact = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL,
payload: DesktopPreviewArtifactInputSchema,
Expand All @@ -392,6 +419,23 @@ export const copyArtifactToClipboard = DesktopIpc.makeIpcMethod({
}),
});

/**
* Registered separately from `methods`: it carries `BrowserFrameHub` in its
* context rather than `PreviewManager`, so it does not unify with the shared
* loop in `DesktopIpcHandlers`. Returns the loopback origin AND the hub
* secret — the renderer forwards both inside the automation-host
* registration; the broker stores the secret server-side only.
*/
export const frameHubEndpoint = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_FRAME_HUB_CHANNEL,
payload: Schema.Void,
result: Schema.NullOr(Schema.Struct({ origin: Schema.String, secret: Schema.String })),
handler: Effect.fn("desktop.ipc.preview.frameHubEndpoint")(function* () {
const hub = yield* BrowserFrameHub.BrowserFrameHub;
return { origin: hub.origin, secret: hub.secret };
}),
});

export const automationStatus = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL,
payload: DesktopPreviewTabInputSchema,
Expand Down Expand Up @@ -493,17 +537,20 @@ export const methods = [
zoomIn,
zoomOut,
resetZoom,
setZoomFactor,
hardReload,
setColorScheme,
setAudioMuted,
openDevTools,
closeDevTools,
clearCookies,
clearCache,
getPreviewConfig,
setAnnotationTheme,
pickElement,
cancelPickElement,
captureScreenshot,
capturePageImage,
revealArtifact,
copyArtifactToClipboard,
openPictureInPicture,
Expand Down
7 changes: 6 additions & 1 deletion apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts";
import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts";
import * as BrowserSession from "./preview/BrowserSession.ts";
import * as BrowserFrameHub from "./preview/FrameHub.ts";
import * as PreviewManager from "./preview/Manager.ts";
import * as DesktopWindow from "./window/DesktopWindow.ts";
import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts";
Expand Down Expand Up @@ -146,8 +147,12 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe(

const desktopPreviewLayer = PreviewManager.layer.pipe(
// Merged rather than provided so the IPC handlers can reach the import
// service alongside the manager; both sit on the same BrowserSession.
// service alongside the manager; both sit on the same BrowserSession. The
// frame hub is merged for the same reason — `preview.frames.hubEndpoint`
// reaches it through the IPC handler context. It provides the manager
// internally; layer memoization keeps it the same instance.
Layer.provideMerge(BrowserImport.layer.pipe(Layer.provide(LinuxBrowserSecret.layer))),
Layer.provideMerge(BrowserFrameHub.layer.pipe(Layer.provide(PreviewManager.layer))),
Layer.provideMerge(BrowserSession.layer),
Layer.provideMerge(desktopFoundationLayer),
);
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,13 +303,17 @@ contextBridge.exposeInMainWorld("desktopBridge", {
zoomIn: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, { tabId }),
zoomOut: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, { tabId }),
resetZoom: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, { tabId }),
setZoomFactor: (tabId, zoomFactor) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ZOOM_FACTOR_CHANNEL, { tabId, zoomFactor }),
hardReload: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, { tabId }),
setColorScheme: (tabId, colorScheme) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }),
setAudioMuted: (tabId, audioMuted) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }),
openDevTools: (tabId) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }),
closeDevTools: (tabId) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_CLOSE_DEVTOOLS_CHANNEL, { tabId }),
listBrowserImportSources: () => ipcRenderer.invoke(IpcChannels.PREVIEW_IMPORT_SOURCES_CHANNEL),
importBrowserCookies: (input) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_IMPORT_COOKIES_CHANNEL, input),
Expand All @@ -326,6 +330,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {
ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }),
captureScreenshot: (tabId) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, { tabId }),
capturePageImage: (tabId) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_CAPTURE_PAGE_IMAGE_CHANNEL, { tabId }),
revealArtifact: (path) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, { path }),
copyArtifactToClipboard: (path) =>
Expand All @@ -336,6 +342,9 @@ contextBridge.exposeInMainWorld("desktopBridge", {
close: (tabId) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL, { tabId }),
},
frames: {
hubEndpoint: () => ipcRenderer.invoke(IpcChannels.PREVIEW_FRAME_HUB_CHANNEL),
},
recording: {
onInput: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, event: unknown) => {
Expand Down
Loading
Loading