1import {
2 DesktopPreviewAnnotationSendEnabledInputSchema,
3 DesktopPreviewAnnotationThemeInputSchema,
4 DesktopPreviewArtifactInputSchema,
5 DesktopPreviewConfigInputSchema,
6 DesktopPreviewNavigateInputSchema,
7 DesktopPreviewRecordingArtifactSchema,
8 DesktopPreviewRecordingSaveInputSchema,
9 DesktopPreviewRegisterWebviewInputSchema,
10 DesktopPreviewScreenshotArtifactSchema,
11 DesktopPreviewSetAudioMutedInputSchema,
12 DesktopPreviewSetColorSchemeInputSchema,
13 DesktopPreviewSetZoomFactorInputSchema,
14 BrowserImportResult,
15 BrowserImportSource,
16 DesktopPreviewClearDataInputSchema,
17 DesktopPreviewImportCookiesInputSchema,
18 DesktopPreviewCreateTabInputSchema,
19 DesktopPreviewTabInputSchema,
20 DesktopPreviewWebviewConfigSchema,
21 PreviewAnnotationSubmissionResultSchema,
22 DEFAULT_BROWSER_PROFILE_ID,
23 INCOGNITO_BROWSER_PROFILE_ID,
24 PreviewForwardedShortcut,
25 MAX_KEYBINDINGS_COUNT,
26} from "@t3tools/contracts";
27import * as Effect from "effect/Effect";
28import * as Schema from "effect/Schema";
29import * as Option from "effect/Option";
30import * as NodeURL from "node:url";
31
32import * as ElectronWindow from "../../electron/ElectronWindow.ts";
33import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts";
34import * as PreviewManager from "../../preview/Manager.ts";
35import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts";
36import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts";
37import * as IpcChannels from "../channels.ts";
38import * as DesktopIpc from "../DesktopIpc.ts";
39
40export const installPreviewEventForwarding = Effect.fn(
41 "desktop.ipc.preview.installEventForwarding",
42)(function* () {
43 const electronWindow = yield* ElectronWindow.ElectronWindow;
44 const manager = yield* PreviewManager.PreviewManager;
45 yield* manager.subscribeStateChanges((tabId, state) =>
46 electronWindow.sendAll(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state),
47 );
48 yield* manager.subscribeRecordingFrames((frame) =>
49 electronWindow.sendAll(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame),
50 );
51 yield* manager.subscribeRecordingInputs((event) =>
52 electronWindow.sendAll(IpcChannels.PREVIEW_RECORDING_INPUT_CHANNEL, event),
53 );
54 yield* manager.subscribePointerEvents((event) =>
55 electronWindow.sendAll(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event),
56 );
57 yield* manager.subscribeOpenLinks((event) =>
58 electronWindow.sendAll(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, event),
59 );
60});
61
62export const setForwardedShortcuts = DesktopIpc.makeIpcMethod({
63 channel: IpcChannels.PREVIEW_SET_FORWARDED_SHORTCUTS_CHANNEL,
64 payload: Schema.Array(PreviewForwardedShortcut).check(Schema.isMaxLength(MAX_KEYBINDINGS_COUNT)),
65 result: Schema.Void,
66 handler: Effect.fn("desktop.ipc.preview.setForwardedShortcuts")(function* (shortcuts) {
67 const manager = yield* PreviewManager.PreviewManager;
68 yield* manager.setForwardedShortcuts(shortcuts);
69 }),
70});
71
72export const createTab = DesktopIpc.makeIpcMethod({
73 channel: IpcChannels.PREVIEW_CREATE_TAB_CHANNEL,
74 payload: DesktopPreviewCreateTabInputSchema,
75 result: Schema.Void,
76 handler: Effect.fn("desktop.ipc.preview.createTab")(function* ({
77 tabId,
78 zoomFactor,
79 colorScheme,
80 serverTab,
81 }) {
82 const manager = yield* PreviewManager.PreviewManager;
83 yield* manager.createTab(tabId, { zoomFactor, colorScheme, serverTab });
84 }),
85});
86
87export const closeTab = DesktopIpc.makeIpcMethod({
88 channel: IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL,
89 payload: DesktopPreviewTabInputSchema,
90 result: Schema.Void,
91 handler: Effect.fn("desktop.ipc.preview.closeTab")(function* ({ tabId }) {
92 const manager = yield* PreviewManager.PreviewManager;
93 yield* manager.closeTab(tabId);
94 }),
95});
96
97export const registerWebview = DesktopIpc.makeIpcMethod({
98 channel: IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL,
99 payload: DesktopPreviewRegisterWebviewInputSchema,
100 result: Schema.Void,
101 handler: Effect.fn("desktop.ipc.preview.registerWebview")(function* ({ tabId, webContentsId }) {
102 const manager = yield* PreviewManager.PreviewManager;
103 yield* manager.registerWebview(tabId, webContentsId);
104 }),
105});
106
107export const navigate = DesktopIpc.makeIpcMethod({
108 channel: IpcChannels.PREVIEW_NAVIGATE_CHANNEL,
109 payload: DesktopPreviewNavigateInputSchema,
110 result: Schema.Void,
111 handler: Effect.fn("desktop.ipc.preview.navigate")(function* ({ tabId, url }) {
112 const manager = yield* PreviewManager.PreviewManager;
113 yield* manager.navigate(tabId, url);
114 }),
115});
116
117const tabMethod = (
118 channel: string,
119 name: string,
120 invoke: (
121 manager: PreviewManager.PreviewManager["Service"],
122 tabId: string,
123 ) => Effect.Effect<void, PreviewManager.PreviewManagerError>,
124) =>
125 DesktopIpc.makeIpcMethod({
126 channel,
127 payload: DesktopPreviewTabInputSchema,
128 result: Schema.Void,
129 handler: Effect.fn(name)(function* ({ tabId }) {
130 const manager = yield* PreviewManager.PreviewManager;
131 yield* invoke(manager, tabId);
132 }),
133 });
134
135export const goBack = tabMethod(
136 IpcChannels.PREVIEW_GO_BACK_CHANNEL,
137 "desktop.ipc.preview.goBack",
138 (manager, tabId) => manager.goBack(tabId),
139);
140export const goForward = tabMethod(
141 IpcChannels.PREVIEW_GO_FORWARD_CHANNEL,
142 "desktop.ipc.preview.goForward",
143 (manager, tabId) => manager.goForward(tabId),
144);
145export const refresh = tabMethod(
146 IpcChannels.PREVIEW_REFRESH_CHANNEL,
147 "desktop.ipc.preview.refresh",
148 (manager, tabId) => manager.refresh(tabId),
149);
150export const zoomIn = tabMethod(
151 IpcChannels.PREVIEW_ZOOM_IN_CHANNEL,
152 "desktop.ipc.preview.zoomIn",
153 (manager, tabId) => manager.zoomIn(tabId),
154);
155export const zoomOut = tabMethod(
156 IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL,
157 "desktop.ipc.preview.zoomOut",
158 (manager, tabId) => manager.zoomOut(tabId),
159);
160export const resetZoom = tabMethod(
161 IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL,
162 "desktop.ipc.preview.resetZoom",
163 (manager, tabId) => manager.resetZoom(tabId),
164);
165export const hardReload = tabMethod(
166 IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL,
167 "desktop.ipc.preview.hardReload",
168 (manager, tabId) => manager.hardReload(tabId),
169);
170export const setZoomFactor = DesktopIpc.makeIpcMethod({
171 channel: IpcChannels.PREVIEW_SET_ZOOM_FACTOR_CHANNEL,
172 payload: DesktopPreviewSetZoomFactorInputSchema,
173 result: Schema.Void,
174 handler: Effect.fn("desktop.ipc.preview.setZoomFactor")(function* ({ tabId, zoomFactor }) {
175 const manager = yield* PreviewManager.PreviewManager;
176 yield* manager.setZoomFactor(tabId, zoomFactor);
177 }),
178});
179export const setColorScheme = DesktopIpc.makeIpcMethod({
180 channel: IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL,
181 payload: DesktopPreviewSetColorSchemeInputSchema,
182 result: Schema.Void,
183 handler: Effect.fn("desktop.ipc.preview.setColorScheme")(function* ({ tabId, colorScheme }) {
184 const manager = yield* PreviewManager.PreviewManager;
185 yield* manager.setColorScheme(tabId, colorScheme);
186 }),
187});
188export const setAudioMuted = DesktopIpc.makeIpcMethod({
189 channel: IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL,
190 payload: DesktopPreviewSetAudioMutedInputSchema,
191 result: Schema.Void,
192 handler: Effect.fn("desktop.ipc.preview.setAudioMuted")(function* ({ tabId, audioMuted }) {
193 const manager = yield* PreviewManager.PreviewManager;
194 yield* manager.setAudioMuted(tabId, audioMuted);
195 }),
196});
197export const openDevTools = tabMethod(
198 IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL,
199 "desktop.ipc.preview.openDevTools",
200 (manager, tabId) => manager.openDevTools(tabId),
201);
202export const cancelPickElement = tabMethod(
203 IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL,
204 "desktop.ipc.preview.cancelPickElement",
205 (manager, tabId) => manager.cancelPickElement(tabId),
206);
207export const startRecording = DesktopIpc.makeIpcMethod({
208 channel: IpcChannels.PREVIEW_RECORDING_START_CHANNEL,
209 payload: DesktopPreviewTabInputSchema,
210 result: Schema.Void,
211 handler: Effect.fn("desktop.ipc.preview.startRecording")(function* ({ tabId }) {
212 const manager = yield* PreviewManager.PreviewManager;
213 const store = yield* DesktopClientSettings.DesktopClientSettings;
214 const settings = yield* store.get;
215 const options = Option.map(settings, (value) => ({
216 showKeyPresses: value.browserRecordingShowKeyPresses,
217 showMousePresses: value.browserRecordingShowMousePresses,
218 }));
219 yield* manager.startRecording(tabId, Option.getOrUndefined(options));
220 }),
221});
222export const stopRecording = tabMethod(
223 IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL,
224 "desktop.ipc.preview.stopRecording",
225 (manager, tabId) => manager.stopRecording(tabId),
226);
227export const openPictureInPicture = tabMethod(
228 IpcChannels.PREVIEW_PICTURE_IN_PICTURE_OPEN_CHANNEL,
229 "desktop.ipc.preview.openPictureInPicture",
230 (manager, tabId) => manager.openPictureInPicture(tabId),
231);
232export const closePictureInPicture = tabMethod(
233 IpcChannels.PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL,
234 "desktop.ipc.preview.closePictureInPicture",
235 (manager, tabId) => manager.closePictureInPicture(tabId),
236);
237
238export const clearCookies = DesktopIpc.makeIpcMethod({
239 channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL,
240 payload: DesktopPreviewClearDataInputSchema,
241 result: Schema.Void,
242 handler: Effect.fn("desktop.ipc.preview.clearCookies")(function* ({ environmentId, profileId }) {
243 const manager = yield* PreviewManager.PreviewManager;
244 yield* manager.clearCookies(yield* resolveClearPartitions(manager, environmentId, profileId));
245 }),
246});
247
248export const clearCache = DesktopIpc.makeIpcMethod({
249 channel: IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL,
250 payload: DesktopPreviewClearDataInputSchema,
251 result: Schema.Void,
252 handler: Effect.fn("desktop.ipc.preview.clearCache")(function* ({ environmentId, profileId }) {
253 const manager = yield* PreviewManager.PreviewManager;
254 yield* manager.clearCache(yield* resolveClearPartitions(manager, environmentId, profileId));
255 }),
256});
257
258/**
259 * Partition scope for an (environment, profile) pair.
260 *
261 * The default profile keeps the bare environment id it used before profiles
262 * existed, so upgrading does not strand anyone's existing logins in an
263 * orphaned partition. Incognito derives a non-persistent partition.
264 */
265export function resolvePartitionScope(
266 environmentId: string,
267 profileId: string | undefined,
268): {
269 readonly scope: string;
270 readonly persistent: boolean;
271 readonly namespace?: "profile";
272} {
273 if (profileId === undefined || profileId === DEFAULT_BROWSER_PROFILE_ID) {
274 return { scope: environmentId, persistent: true };
275 }
276 // JSON's tuple framing is injective for strings, including lone UTF-16
277 // surrogates (which it escapes). URI encoding throws on those supported ids,
278 // while replacing them with U+FFFD would collapse distinct identities.
279 return {
280 scope: JSON.stringify([environmentId, profileId]),
281 persistent: profileId !== INCOGNITO_BROWSER_PROFILE_ID,
282 namespace: "profile" as const,
283 };
284}
285
286/**
287 * Clearing without a profile keeps the historical "everything" behaviour for
288 * an explicit all-profiles action; naming a profile confines it to that
289 * profile's partition so one profile's sign-out cannot reach the others.
290 */
291const resolveClearPartitions = Effect.fn("desktop.ipc.preview.resolveClearPartitions")(function* (
292 manager: PreviewManager.PreviewManager["Service"],
293 environmentId: string,
294 profileId: string | undefined,
295) {
296 if (profileId === undefined) return undefined;
297 const { scope, persistent, namespace } = resolvePartitionScope(environmentId, profileId);
298 // Loading the session is what puts the partition in the map the clear walks.
299 // Deriving the partition string alone leaves nothing to match, so clearing a
300 // profile with no tab open this run — after a restart, or when deleting a
301 // profile — would report success and delete nothing.
302 yield* manager.getBrowserSession(scope, persistent, namespace);
303 return [yield* manager.getBrowserPartition(scope, persistent, namespace)];
304});
305
306export const getPreviewConfig = DesktopIpc.makeIpcMethod({
307 channel: IpcChannels.PREVIEW_GET_CONFIG_CHANNEL,
308 payload: DesktopPreviewConfigInputSchema,
309 result: DesktopPreviewWebviewConfigSchema,
310 handler: Effect.fn("desktop.ipc.preview.getConfig")(function* ({ environmentId, profileId }) {
311 const manager = yield* PreviewManager.PreviewManager;
312 const { scope, persistent, namespace } = resolvePartitionScope(environmentId, profileId);
313 // Creating the session first is what installs the UA rewrite and permission
314 // handlers; a guest that attached to an untouched partition would run with
315 // Electron's default UA and Chromium's default permission behaviour.
316 yield* manager.getBrowserSession(scope, persistent, namespace);
317 return {
318 partition: yield* manager.getBrowserPartition(scope, persistent, namespace),
319 webPreferences: PREVIEW_WEBVIEW_PREFERENCES,
320 preloadUrl: NodeURL.pathToFileURL(`${__dirname}/preview-pick-preload.cjs`).href,
321 };
322 }),
323});
324
325/**
326 * Registered separately from `methods`: these carry `BrowserImport` in their
327 * context and their own failure type, so they do not unify with the
328 * manager-backed handlers the shared loop iterates.
329 */
330export const listBrowserImportSources = DesktopIpc.makeIpcMethod({
331 channel: IpcChannels.PREVIEW_IMPORT_SOURCES_CHANNEL,
332 payload: Schema.Void,
333 result: Schema.Array(BrowserImportSource),
334 handler: Effect.fn("desktop.ipc.preview.listBrowserImportSources")(function* () {
335 const browserImport = yield* BrowserImport.BrowserImport;
336 return yield* browserImport.listSources;
337 }),
338});
339
340export const importBrowserCookies = DesktopIpc.makeIpcMethod({
341 channel: IpcChannels.PREVIEW_IMPORT_COOKIES_CHANNEL,
342 payload: DesktopPreviewImportCookiesInputSchema,
343 result: BrowserImportResult,
344 handler: Effect.fn("desktop.ipc.preview.importBrowserCookies")(function* ({
345 environmentId,
346 ...importInput
347 }) {
348 const browserImport = yield* BrowserImport.BrowserImport;
349 // Derived in main from the same helper the webview config uses, so cookies
350 // land in exactly the partition the profile's tabs attach to.
351 const { scope, persistent, namespace } = resolvePartitionScope(
352 environmentId,
353 importInput.targetProfileId,
354 );
355 return yield* browserImport.importCookies({
356 input: importInput,
357 scope,
358 persistent,
359 ...(namespace === undefined ? {} : { namespace }),
360 });
361 }),
362});
363
364export const setAnnotationTheme = DesktopIpc.makeIpcMethod({
365 channel: IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL,
366 payload: DesktopPreviewAnnotationThemeInputSchema,
367 result: Schema.Void,
368 handler: Effect.fn("desktop.ipc.preview.setAnnotationTheme")(function* ({ theme }) {
369 const manager = yield* PreviewManager.PreviewManager;
370 yield* manager.setAnnotationTheme(theme);
371 }),
372});
373
374export const pickElement = DesktopIpc.makeIpcMethod({
375 channel: IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL,
376 payload: DesktopPreviewTabInputSchema,
377 result: Schema.NullOr(PreviewAnnotationSubmissionResultSchema),
378 handler: Effect.fn("desktop.ipc.preview.pickElement")(function* ({ tabId }) {
379 const manager = yield* PreviewManager.PreviewManager;
380 return yield* manager.pickElement(tabId);
381 }),
382});
383
384export const setAnnotationSendEnabled = DesktopIpc.makeIpcMethod({
385 channel: IpcChannels.PREVIEW_SET_ANNOTATION_SEND_ENABLED_CHANNEL,
386 payload: DesktopPreviewAnnotationSendEnabledInputSchema,
387 result: Schema.Void,
388 handler: Effect.fn("desktop.ipc.preview.setAnnotationSendEnabled")(function* ({
389 tabId,
390 enabled,
391 }) {
392 const manager = yield* PreviewManager.PreviewManager;
393 yield* manager.setAnnotationSendEnabled(tabId, enabled);
394 }),
395});
396
397export const captureScreenshot = DesktopIpc.makeIpcMethod({
398 channel: IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL,
399 payload: DesktopPreviewTabInputSchema,
400 result: DesktopPreviewScreenshotArtifactSchema,
401 handler: Effect.fn("desktop.ipc.preview.captureScreenshot")(function* ({ tabId }) {
402 const manager = yield* PreviewManager.PreviewManager;
403 return yield* manager.captureScreenshot(tabId);
404 }),
405});
406
407export const revealArtifact = DesktopIpc.makeIpcMethod({
408 channel: IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL,
409 payload: DesktopPreviewArtifactInputSchema,
410 result: Schema.Void,
411 handler: Effect.fn("desktop.ipc.preview.revealArtifact")(function* ({ path }) {
412 const manager = yield* PreviewManager.PreviewManager;
413 yield* manager.revealArtifact(path);
414 }),
415});
416
417export const copyArtifactToClipboard = DesktopIpc.makeIpcMethod({
418 channel: IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL,
419 payload: DesktopPreviewArtifactInputSchema,
420 result: Schema.Void,
421 handler: Effect.fn("desktop.ipc.preview.copyArtifactToClipboard")(function* ({ path }) {
422 const manager = yield* PreviewManager.PreviewManager;
423 yield* manager.copyArtifactToClipboard(path);
424 }),
425});
426
427export const saveRecording = DesktopIpc.makeIpcMethod({
428 channel: IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL,
429 payload: DesktopPreviewRecordingSaveInputSchema,
430 result: DesktopPreviewRecordingArtifactSchema,
431 handler: Effect.fn("desktop.ipc.preview.saveRecording")(function* ({ tabId, mimeType, data }) {
432 const manager = yield* PreviewManager.PreviewManager;
433 return yield* manager.saveRecording(tabId, mimeType, data);
434 }),
435});
436
437export const methods = [
438 setForwardedShortcuts,
439 createTab,
440 closeTab,
441 registerWebview,
442 navigate,
443 goBack,
444 goForward,
445 refresh,
446 zoomIn,
447 zoomOut,
448 resetZoom,
449 hardReload,
450 setColorScheme,
451 setZoomFactor,
452 setAudioMuted,
453 openDevTools,
454 clearCookies,
455 clearCache,
456 getPreviewConfig,
457 setAnnotationTheme,
458 setAnnotationSendEnabled,
459 pickElement,
460 cancelPickElement,
461 captureScreenshot,
462 revealArtifact,
463 copyArtifactToClipboard,
464 openPictureInPicture,
465 closePictureInPicture,
466 startRecording,
467 stopRecording,
468 saveRecording,
469] as const;