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
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom";
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_SET_VIEWPORT_CHANNEL = "desktop:preview-set-viewport";
export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools";
export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies";
export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache";
Expand All @@ -77,6 +78,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_AUTOMATION_SET_VIEWPORT_CHANNEL = "desktop:preview-automation-set-viewport";
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
28 changes: 28 additions & 0 deletions apps/desktop/src/ipc/methods/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
DesktopPreviewAnnotationThemeInputSchema,
DesktopPreviewArtifactInputSchema,
DesktopPreviewAutomationClickInputSchema,
DesktopPreviewAutomationSetViewportInputSchema,
DesktopPreviewAutomationEvaluateInputSchema,
DesktopPreviewAutomationPressInputSchema,
DesktopPreviewAutomationScrollInputSchema,
Expand Down Expand Up @@ -163,6 +164,18 @@ export const setAudioMuted = DesktopIpc.makeIpcMethod({
yield* manager.setAudioMuted(tabId, audioMuted);
}),
});
export const setViewport = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_SET_VIEWPORT_CHANNEL,
payload: DesktopPreviewAutomationSetViewportInputSchema,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.preview.setViewport")(function* (input) {
const manager = yield* PreviewManager.PreviewManager;
yield* manager.setViewport(
input.tabId,
"clear" in input ? { clear: true } : { width: input.width, height: input.height },
);
}),
});
export const openDevTools = tabMethod(
IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL,
"desktop.ipc.preview.openDevTools",
Expand Down Expand Up @@ -299,6 +312,19 @@ export const automationSnapshot = DesktopIpc.makeIpcMethod({
}),
});

export const automationSetViewport = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_AUTOMATION_SET_VIEWPORT_CHANNEL,
payload: DesktopPreviewAutomationSetViewportInputSchema,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.preview.automationSetViewport")(function* (input) {
const manager = yield* PreviewManager.PreviewManager;
yield* manager.automationSetViewport(
input.tabId,
"clear" in input ? { clear: true } : { width: input.width, height: input.height },
);
}),
});

export const automationClick = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL,
payload: DesktopPreviewAutomationClickInputSchema,
Expand Down Expand Up @@ -383,6 +409,7 @@ export const methods = [
hardReload,
setColorScheme,
setAudioMuted,
setViewport,
openDevTools,
clearCookies,
clearCache,
Expand All @@ -397,6 +424,7 @@ export const methods = [
closePictureInPicture,
automationStatus,
automationSnapshot,
automationSetViewport,
automationClick,
automationType,
automationPress,
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,11 @@ contextBridge.exposeInMainWorld("desktopBridge", {
ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }),
setAudioMuted: (tabId, audioMuted) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }),
setViewport: (tabId, input) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_SET_VIEWPORT_CHANNEL, {
tabId,
...input,
}),
openDevTools: (tabId) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }),
clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL),
Expand Down Expand Up @@ -236,6 +241,11 @@ contextBridge.exposeInMainWorld("desktopBridge", {
ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, { tabId }),
snapshot: (tabId) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, { tabId }),
setViewport: (tabId, input) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SET_VIEWPORT_CHANNEL, {
tabId,
...input,
}),
click: (tabId, input) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, { tabId, input }),
type: (tabId, input) =>
Expand Down
118 changes: 118 additions & 0 deletions apps/desktop/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1554,6 +1554,124 @@ describe("PreviewManager", () => {
),
);

effectIt.effect("applies a guest viewport override without taking agent control", () =>
withManager((manager) =>
Effect.gen(function* () {
const sendCommand = vi.fn(async () => undefined);
fromId.mockReturnValue({
id: 42,
isDestroyed: () => false,
isDevToolsOpened: () => false,
getType: () => "webview",
getURL: () => "https://example.com",
getTitle: () => "Example",
isLoading: () => false,
getZoomFactor: () => 1,
setZoomFactor: vi.fn(),
setAudioMuted: vi.fn(),
isCurrentlyAudible: () => false,
on: vi.fn(),
off: vi.fn(),
ipc: { on: vi.fn(), off: vi.fn() },
send: webviewSend,
navigationHistory: { canGoBack: () => false, canGoForward: () => false },
setWindowOpenHandler: vi.fn(),
debugger: {
isAttached: () => false,
attach: vi.fn(),
sendCommand,
on: vi.fn(),
off: vi.fn(),
},
} as never);
const controllers: Array<PreviewManager.PreviewTabState["controller"]> = [];

yield* manager.subscribeStateChanges((_tabId, state) =>
Effect.sync(() => {
controllers.push(state.controller);
}),
);
yield* manager.createTab("tab_viewport");
yield* manager.registerWebview("tab_viewport", 42);
yield* manager.setViewport("tab_viewport", { width: 390, height: 844 });
yield* manager.setViewport("tab_viewport", { width: 844, height: 390 });
yield* manager.setViewport("tab_viewport", { clear: true });

expect(sendCommand).toHaveBeenCalledWith("Emulation.setDeviceMetricsOverride", {
width: 390,
height: 844,
deviceScaleFactor: 1,
mobile: true,
});
expect(sendCommand).toHaveBeenCalledWith("Emulation.setDeviceMetricsOverride", {
width: 844,
height: 390,
deviceScaleFactor: 1,
mobile: true,
});
expect(sendCommand).toHaveBeenCalledWith("Emulation.clearDeviceMetricsOverride");
expect(controllers).not.toContain("agent");
}),
),
);

effectIt.effect("re-applies a guest viewport override after a webview swap", () =>
withManager((manager) =>
Effect.gen(function* () {
const makeWebContents = (id: number) => {
const sendCommand = vi.fn(async () => undefined);
return {
sendCommand,
wc: {
id,
isDestroyed: () => false,
isDevToolsOpened: () => false,
getType: () => "webview",
getURL: () => "https://example.com",
getTitle: () => "Example",
isLoading: () => false,
getZoomFactor: () => 1,
setZoomFactor: vi.fn(),
setAudioMuted: vi.fn(),
isCurrentlyAudible: () => false,
on: vi.fn(),
off: vi.fn(),
ipc: { on: vi.fn(), off: vi.fn() },
send: webviewSend,
navigationHistory: { canGoBack: () => false, canGoForward: () => false },
setWindowOpenHandler: vi.fn(),
debugger: {
isAttached: () => false,
attach: vi.fn(),
sendCommand,
on: vi.fn(),
off: vi.fn(),
},
} as never,
};
};
const first = makeWebContents(42);
fromId.mockReturnValue(first.wc);

yield* manager.createTab("tab_viewport_restore");
yield* manager.registerWebview("tab_viewport_restore", 42);
yield* manager.setViewport("tab_viewport_restore", { width: 390, height: 844 });

const replacement = makeWebContents(43);
fromId.mockReturnValue(replacement.wc);
yield* manager.registerWebview("tab_viewport_restore", 43);
yield* Effect.yieldNow;

expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setDeviceMetricsOverride", {
width: 390,
height: 844,
deviceScaleFactor: 1,
mobile: true,
});
}),
),
);

effectIt.effect("blocks late webview and capture starts during tab close", () =>
withManager((manager) =>
Effect.gen(function* () {
Expand Down
91 changes: 87 additions & 4 deletions apps/desktop/src/preview/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME);
const mainWindowRef = yield* Ref.make<Option.Option<BrowserWindow>>(Option.none());
const tabsRef = yield* SynchronizedRef.make<ReadonlyMap<string, PreviewTabState>>(new Map());
const viewportOverridesRef = yield* SynchronizedRef.make<
ReadonlyMap<string, { readonly width: number; readonly height: number }>
>(new Map());
const attachedRef = yield* Ref.make<ReadonlyMap<number, ManagedListeners>>(new Map());
const listenersRef = yield* Ref.make<ReadonlySet<Listener>>(new Set());
const pointerEventListenersRef = yield* Ref.make<ReadonlySet<PointerEventListener>>(new Set());
Expand Down Expand Up @@ -1829,6 +1832,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
] as const;
});
if (Option.isNone(tab)) return;
yield* SynchronizedRef.update(viewportOverridesRef, (overrides) =>
replaceMap(overrides, (copy) => {
copy.delete(tabId);
}),
);
const closedTab = tab.value;
if (closedTab.webContentsId != null) {
yield* Effect.all(
Expand Down Expand Up @@ -2362,10 +2370,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
);
});

