rowboat/apps/x/apps/main/src/browser/view.ts
2026-04-14 09:32:36 +05:30

207 lines
5.9 KiB
TypeScript

import { BrowserWindow, WebContentsView, session, shell } from 'electron';
import { EventEmitter } from 'node:events';
/**
* Embedded browser pane implementation.
*
* A single lazy-created WebContentsView is hosted on top of the main
* BrowserWindow's contentView, positioned by pixel bounds the renderer
* computes via ResizeObserver.
*
* The view uses a persistent session partition so cookies/localStorage/
* form-fill state survive app restarts, and spoofs a standard Chrome UA so
* sites like Google (OAuth) don't reject it as an embedded browser.
*/
const PARTITION = 'persist:rowboat-browser';
// Claims Chrome 130 on macOS — close enough to recent stable for OAuth servers
// that sniff the UA looking for "real browser" shapes.
const SPOOF_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36';
const HOME_URL = 'https://www.google.com';
export interface BrowserBounds {
x: number;
y: number;
width: number;
height: number;
}
export interface BrowserState {
url: string;
title: string;
canGoBack: boolean;
canGoForward: boolean;
loading: boolean;
}
const EMPTY_STATE: BrowserState = {
url: '',
title: '',
canGoBack: false,
canGoForward: false,
loading: false,
};
export class BrowserViewManager extends EventEmitter {
private window: BrowserWindow | null = null;
private view: WebContentsView | null = null;
private attached = false;
private visible = false;
private bounds: BrowserBounds = { x: 0, y: 0, width: 0, height: 0 };
attach(window: BrowserWindow): void {
this.window = window;
window.on('closed', () => {
this.window = null;
this.view = null;
this.attached = false;
this.visible = false;
});
}
private ensureView(): WebContentsView {
if (this.view) return this.view;
if (!this.window) {
throw new Error('BrowserViewManager: no window attached');
}
// One shared session across all BrowserViewManager instances in this
// process, keyed by partition name. Setting the UA on the session covers
// requests the webContents issues before the first page is loaded.
const browserSession = session.fromPartition(PARTITION);
browserSession.setUserAgent(SPOOF_UA);
const view = new WebContentsView({
webPreferences: {
session: browserSession,
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
},
});
// Also set UA on the webContents directly — belt-and-braces for sites
// that inspect the request-level UA vs the navigator UA.
view.webContents.setUserAgent(SPOOF_UA);
this.wireEvents(view);
this.view = view;
return view;
}
private wireEvents(view: WebContentsView): void {
const wc = view.webContents;
const emit = () => this.emit('state-updated', this.snapshotState());
wc.on('did-navigate', emit);
wc.on('did-navigate-in-page', emit);
wc.on('did-start-loading', emit);
wc.on('did-stop-loading', emit);
wc.on('did-finish-load', emit);
wc.on('did-fail-load', emit);
wc.on('page-title-updated', emit);
// Pop-ups / target="_blank" — hand off to the OS browser for now.
// The embedded pane is single-tab in v1.
wc.setWindowOpenHandler(({ url }) => {
void shell.openExternal(url);
return { action: 'deny' };
});
}
setVisible(visible: boolean): void {
if (!this.window) return;
const view = visible ? this.ensureView() : this.view;
if (!view) return;
const contentView = this.window.contentView;
if (visible) {
if (!this.attached) {
contentView.addChildView(view);
this.attached = true;
}
view.setBounds(this.bounds);
this.visible = true;
// First-time load — land on a useful page rather than about:blank.
const currentUrl = view.webContents.getURL();
if (!currentUrl || currentUrl === 'about:blank') {
void view.webContents.loadURL(HOME_URL);
}
} else {
if (this.attached) {
contentView.removeChildView(view);
this.attached = false;
}
this.visible = false;
}
}
setBounds(bounds: BrowserBounds): void {
this.bounds = bounds;
if (this.view && this.visible) {
this.view.setBounds(bounds);
}
}
async navigate(rawUrl: string): Promise<{ ok: boolean; error?: string }> {
try {
const view = this.ensureView();
// If the user typed "example.com" without a scheme, assume https.
// Schemes are already filtered at the IPC boundary, so we know it's
// not file://, javascript:, etc.
let url = rawUrl.trim();
if (!/^[a-z][a-z0-9+.-]*:/i.test(url)) {
url = `https://${url}`;
}
await view.webContents.loadURL(url);
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
back(): { ok: boolean } {
if (!this.view) return { ok: false };
const history = this.view.webContents.navigationHistory;
if (!history.canGoBack()) return { ok: false };
history.goBack();
return { ok: true };
}
forward(): { ok: boolean } {
if (!this.view) return { ok: false };
const history = this.view.webContents.navigationHistory;
if (!history.canGoForward()) return { ok: false };
history.goForward();
return { ok: true };
}
reload(): void {
if (!this.view) return;
this.view.webContents.reload();
}
getState(): BrowserState {
return this.snapshotState();
}
private snapshotState(): BrowserState {
if (!this.view) return { ...EMPTY_STATE };
const wc = this.view.webContents;
return {
url: wc.getURL(),
title: wc.getTitle(),
canGoBack: wc.navigationHistory.canGoBack(),
canGoForward: wc.navigationHistory.canGoForward(),
loading: wc.isLoading(),
};
}
}
export const browserViewManager = new BrowserViewManager();