| 1 | import { WebContentsView, session as electronSession, type BrowserWindow, type Session, type WebContents, type WebPreferences } from "electron"; |
| 2 | import type { Logger } from "../log.js"; |
| 3 | import { isBlockedNavigation, isPopupURL, type GuestView, type GuestViewEvents, type GuestViewFactory } from "./guestView.js"; |
| 4 | |
| 5 | const GUEST_PERMISSIONS = new Set(["clipboard-sanitized-write", "fullscreen"]); |
| 6 | |
| 7 | export interface ElectronGuestViewDeps { |
| 8 | window(): BrowserWindow | null; |
| 9 | preloadPath: string; |
| 10 | log: Logger; |
| 11 | onSession?(partition: string, session: Session): void; |
| 12 | } |
| 13 | |
| 14 | // Website views are untrusted: sandboxed, isolated, no Node, only the guest |
| 15 | // preload that reports user input. Every partition session gets the same |
| 16 | // permission policy the first time it is seen. |
| 17 | export class ElectronGuestViewFactory implements GuestViewFactory { |
| 18 | private readonly sessions = new Set<string>(); |
| 19 | |
| 20 | constructor(private readonly deps: ElectronGuestViewDeps) {} |
| 21 | |
| 22 | create(partition: string, inherited?: WebPreferences): GuestView { |
| 23 | const win = this.deps.window(); |
| 24 | if (!win) throw new Error("browser tabs need the main window"); |
| 25 | this.prepareSession(partition); |
| 26 | const view = new WebContentsView({ |
| 27 | webPreferences: { |
| 28 | ...inherited, |
| 29 | partition, |
| 30 | preload: this.deps.preloadPath, |
| 31 | sandbox: true, |
| 32 | contextIsolation: true, |
| 33 | nodeIntegration: false, |
| 34 | nodeIntegrationInSubFrames: false, |
| 35 | nodeIntegrationInWorker: false, |
| 36 | webviewTag: false, |
| 37 | spellcheck: true, |
| 38 | backgroundThrottling: false, |
| 39 | }, |
| 40 | }); |
| 41 | win.contentView.addChildView(view); |
| 42 | view.setVisible(false); |
| 43 | return new ElectronGuestView(view, win, this, this.deps.log); |
| 44 | } |
| 45 | |
| 46 | private prepareSession(partition: string): void { |
| 47 | if (this.sessions.has(partition)) return; |
| 48 | this.sessions.add(partition); |
| 49 | const session = electronSession.fromPartition(partition); |
| 50 | session.setPermissionRequestHandler((_contents, permission, callback) => callback(GUEST_PERMISSIONS.has(permission))); |
| 51 | session.setPermissionCheckHandler((_contents, permission) => GUEST_PERMISSIONS.has(permission)); |
| 52 | this.deps.onSession?.(partition, session); |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | class ElectronGuestView implements GuestView { |
| 57 | private destroyed = false; |
| 58 | |
| 59 | constructor( |
| 60 | private readonly view: WebContentsView, |
| 61 | private readonly win: BrowserWindow, |
| 62 | private readonly factory: GuestViewFactory, |
| 63 | private readonly log: Logger, |
| 64 | ) {} |
| 65 | |
| 66 | get page(): WebContents { |
| 67 | return this.view.webContents; |
| 68 | } |
| 69 | |
| 70 | bind(events: GuestViewEvents): void { |
| 71 | const wc = this.view.webContents; |
| 72 | wc.on("did-start-loading", () => events.onStartLoading()); |
| 73 | wc.on("did-stop-loading", () => events.onStopLoading()); |
| 74 | wc.on("did-navigate", (_event, url) => events.onNavigate(url, false)); |
| 75 | wc.on("did-navigate-in-page", (_event, url, isMainFrame) => { |
| 76 | if (isMainFrame) events.onNavigate(url, true); |
| 77 | }); |
| 78 | wc.on("page-title-updated", (_event, title) => events.onTitle(title)); |
| 79 | wc.on("did-fail-load", (_event, code, description, url, isMainFrame) => { |
| 80 | if (isMainFrame && code !== -3) events.onFailLoad(code, description, url); |
| 81 | }); |
| 82 | wc.on("render-process-gone", (_event, details) => events.onRenderProcessGone(details.reason)); |
| 83 | wc.on("destroyed", () => events.onDestroyed()); |
| 84 | wc.on("will-navigate", (details) => this.guardNavigation(details, details.url)); |
| 85 | wc.on("will-frame-navigate", (details) => this.guardNavigation(details, details.url)); |
| 86 | wc.on("will-redirect", (details) => this.guardNavigation(details, details.url)); |
| 87 | wc.on("will-attach-webview", (event) => event.preventDefault()); |
| 88 | wc.setWindowOpenHandler(({ url, disposition }) => { |
| 89 | if (!isPopupURL(url)) return { action: "deny" }; |
| 90 | const adopt = events.onPopup(url, disposition); |
| 91 | if (!adopt) return { action: "deny" }; |
| 92 | return { |
| 93 | action: "allow", |
| 94 | createWindow: (options) => { |
| 95 | const child = this.factory.create(String(options.webPreferences?.partition ?? ""), options.webPreferences); |
| 96 | adopt(child); |
| 97 | return child.page as WebContents; |
| 98 | }, |
| 99 | }; |
| 100 | }); |
| 101 | } |
| 102 | |
| 103 | private guardNavigation(details: { preventDefault(): void }, url: string): void { |
| 104 | if (!isBlockedNavigation(url)) return; |
| 105 | details.preventDefault(); |
| 106 | this.log.warn(`blocked browser navigation to ${url.slice(0, 120)}`); |
| 107 | } |
| 108 | |
| 109 | setBounds(bounds: Electron.Rectangle): void { |
| 110 | if (!this.destroyed) this.view.setBounds(bounds); |
| 111 | } |
| 112 | |
| 113 | setVisible(visible: boolean): void { |
| 114 | if (!this.destroyed) this.view.setVisible(visible); |
| 115 | } |
| 116 | |
| 117 | // Detach first so the window never paints a closing view, then close the |
| 118 | // WebContents; the prototype's quit order that never left orphans. |
| 119 | destroy(): void { |
| 120 | if (this.destroyed) return; |
| 121 | this.destroyed = true; |
| 122 | if (!this.win.isDestroyed()) { |
| 123 | try { |
| 124 | this.win.contentView.removeChildView(this.view); |
| 125 | } catch (error) { |
| 126 | this.log.warn(`removeChildView failed: ${String(error)}`); |
| 127 | } |
| 128 | } |
| 129 | const wc = this.view.webContents; |
| 130 | if (!wc.isDestroyed()) wc.close(); |
| 131 | } |
| 132 | } |
| 133 |