// Re-establish the control session after a detach, restoring any
// color-scheme override the tab carries. The scheme is read after the
// session attaches so a concurrent setColorScheme is not overwritten with
// a stale snapshot.
// Re-establish the control session after a detach, restoring color-scheme
// and viewport overrides the tab carries. Both live on the CDP debugger
// session, so they are lost on webview swap and DevTools open/close.
// Values are read after attach so a concurrent setColorScheme/setViewport
// is not overwritten with a stale snapshot.
const restoreControlSession = (tabId: string, wc: Electron.WebContents) =>
Effect.gen(function* () {
const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
Expand All @@ -2388,6 +2397,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
}),
);
}
const viewportOverride = (yield* SynchronizedRef.get(viewportOverridesRef)).get(tabId);
if (viewportOverride) {
yield* applyViewportOverride(tabId, wc, viewportOverride);
}
}).pipe(Effect.ignore);

const setColorScheme = Effect.fn("PreviewManager.setColorScheme")(function* (
Expand Down Expand Up @@ -2446,6 +2459,64 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
);
});

const deviceMetricsOverride = (input: { readonly width: number; readonly height: number }) => ({
width: input.width,
height: input.height,
deviceScaleFactor: 1,
// Shortest side, so landscape phones stay mobile (844x390, not width-only).
mobile: Math.min(input.width, input.height) < 768,
});

const rememberViewportOverride = (
tabId: string,
input: { readonly width: number; readonly height: number } | { readonly clear: true },
) =>
SynchronizedRef.update(viewportOverridesRef, (overrides) =>
replaceMap(overrides, (copy) => {
if ("clear" in input) copy.delete(tabId);
else copy.set(tabId, { width: input.width, height: input.height });
}),
);

const applyViewportOverride = Effect.fn("PreviewManager.applyViewportOverride")(function* (
tabId: string,
wc: Electron.WebContents,
input: { readonly width: number; readonly height: number } | { readonly clear: true },
) {
yield* ensureControlSession(wc);
yield* attemptPromise({ operation: "applyViewportOverride", tabId, webContentsId: wc.id }, () =>
"clear" in input
? wc.debugger.sendCommand("Emulation.clearDeviceMetricsOverride")
: wc.debugger.sendCommand(
"Emulation.setDeviceMetricsOverride",
Comment thread
gbarros-dev marked this conversation as resolved.
deviceMetricsOverride(input),
),
);
});

// Human/toolbar path. Must not take agent control or write a resize action.
const setViewport = Effect.fn("PreviewManager.setViewport")(function* (
tabId: string,
input: { readonly width: number; readonly height: number } | { readonly clear: true },
) {
const wc = yield* requireWebContents(tabId);
yield* rememberViewportOverride(tabId, input);
yield* applyViewportOverride(tabId, wc, input);
});

const automationSetViewport = Effect.fn("PreviewManager.automationSetViewport")(function* (
tabId: string,
input: { readonly width: number; readonly height: number } | { readonly clear: true },
) {
const wc = yield* requireWebContents(tabId);
yield* rememberViewportOverride(tabId, input);
yield* withControlSession(tabId, wc, "resize", (send) =>
"clear" in input
? send("Emulation.clearDeviceMetricsOverride")
: send("Emulation.setDeviceMetricsOverride", deviceMetricsOverride(input)),
);
Comment thread
cursor[bot] marked this conversation as resolved.
});
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* (
tabId: string,
) {
Expand Down Expand Up @@ -3746,6 +3817,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
setAnnotationTheme,
setAudioMuted,
setColorScheme,
setViewport,
automationSetViewport,
setMainWindow,
startRecording,
closePictureInPicture,
Expand Down Expand Up @@ -4062,6 +4135,14 @@ export class PreviewManager extends Context.Service<
tabId: string,
audioMuted: boolean,
) => Effect.Effect<void, PreviewManagerError>;
readonly setViewport: (
tabId: string,
input: { readonly width: number; readonly height: number } | { readonly clear: true },
) => Effect.Effect<void, PreviewManagerError>;
readonly automationSetViewport: (
tabId: string,
input: { readonly width: number; readonly height: number } | { readonly clear: true },
) => Effect.Effect<void, PreviewManagerError>;
readonly openDevTools: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
readonly clearCookies: () => Effect.Effect<void, PreviewManagerError>;
readonly clearCache: () => Effect.Effect<void, PreviewManagerError>;
Expand Down Expand Up @@ -4161,6 +4242,8 @@ export const make = Effect.gen(function* PreviewManagerMake() {
hardReload: operations.hardReload,
setColorScheme: operations.setColorScheme,
setAudioMuted: operations.setAudioMuted,
setViewport: operations.setViewport,
automationSetViewport: operations.automationSetViewport,
openDevTools: operations.openDevTools,
clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () {
yield* browserSession
Expand Down
Loading
Loading