Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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: 1 addition & 1 deletion apps/desktop/src/ipc/methods/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe("preview IPC methods", () => {
effectIt.effect("rejects invalid webContents ids before resolving the preview service", () =>
Effect.map(
PreviewIpc.registerWebview
.handler({ tabId: "tab-1", webContentsId: 0 })
.handler({ tabId: "tab-1", webContentsId: 0, initialUrl: null })
.pipe(Effect.provideService(PreviewManager.PreviewManager, null as never), Effect.exit),
(exit) => {
expect(Exit.isFailure(exit)).toBe(true);
Expand Down
8 changes: 6 additions & 2 deletions apps/desktop/src/ipc/methods/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,13 @@ export const registerWebview = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL,
payload: DesktopPreviewRegisterWebviewInputSchema,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.preview.registerWebview")(function* ({ tabId, webContentsId }) {
handler: Effect.fn("desktop.ipc.preview.registerWebview")(function* ({
tabId,
webContentsId,
initialUrl,
}) {
const manager = yield* PreviewManager.PreviewManager;
yield* manager.registerWebview(tabId, webContentsId);
yield* manager.registerWebview(tabId, webContentsId, initialUrl);
}),
});

Expand Down
8 changes: 6 additions & 2 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,12 @@ contextBridge.exposeInMainWorld("desktopBridge", {
colorScheme: defaults?.colorScheme,
}),
closeTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, { tabId }),
registerWebview: (tabId, webContentsId) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, { tabId, webContentsId }),
registerWebview: (tabId, webContentsId, initialUrl) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, {
tabId,
webContentsId,
initialUrl,
}),
navigate: (tabId, url) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_NAVIGATE_CHANNEL, { tabId, url }),
goBack: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_GO_BACK_CHANNEL, { tabId }),
Expand Down
220 changes: 216 additions & 4 deletions apps/desktop/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ describe("isPreviewRefreshShortcut", () => {

const {
browserWindowConstructor,
browserWindowFromWebContents,
createFromPath,
fromId,
getFocusedWebContents,
Expand All @@ -70,6 +71,9 @@ const {
writeImage,
} = vi.hoisted(() => ({
browserWindowConstructor: vi.fn(),
browserWindowFromWebContents: vi.fn<
(webContents: Electron.WebContents) => Electron.BrowserWindow | null
>(() => null),
createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })),
fromId: vi.fn((_id?: number) => null),
getFocusedWebContents: vi.fn(() => null),
Expand All @@ -81,7 +85,9 @@ const {
}));

vi.mock("electron", () => ({
BrowserWindow: browserWindowConstructor,
BrowserWindow: Object.assign(browserWindowConstructor, {
fromWebContents: browserWindowFromWebContents,
}),
clipboard: {
writeImage,
},
Expand Down Expand Up @@ -200,13 +206,16 @@ const makeSourcePng = (width = 1, height = 1): Buffer => {

const makeFaviconWebContents = (options?: {
readonly fetch?: (url: string, init?: RequestInit) => Promise<Response>;
readonly hostWebContents?: Electron.WebContents | null;
readonly id?: number;
readonly rasterize?: (code: string) => Promise<unknown>;
readonly trackNavigationHistory?: boolean;
readonly url?: string;
}) => {
const sourcePng = makeSourcePng();
const listeners = new Map<string, (...args: never[]) => void>();
let currentUrl = options?.url ?? "http://localhost:3200/";
let canGoBack = false;
let destroyed = false;
let loading = false;
const fetch = vi.fn(
Expand All @@ -221,11 +230,26 @@ const makeFaviconWebContents = (options?: {
options?.rasterize ? options.rasterize(scripts[0]?.code ?? "") : TEST_FAVICON,
);
const reload = vi.fn();
const navigationHistoryClear = vi.fn(() => {
canGoBack = false;
});
const loadURL = vi.fn(async (url: string) => {
if (options?.trackNavigationHistory && currentUrl !== url) {
canGoBack = true;
currentUrl = url;
listeners.get("did-navigate")?.();
await Promise.resolve();
return;
}
currentUrl = url;
});
const off = vi.fn();
const debuggerOff = vi.fn();
const session = { fetch };
const setWindowOpenHandler =
vi.fn<
(handler: (details: Electron.HandlerDetails) => Electron.WindowOpenHandlerResponse) => void
>();
const webContents = {
id: options?.id ?? 42,
isDestroyed: () => destroyed,
Expand All @@ -235,6 +259,7 @@ const makeFaviconWebContents = (options?: {
isLoading: () => loading,
isDevToolsOpened: () => false,
getZoomFactor: () => 1,
hostWebContents: options?.hostWebContents ?? null,
setZoomFactor: vi.fn(),
setAudioMuted: vi.fn(),
isCurrentlyAudible: () => false,
Expand All @@ -247,9 +272,13 @@ const makeFaviconWebContents = (options?: {
off,
ipc: { on: vi.fn(), off: vi.fn() },
send: webviewSend,
session: { fetch },
navigationHistory: { canGoBack: () => false, canGoForward: () => false },
setWindowOpenHandler: vi.fn(),
session,
navigationHistory: {
canGoBack: () => canGoBack,
canGoForward: () => false,
clear: navigationHistoryClear,
},
setWindowOpenHandler,
executeJavaScriptInIsolatedWorld,
debugger: {
isAttached: () => false,
Expand All @@ -260,13 +289,17 @@ const makeFaviconWebContents = (options?: {
},
};
return {
canGoBack: () => canGoBack,
executeJavaScriptInIsolatedWorld,
fetch,
debuggerOff,
listeners,
loadURL,
navigationHistoryClear,
off,
reload,
session,
setWindowOpenHandler,
setDestroyed: (value: boolean) => {
destroyed = value;
},
Expand All @@ -286,6 +319,14 @@ const settle = function* (until: () => boolean) {
}
};

const windowOpenDetails = (url: string): Electron.HandlerDetails => ({
url,
frameName: "oauth",
features: "width=500,height=600",
disposition: "new-window",
referrer: { url: "http://localhost:3200/", policy: "strict-origin-when-cross-origin" },
});

const makeTestPictureInPictureWindow = (loadURL: () => Promise<void> = async () => undefined) => {
const listeners = new Map<string, () => void>();
const send = vi.fn();
Expand Down Expand Up @@ -319,6 +360,8 @@ const makeTestPictureInPictureWindow = (loadURL: () => Promise<void> = async ()
describe("PreviewManager", () => {
beforeEach(() => {
browserWindowConstructor.mockReset();
browserWindowFromWebContents.mockReset();
browserWindowFromWebContents.mockReturnValue(null);
fromId.mockClear();
getFocusedWebContents.mockReset();
getFocusedWebContents.mockReturnValue(null);
Expand All @@ -330,6 +373,113 @@ describe("PreviewManager", () => {
webviewSend.mockClear();
});

effectIt.effect("allows an OAuth popup without navigating the opener", () =>
withManager((manager) =>
Effect.gen(function* () {
const hostWebContents = {} as Electron.WebContents;
const parent = { isDestroyed: () => false } as Electron.BrowserWindow;
const preview = makeFaviconWebContents({ hostWebContents });
browserWindowFromWebContents.mockReturnValue(parent);
fromId.mockReturnValue(preview.webContents);

yield* manager.createTab("tab_popup");
yield* manager.registerWebview("tab_popup", 42);

const handler = preview.setWindowOpenHandler.mock.calls[0]?.[0];
expect(handler).toBeDefined();
if (!handler) return;

const expected = {
action: "allow",
outlivesOpener: false,
overrideBrowserWindowOptions: {
parent,
autoHideMenuBar: true,
webPreferences: {
session: preview.session,
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
nodeIntegrationInSubFrames: false,
webviewTag: false,
},
},
};
for (const url of [
"https://accounts.google.com/",
"http://localhost:3200/auth",
"about:blank",
]) {
expect(handler(windowOpenDetails(url))).toEqual(expected);
}
expect(preview.loadURL).not.toHaveBeenCalled();
}),
),
);

effectIt.effect("denies unsafe and nested preview popups", () =>
withManager((manager) =>
Effect.gen(function* () {
const preview = makeFaviconWebContents();
fromId.mockReturnValue(preview.webContents);

yield* manager.createTab("tab_popup_policy");
yield* manager.registerWebview("tab_popup_policy", 42);

const handler = preview.setWindowOpenHandler.mock.calls[0]?.[0];
expect(handler).toBeDefined();
if (!handler) return;
for (const url of [
"",
"file:///tmp/secret",
"data:text/html,hello",
"javascript:document.body.textContent='blocked'",
"mailto:test@example.com",
"about:blank?unexpected",
"http://[::1",
]) {
expect(handler(windowOpenDetails(url))).toEqual({ action: "deny" });
}

const setMenuBarVisibility = vi.fn();
const setChildWindowOpenHandler =
vi.fn<
(
handler: (details: Electron.HandlerDetails) => Electron.WindowOpenHandlerResponse,
) => void
>();
const popupWindow = {
isDestroyed: () => false,
setMenuBarVisibility,
webContents: {
id: 84,
isDestroyed: () => false,
setWindowOpenHandler: setChildWindowOpenHandler,
},
} as never;
const didCreateWindow = preview.listeners.get("did-create-window") as unknown as (
popupWindow: Electron.BrowserWindow,
) => void;
didCreateWindow(popupWindow);

expect(setMenuBarVisibility).toHaveBeenCalledWith(false);
const childHandler = setChildWindowOpenHandler.mock.calls[0]?.[0];
expect(childHandler).toBeDefined();
expect(childHandler?.(windowOpenDetails("https://example.com"))).toEqual({
action: "deny",
});

yield* manager.closeTab("tab_popup_policy");
expect(preview.off).toHaveBeenCalledWith("did-create-window", didCreateWindow);
const detachedHandler = preview.setWindowOpenHandler.mock.calls.at(-1)?.[0];
expect(detachedHandler?.(windowOpenDetails("https://example.com"))).toEqual({
action: "deny",
});
expect(preview.loadURL).not.toHaveBeenCalled();
}),
),
);

effectIt.effect("reports an unregistered webview as temporarily unavailable", () =>
withManager((manager) =>
Effect.gen(function* () {
Expand Down Expand Up @@ -503,6 +653,68 @@ describe("PreviewManager", () => {
),
);

effectIt.effect("loads the bootstrap URL after installing the popup policy", () =>
withManager((manager) =>
Effect.gen(function* () {
const preview = makeFaviconWebContents({ url: "about:blank" });
fromId.mockReturnValue(preview.webContents);

yield* manager.createTab("tab_bootstrap");
yield* manager.registerWebview("tab_bootstrap", 42, "https://example.com/start");
yield* Effect.yieldNow;

expect(preview.setWindowOpenHandler).toHaveBeenCalledOnce();
expect(preview.loadURL).toHaveBeenCalledOnce();
expect(preview.loadURL).toHaveBeenCalledWith("https://example.com/start");
expect(yield* manager.automationStatus("tab_bootstrap")).toMatchObject({
url: "https://example.com/start",
});
}),
),
);

effectIt.effect("keeps the bootstrap URL pending through blank guest events", () =>
withManager((manager) =>
Effect.gen(function* () {
const preview = makeFaviconWebContents({ url: "about:blank" });
fromId.mockReturnValue(preview.webContents);
webviewSend.mockImplementationOnce(() => {
preview.listeners.get("did-stop-loading")?.();
});

yield* manager.createTab("tab_bootstrap_blank_event");
yield* manager.registerWebview(
"tab_bootstrap_blank_event",
42,
"https://example.com/start",
);
yield* settle(() => preview.loadURL.mock.calls.length > 0);

expect(preview.loadURL).toHaveBeenCalledOnce();
expect(preview.loadURL).toHaveBeenCalledWith("https://example.com/start");
}),
),
);

effectIt.effect("removes the blank placeholder from bootstrap history", () =>
withManager((manager) =>
Effect.gen(function* () {
const preview = makeFaviconWebContents({
trackNavigationHistory: true,
url: "about:blank",
});
fromId.mockReturnValue(preview.webContents);

yield* manager.createTab("tab_bootstrap_history");
yield* manager.registerWebview("tab_bootstrap_history", 42, "https://example.com/start");
yield* settle(() => preview.navigationHistoryClear.mock.calls.length > 0);

expect(preview.navigationHistoryClear).toHaveBeenCalledOnce();
expect(preview.canGoBack()).toBe(false);
}),
),
);

effectIt.effect("detaches a destroyed webview instead of navigating it", () =>
withManager((manager) =>
Effect.gen(function* () {
Expand Down
Loading
Loading