Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 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