mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-24 20:28:16 +02:00
browser initial commit
This commit is contained in:
parent
1a7dce35c8
commit
40b07ad68f
7 changed files with 666 additions and 11 deletions
69
apps/x/apps/main/src/browser/ipc.ts
Normal file
69
apps/x/apps/main/src/browser/ipc.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { BrowserWindow } from 'electron';
|
||||
import { ipc } from '@x/shared';
|
||||
import { browserViewManager, type BrowserState } from './view.js';
|
||||
|
||||
type IPCChannels = ipc.IPCChannels;
|
||||
|
||||
type InvokeHandler<K extends keyof IPCChannels> = (
|
||||
event: Electron.IpcMainInvokeEvent,
|
||||
args: IPCChannels[K]['req'],
|
||||
) => IPCChannels[K]['res'] | Promise<IPCChannels[K]['res']>;
|
||||
|
||||
type BrowserHandlers = {
|
||||
'browser:setBounds': InvokeHandler<'browser:setBounds'>;
|
||||
'browser:setVisible': InvokeHandler<'browser:setVisible'>;
|
||||
'browser:navigate': InvokeHandler<'browser:navigate'>;
|
||||
'browser:back': InvokeHandler<'browser:back'>;
|
||||
'browser:forward': InvokeHandler<'browser:forward'>;
|
||||
'browser:reload': InvokeHandler<'browser:reload'>;
|
||||
'browser:getState': InvokeHandler<'browser:getState'>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Browser-specific IPC handlers, exported as a plain object so they can be
|
||||
* spread into the main `registerIpcHandlers({...})` call in ipc.ts. This
|
||||
* mirrors the convention of keeping feature handlers flat and namespaced by
|
||||
* channel prefix (`browser:*`).
|
||||
*/
|
||||
export const browserIpcHandlers: BrowserHandlers = {
|
||||
'browser:setBounds': async (_event, args) => {
|
||||
browserViewManager.setBounds(args);
|
||||
return { ok: true };
|
||||
},
|
||||
'browser:setVisible': async (_event, args) => {
|
||||
browserViewManager.setVisible(args.visible);
|
||||
return { ok: true };
|
||||
},
|
||||
'browser:navigate': async (_event, args) => {
|
||||
return browserViewManager.navigate(args.url);
|
||||
},
|
||||
'browser:back': async () => {
|
||||
return browserViewManager.back();
|
||||
},
|
||||
'browser:forward': async () => {
|
||||
return browserViewManager.forward();
|
||||
},
|
||||
'browser:reload': async () => {
|
||||
browserViewManager.reload();
|
||||
return { ok: true };
|
||||
},
|
||||
'browser:getState': async () => {
|
||||
return browserViewManager.getState();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Wire the BrowserViewManager's state-updated event to all renderer windows
|
||||
* as a `browser:didUpdateState` push. Must be called once after the main
|
||||
* window is created so the manager has a window to attach to.
|
||||
*/
|
||||
export function setupBrowserEventForwarding(): void {
|
||||
browserViewManager.on('state-updated', (state: BrowserState) => {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
for (const win of windows) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
win.webContents.send('browser:didUpdateState', state);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
207
apps/x/apps/main/src/browser/view.ts
Normal file
207
apps/x/apps/main/src/browser/view.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
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();
|
||||
|
|
@ -44,6 +44,7 @@ import { getBillingInfo } from '@x/core/dist/billing/billing.js';
|
|||
import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
|
||||
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
||||
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||
import { browserIpcHandlers } from './browser/ipc.js';
|
||||
|
||||
/**
|
||||
* Convert markdown to a styled HTML document for PDF/DOCX export.
|
||||
|
|
@ -759,5 +760,7 @@ export function setupIpcHandlers() {
|
|||
'billing:getInfo': async () => {
|
||||
return await getBillingInfo();
|
||||
},
|
||||
// Embedded browser handlers (WebContentsView + navigation)
|
||||
...browserIpcHandlers,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ import started from "electron-squirrel-startup";
|
|||
import { execSync, exec, execFileSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
|
||||
import { browserViewManager } from "./browser/view.js";
|
||||
import { setupBrowserEventForwarding } from "./browser/ipc.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
|
|
@ -169,6 +171,10 @@ function createWindow() {
|
|||
}
|
||||
});
|
||||
|
||||
// Attach the embedded browser pane manager to this window.
|
||||
// The WebContentsView is created lazily on first `browser:setVisible`.
|
||||
browserViewManager.attach(win);
|
||||
|
||||
if (app.isPackaged) {
|
||||
win.loadURL("app://-/index.html");
|
||||
} else {
|
||||
|
|
@ -210,6 +216,7 @@ app.whenReady().then(async () => {
|
|||
await initConfigs();
|
||||
|
||||
setupIpcHandlers();
|
||||
setupBrowserEventForwarding();
|
||||
|
||||
createWindow();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue