mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-27 20:29:44 +02:00
added tabs
This commit is contained in:
parent
76dff027f0
commit
90aa4c2f40
4 changed files with 436 additions and 232 deletions
|
|
@ -12,6 +12,9 @@ type InvokeHandler<K extends keyof IPCChannels> = (
|
||||||
type BrowserHandlers = {
|
type BrowserHandlers = {
|
||||||
'browser:setBounds': InvokeHandler<'browser:setBounds'>;
|
'browser:setBounds': InvokeHandler<'browser:setBounds'>;
|
||||||
'browser:setVisible': InvokeHandler<'browser:setVisible'>;
|
'browser:setVisible': InvokeHandler<'browser:setVisible'>;
|
||||||
|
'browser:newTab': InvokeHandler<'browser:newTab'>;
|
||||||
|
'browser:switchTab': InvokeHandler<'browser:switchTab'>;
|
||||||
|
'browser:closeTab': InvokeHandler<'browser:closeTab'>;
|
||||||
'browser:navigate': InvokeHandler<'browser:navigate'>;
|
'browser:navigate': InvokeHandler<'browser:navigate'>;
|
||||||
'browser:back': InvokeHandler<'browser:back'>;
|
'browser:back': InvokeHandler<'browser:back'>;
|
||||||
'browser:forward': InvokeHandler<'browser:forward'>;
|
'browser:forward': InvokeHandler<'browser:forward'>;
|
||||||
|
|
@ -34,6 +37,15 @@ export const browserIpcHandlers: BrowserHandlers = {
|
||||||
browserViewManager.setVisible(args.visible);
|
browserViewManager.setVisible(args.visible);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
|
'browser:newTab': async (_event, args) => {
|
||||||
|
return browserViewManager.newTab(args.url);
|
||||||
|
},
|
||||||
|
'browser:switchTab': async (_event, args) => {
|
||||||
|
return browserViewManager.switchTab(args.tabId);
|
||||||
|
},
|
||||||
|
'browser:closeTab': async (_event, args) => {
|
||||||
|
return browserViewManager.closeTab(args.tabId);
|
||||||
|
},
|
||||||
'browser:navigate': async (_event, args) => {
|
'browser:navigate': async (_event, args) => {
|
||||||
return browserViewManager.navigate(args.url);
|
return browserViewManager.navigate(args.url);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
import { BrowserWindow, WebContentsView, session, shell } from 'electron';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { BrowserWindow, WebContentsView, session, shell, type Session } from 'electron';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Embedded browser pane implementation.
|
* Embedded browser pane implementation.
|
||||||
*
|
*
|
||||||
* A single lazy-created WebContentsView is hosted on top of the main
|
* Each browser tab owns its own WebContentsView. Only the active tab's view is
|
||||||
* BrowserWindow's contentView, positioned by pixel bounds the renderer
|
* attached to the main window at a time, but inactive tabs keep their own page
|
||||||
* computes via ResizeObserver.
|
* history and loaded state in memory so switching tabs feels immediate.
|
||||||
*
|
*
|
||||||
* The view uses a persistent session partition so cookies/localStorage/
|
* All tabs share one persistent session partition so cookies/localStorage/
|
||||||
* form-fill state survive app restarts, and spoofs a standard Chrome UA so
|
* form-fill state survive app restarts, and the browser surface spoofs a
|
||||||
* sites like Google (OAuth) don't reject it as an embedded browser.
|
* standard Chrome UA so sites like Google (OAuth) don't reject it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const PARTITION = 'persist:rowboat-browser';
|
const PARTITION = 'persist:rowboat-browser';
|
||||||
|
|
@ -29,7 +30,8 @@ export interface BrowserBounds {
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrowserState {
|
export interface BrowserTabState {
|
||||||
|
id: string;
|
||||||
url: string;
|
url: string;
|
||||||
title: string;
|
title: string;
|
||||||
canGoBack: boolean;
|
canGoBack: boolean;
|
||||||
|
|
@ -37,18 +39,28 @@ export interface BrowserState {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BrowserState {
|
||||||
|
activeTabId: string | null;
|
||||||
|
tabs: BrowserTabState[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrowserTab = {
|
||||||
|
id: string;
|
||||||
|
view: WebContentsView;
|
||||||
|
};
|
||||||
|
|
||||||
const EMPTY_STATE: BrowserState = {
|
const EMPTY_STATE: BrowserState = {
|
||||||
url: '',
|
activeTabId: null,
|
||||||
title: '',
|
tabs: [],
|
||||||
canGoBack: false,
|
|
||||||
canGoForward: false,
|
|
||||||
loading: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export class BrowserViewManager extends EventEmitter {
|
export class BrowserViewManager extends EventEmitter {
|
||||||
private window: BrowserWindow | null = null;
|
private window: BrowserWindow | null = null;
|
||||||
private view: WebContentsView | null = null;
|
private browserSession: Session | null = null;
|
||||||
private attached = false;
|
private tabs = new Map<string, BrowserTab>();
|
||||||
|
private tabOrder: string[] = [];
|
||||||
|
private activeTabId: string | null = null;
|
||||||
|
private attachedTabId: string | null = null;
|
||||||
private visible = false;
|
private visible = false;
|
||||||
private bounds: BrowserBounds = { x: 0, y: 0, width: 0, height: 0 };
|
private bounds: BrowserBounds = { x: 0, y: 0, width: 0, height: 0 };
|
||||||
|
|
||||||
|
|
@ -56,54 +68,78 @@ export class BrowserViewManager extends EventEmitter {
|
||||||
this.window = window;
|
this.window = window;
|
||||||
window.on('closed', () => {
|
window.on('closed', () => {
|
||||||
this.window = null;
|
this.window = null;
|
||||||
this.view = null;
|
this.browserSession = null;
|
||||||
this.attached = false;
|
this.tabs.clear();
|
||||||
|
this.tabOrder = [];
|
||||||
|
this.activeTabId = null;
|
||||||
|
this.attachedTabId = null;
|
||||||
this.visible = false;
|
this.visible = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureView(): WebContentsView {
|
private getSession(): Session {
|
||||||
if (this.view) return this.view;
|
if (this.browserSession) return this.browserSession;
|
||||||
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);
|
const browserSession = session.fromPartition(PARTITION);
|
||||||
browserSession.setUserAgent(SPOOF_UA);
|
browserSession.setUserAgent(SPOOF_UA);
|
||||||
|
this.browserSession = browserSession;
|
||||||
|
return browserSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitState(): void {
|
||||||
|
this.emit('state-updated', this.snapshotState());
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTab(tabId: string | null): BrowserTab | null {
|
||||||
|
if (!tabId) return null;
|
||||||
|
return this.tabs.get(tabId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getActiveTab(): BrowserTab | null {
|
||||||
|
return this.getTab(this.activeTabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeUrl(rawUrl: string): string {
|
||||||
|
let url = rawUrl.trim();
|
||||||
|
if (!/^[a-z][a-z0-9+.-]*:/i.test(url)) {
|
||||||
|
url = `https://${url}`;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isEmbeddedTabUrl(url: string): boolean {
|
||||||
|
return /^https?:\/\//i.test(url) || url === 'about:blank';
|
||||||
|
}
|
||||||
|
|
||||||
|
private createView(tabId: string): WebContentsView {
|
||||||
const view = new WebContentsView({
|
const view = new WebContentsView({
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
session: browserSession,
|
session: this.getSession(),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
nodeIntegration: false,
|
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);
|
view.webContents.setUserAgent(SPOOF_UA);
|
||||||
|
this.wireEvents(tabId, view);
|
||||||
this.wireEvents(view);
|
|
||||||
this.view = view;
|
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
private wireEvents(view: WebContentsView): void {
|
private wireEvents(tabId: string, view: WebContentsView): void {
|
||||||
const wc = view.webContents;
|
const wc = view.webContents;
|
||||||
|
|
||||||
const emit = () => this.emit('state-updated', this.snapshotState());
|
const emit = () => this.emitState();
|
||||||
|
|
||||||
// Defensively re-apply bounds on navigation events. Electron's
|
// Electron occasionally drops WebContentsView layout on navigation.
|
||||||
// WebContentsView is known to occasionally reset its laid-out bounds on
|
// Re-applying the cached bounds is cheap and keeps the active tab pinned
|
||||||
// navigation (a behavior carried over from the deprecated BrowserView),
|
// to the renderer-computed viewport.
|
||||||
// which manifests as the view "spilling" outside its intended pane.
|
|
||||||
// Re-applying after every navigation/load event is cheap and idempotent.
|
|
||||||
const reapplyBounds = () => {
|
const reapplyBounds = () => {
|
||||||
if (this.attached && this.bounds.width > 0 && this.bounds.height > 0) {
|
if (
|
||||||
|
this.attachedTabId === tabId &&
|
||||||
|
this.visible &&
|
||||||
|
this.bounds.width > 0 &&
|
||||||
|
this.bounds.height > 0
|
||||||
|
) {
|
||||||
view.setBounds(this.bounds);
|
view.setBounds(this.bounds);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -118,105 +154,20 @@ export class BrowserViewManager extends EventEmitter {
|
||||||
wc.on('did-fail-load', () => { reapplyBounds(); emit(); });
|
wc.on('did-fail-load', () => { reapplyBounds(); emit(); });
|
||||||
wc.on('page-title-updated', 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 }) => {
|
wc.setWindowOpenHandler(({ url }) => {
|
||||||
void shell.openExternal(url);
|
if (this.isEmbeddedTabUrl(url)) {
|
||||||
|
void this.newTab(url);
|
||||||
|
} else {
|
||||||
|
void shell.openExternal(url);
|
||||||
|
}
|
||||||
return { action: 'deny' };
|
return { action: 'deny' };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setVisible(visible: boolean): void {
|
private snapshotTabState(tab: BrowserTab): BrowserTabState {
|
||||||
if (!this.window) return;
|
const wc = tab.view.webContents;
|
||||||
const view = visible ? this.ensureView() : this.view;
|
|
||||||
if (!view) return;
|
|
||||||
|
|
||||||
const contentView = this.window.contentView;
|
|
||||||
|
|
||||||
if (visible) {
|
|
||||||
// Order: attach FIRST, then setBounds. Calling setBounds on an
|
|
||||||
// unattached WebContentsView can leave it in a state where the next
|
|
||||||
// attach uses default bounds, blanking the renderer area.
|
|
||||||
if (!this.attached) {
|
|
||||||
contentView.addChildView(view);
|
|
||||||
this.attached = true;
|
|
||||||
}
|
|
||||||
// The renderer only asks us to show the view after it has pushed a
|
|
||||||
// fresh non-zero rect, so applying the cached bounds here should land
|
|
||||||
// the surface in the correct pane immediately on attach.
|
|
||||||
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;
|
|
||||||
// Only apply to the view if it's currently attached and visible.
|
|
||||||
// Applying to a detached view appears to put Electron's WebContentsView
|
|
||||||
// into a bad state on subsequent attach (renderer area blanks out).
|
|
||||||
if (this.view && this.attached && 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 {
|
return {
|
||||||
|
id: tab.id,
|
||||||
url: wc.getURL(),
|
url: wc.getURL(),
|
||||||
title: wc.getTitle(),
|
title: wc.getTitle(),
|
||||||
canGoBack: wc.navigationHistory.canGoBack(),
|
canGoBack: wc.navigationHistory.canGoBack(),
|
||||||
|
|
@ -224,6 +175,189 @@ export class BrowserViewManager extends EventEmitter {
|
||||||
loading: wc.isLoading(),
|
loading: wc.isLoading(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private syncAttachedView(): void {
|
||||||
|
if (!this.window) return;
|
||||||
|
|
||||||
|
const contentView = this.window.contentView;
|
||||||
|
const activeTab = this.getActiveTab();
|
||||||
|
|
||||||
|
if (!this.visible || !activeTab) {
|
||||||
|
const attachedTab = this.getTab(this.attachedTabId);
|
||||||
|
if (attachedTab) {
|
||||||
|
contentView.removeChildView(attachedTab.view);
|
||||||
|
}
|
||||||
|
this.attachedTabId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.attachedTabId && this.attachedTabId !== activeTab.id) {
|
||||||
|
const attachedTab = this.getTab(this.attachedTabId);
|
||||||
|
if (attachedTab) {
|
||||||
|
contentView.removeChildView(attachedTab.view);
|
||||||
|
}
|
||||||
|
this.attachedTabId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.attachedTabId !== activeTab.id) {
|
||||||
|
contentView.addChildView(activeTab.view);
|
||||||
|
this.attachedTabId = activeTab.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.bounds.width > 0 && this.bounds.height > 0) {
|
||||||
|
activeTab.view.setBounds(this.bounds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createTab(initialUrl: string): BrowserTab {
|
||||||
|
if (!this.window) {
|
||||||
|
throw new Error('BrowserViewManager: no window attached');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabId = randomUUID();
|
||||||
|
const tab: BrowserTab = {
|
||||||
|
id: tabId,
|
||||||
|
view: this.createView(tabId),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.tabs.set(tabId, tab);
|
||||||
|
this.tabOrder.push(tabId);
|
||||||
|
this.activeTabId = tabId;
|
||||||
|
this.syncAttachedView();
|
||||||
|
this.emitState();
|
||||||
|
|
||||||
|
const targetUrl =
|
||||||
|
initialUrl === 'about:blank'
|
||||||
|
? HOME_URL
|
||||||
|
: this.normalizeUrl(initialUrl);
|
||||||
|
void tab.view.webContents.loadURL(targetUrl).catch(() => {
|
||||||
|
this.emitState();
|
||||||
|
});
|
||||||
|
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureInitialTab(): BrowserTab {
|
||||||
|
const activeTab = this.getActiveTab();
|
||||||
|
if (activeTab) return activeTab;
|
||||||
|
return this.createTab(HOME_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
private destroyTab(tab: BrowserTab): void {
|
||||||
|
tab.view.webContents.removeAllListeners();
|
||||||
|
if (!tab.view.webContents.isDestroyed()) {
|
||||||
|
tab.view.webContents.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisible(visible: boolean): void {
|
||||||
|
this.visible = visible;
|
||||||
|
if (visible) {
|
||||||
|
this.ensureInitialTab();
|
||||||
|
}
|
||||||
|
this.syncAttachedView();
|
||||||
|
}
|
||||||
|
|
||||||
|
setBounds(bounds: BrowserBounds): void {
|
||||||
|
this.bounds = bounds;
|
||||||
|
const activeTab = this.getActiveTab();
|
||||||
|
if (activeTab && this.attachedTabId === activeTab.id && this.visible) {
|
||||||
|
activeTab.view.setBounds(bounds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async newTab(rawUrl?: string): Promise<{ ok: boolean; tabId?: string; error?: string }> {
|
||||||
|
try {
|
||||||
|
const tab = this.createTab(rawUrl?.trim() ? rawUrl : HOME_URL);
|
||||||
|
return { ok: true, tabId: tab.id };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switchTab(tabId: string): { ok: boolean } {
|
||||||
|
if (!this.tabs.has(tabId)) return { ok: false };
|
||||||
|
if (this.activeTabId === tabId) return { ok: true };
|
||||||
|
this.activeTabId = tabId;
|
||||||
|
this.syncAttachedView();
|
||||||
|
this.emitState();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
closeTab(tabId: string): { ok: boolean } {
|
||||||
|
const tab = this.tabs.get(tabId);
|
||||||
|
if (!tab) return { ok: false };
|
||||||
|
if (this.tabOrder.length <= 1) return { ok: false };
|
||||||
|
|
||||||
|
const closingIndex = this.tabOrder.indexOf(tabId);
|
||||||
|
const nextActiveTabId =
|
||||||
|
this.activeTabId === tabId
|
||||||
|
? this.tabOrder[closingIndex + 1] ?? this.tabOrder[closingIndex - 1] ?? null
|
||||||
|
: this.activeTabId;
|
||||||
|
|
||||||
|
if (this.attachedTabId === tabId && this.window) {
|
||||||
|
this.window.contentView.removeChildView(tab.view);
|
||||||
|
this.attachedTabId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tabs.delete(tabId);
|
||||||
|
this.tabOrder = this.tabOrder.filter((id) => id !== tabId);
|
||||||
|
this.activeTabId = nextActiveTabId;
|
||||||
|
this.destroyTab(tab);
|
||||||
|
this.syncAttachedView();
|
||||||
|
this.emitState();
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async navigate(rawUrl: string): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const activeTab = this.getActiveTab() ?? this.ensureInitialTab();
|
||||||
|
await activeTab.view.webContents.loadURL(this.normalizeUrl(rawUrl));
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
back(): { ok: boolean } {
|
||||||
|
const activeTab = this.getActiveTab();
|
||||||
|
if (!activeTab) return { ok: false };
|
||||||
|
const history = activeTab.view.webContents.navigationHistory;
|
||||||
|
if (!history.canGoBack()) return { ok: false };
|
||||||
|
history.goBack();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
forward(): { ok: boolean } {
|
||||||
|
const activeTab = this.getActiveTab();
|
||||||
|
if (!activeTab) return { ok: false };
|
||||||
|
const history = activeTab.view.webContents.navigationHistory;
|
||||||
|
if (!history.canGoForward()) return { ok: false };
|
||||||
|
history.goForward();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
reload(): void {
|
||||||
|
const activeTab = this.getActiveTab();
|
||||||
|
if (!activeTab) return;
|
||||||
|
activeTab.view.webContents.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): BrowserState {
|
||||||
|
return this.snapshotState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private snapshotState(): BrowserState {
|
||||||
|
if (this.tabOrder.length === 0) return { ...EMPTY_STATE };
|
||||||
|
return {
|
||||||
|
activeTabId: this.activeTabId,
|
||||||
|
tabs: this.tabOrder
|
||||||
|
.map((tabId) => this.tabs.get(tabId))
|
||||||
|
.filter((tab): tab is BrowserTab => tab != null)
|
||||||
|
.map((tab) => this.snapshotTabState(tab)),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const browserViewManager = new BrowserViewManager();
|
export const browserViewManager = new BrowserViewManager();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { ArrowLeft, ArrowRight, RotateCw, Loader2, X } from 'lucide-react'
|
import { ArrowLeft, ArrowRight, Loader2, Plus, RotateCw, X } from 'lucide-react'
|
||||||
|
|
||||||
|
import { TabBar } from '@/components/tab-bar'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -9,11 +10,12 @@ import { cn } from '@/lib/utils'
|
||||||
* Renders a transparent placeholder div whose bounds are reported to the
|
* Renders a transparent placeholder div whose bounds are reported to the
|
||||||
* main process via `browser:setBounds`. The actual browsing surface is an
|
* main process via `browser:setBounds`. The actual browsing surface is an
|
||||||
* Electron WebContentsView layered on top of the renderer by the main
|
* Electron WebContentsView layered on top of the renderer by the main
|
||||||
* process — this component only owns the chrome (address bar, nav, spinner)
|
* process — this component only owns the chrome (tabs, address bar, nav
|
||||||
* and the sizing/visibility lifecycle.
|
* buttons) and the sizing/visibility lifecycle.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface BrowserState {
|
interface BrowserTabState {
|
||||||
|
id: string
|
||||||
url: string
|
url: string
|
||||||
title: string
|
title: string
|
||||||
canGoBack: boolean
|
canGoBack: boolean
|
||||||
|
|
@ -21,58 +23,73 @@ interface BrowserState {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_STATE: BrowserState = {
|
interface BrowserState {
|
||||||
url: '',
|
activeTabId: string | null
|
||||||
title: '',
|
tabs: BrowserTabState[]
|
||||||
canGoBack: false,
|
}
|
||||||
canGoForward: false,
|
|
||||||
loading: false,
|
const EMPTY_STATE: BrowserState = {
|
||||||
|
activeTabId: null,
|
||||||
|
tabs: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Placeholder height we subtract from the inner bounds for the chrome row. */
|
|
||||||
const CHROME_HEIGHT = 40
|
const CHROME_HEIGHT = 40
|
||||||
|
|
||||||
interface BrowserPaneProps {
|
interface BrowserPaneProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getActiveTab = (state: BrowserState) =>
|
||||||
|
state.tabs.find((tab) => tab.id === state.activeTabId) ?? null
|
||||||
|
|
||||||
|
const getBrowserTabTitle = (tab: BrowserTabState) => {
|
||||||
|
const title = tab.title.trim()
|
||||||
|
if (title) return title
|
||||||
|
const url = tab.url.trim()
|
||||||
|
if (!url) return 'New tab'
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
return parsed.hostname || parsed.href
|
||||||
|
} catch {
|
||||||
|
return url.replace(/^https?:\/\//i, '') || 'New tab'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function BrowserPane({ onClose }: BrowserPaneProps) {
|
export function BrowserPane({ onClose }: BrowserPaneProps) {
|
||||||
const [state, setState] = useState<BrowserState>(EMPTY_STATE)
|
const [state, setState] = useState<BrowserState>(EMPTY_STATE)
|
||||||
const [addressValue, setAddressValue] = useState('')
|
const [addressValue, setAddressValue] = useState('')
|
||||||
|
|
||||||
|
const activeTabIdRef = useRef<string | null>(null)
|
||||||
const addressFocusedRef = useRef(false)
|
const addressFocusedRef = useRef(false)
|
||||||
const viewportRef = useRef<HTMLDivElement>(null)
|
const viewportRef = useRef<HTMLDivElement>(null)
|
||||||
const lastBoundsRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null)
|
const lastBoundsRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null)
|
||||||
const viewVisibleRef = useRef(false)
|
const viewVisibleRef = useRef(false)
|
||||||
|
|
||||||
// ── Subscribe to state updates from main ──────────────────────────────────
|
const activeTab = getActiveTab(state)
|
||||||
useEffect(() => {
|
|
||||||
const cleanup = window.ipc.on('browser:didUpdateState', (incoming) => {
|
const applyState = useCallback((next: BrowserState) => {
|
||||||
const next = incoming as BrowserState
|
const previousActiveTabId = activeTabIdRef.current
|
||||||
setState(next)
|
activeTabIdRef.current = next.activeTabId
|
||||||
if (!addressFocusedRef.current) {
|
setState(next)
|
||||||
setAddressValue(next.url)
|
|
||||||
}
|
const nextActiveTab = getActiveTab(next)
|
||||||
})
|
if (!addressFocusedRef.current || next.activeTabId !== previousActiveTabId) {
|
||||||
// Kick an initial state fetch so the chrome reflects wherever the view
|
setAddressValue(nextActiveTab?.url ?? '')
|
||||||
// was left from a previous session (or empty, if never loaded).
|
}
|
||||||
void window.ipc.invoke('browser:getState', null).then((initial) => {
|
|
||||||
const next = initial as BrowserState
|
|
||||||
setState(next)
|
|
||||||
if (!addressFocusedRef.current) {
|
|
||||||
setAddressValue(next.url)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return cleanup
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// ── Bounds tracking ───────────────────────────────────────────────────────
|
useEffect(() => {
|
||||||
// The main process needs pixel-accurate bounds *relative to the window
|
const cleanup = window.ipc.on('browser:didUpdateState', (incoming) => {
|
||||||
// content area*. getBoundingClientRect() returns viewport-relative coords,
|
applyState(incoming as BrowserState)
|
||||||
// which in Electron with hiddenInset titleBar equal content-area coords.
|
})
|
||||||
//
|
|
||||||
// Reads layout synchronously and posts an IPC update only when the rect
|
void window.ipc.invoke('browser:getState', null).then((initial) => {
|
||||||
// actually changed. Cheap enough to call from a RAF loop or observer.
|
applyState(initial as BrowserState)
|
||||||
|
})
|
||||||
|
|
||||||
|
return cleanup
|
||||||
|
}, [applyState])
|
||||||
|
|
||||||
const setViewVisible = useCallback((visible: boolean) => {
|
const setViewVisible = useCallback((visible: boolean) => {
|
||||||
if (viewVisibleRef.current === visible) return
|
if (viewVisibleRef.current === visible) return
|
||||||
viewVisibleRef.current = visible
|
viewVisibleRef.current = visible
|
||||||
|
|
@ -82,6 +99,7 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
||||||
const measureBounds = useCallback(() => {
|
const measureBounds = useCallback(() => {
|
||||||
const el = viewportRef.current
|
const el = viewportRef.current
|
||||||
if (!el) return null
|
if (!el) return null
|
||||||
|
|
||||||
const zoomFactor = Math.max(window.electronUtils.getZoomFactor(), 0.01)
|
const zoomFactor = Math.max(window.electronUtils.getZoomFactor(), 0.01)
|
||||||
const rect = el.getBoundingClientRect()
|
const rect = el.getBoundingClientRect()
|
||||||
const chatSidebar = el.ownerDocument.querySelector<HTMLElement>('[data-chat-sidebar-root]')
|
const chatSidebar = el.ownerDocument.querySelector<HTMLElement>('[data-chat-sidebar-root]')
|
||||||
|
|
@ -89,25 +107,25 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
||||||
const clampedRightCss = chatSidebarRect && chatSidebarRect.width > 0
|
const clampedRightCss = chatSidebarRect && chatSidebarRect.width > 0
|
||||||
? Math.min(rect.right, chatSidebarRect.left)
|
? Math.min(rect.right, chatSidebarRect.left)
|
||||||
: rect.right
|
: rect.right
|
||||||
|
|
||||||
// `getBoundingClientRect()` is reported in zoomed CSS pixels. Electron's
|
// `getBoundingClientRect()` is reported in zoomed CSS pixels. Electron's
|
||||||
// native view bounds are in unzoomed window coordinates, so we have to
|
// native view bounds are in unzoomed window coordinates, so convert back
|
||||||
// convert back using the renderer zoom factor.
|
// using the renderer zoom factor before calling into the main process.
|
||||||
const left = Math.ceil(rect.left * zoomFactor)
|
const left = Math.ceil(rect.left * zoomFactor)
|
||||||
const top = Math.ceil(rect.top * zoomFactor)
|
const top = Math.ceil(rect.top * zoomFactor)
|
||||||
const right = Math.floor(clampedRightCss * zoomFactor)
|
const right = Math.floor(clampedRightCss * zoomFactor)
|
||||||
const bottom = Math.floor(rect.bottom * zoomFactor)
|
const bottom = Math.floor(rect.bottom * zoomFactor)
|
||||||
const width = right - left
|
const width = right - left
|
||||||
const height = bottom - top
|
const height = bottom - top
|
||||||
// A zero-sized rect means the element isn't laid out yet or has been
|
|
||||||
// collapsed behind the chat pane — hide the native view in that case.
|
|
||||||
if (width <= 0 || height <= 0) return null
|
if (width <= 0 || height <= 0) return null
|
||||||
const bounds = {
|
|
||||||
|
return {
|
||||||
x: left,
|
x: left,
|
||||||
y: top,
|
y: top,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
}
|
}
|
||||||
return bounds
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const pushBounds = useCallback((bounds: { x: number; y: number; width: number; height: number }) => {
|
const pushBounds = useCallback((bounds: { x: number; y: number; width: number; height: number }) => {
|
||||||
|
|
@ -138,24 +156,10 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
||||||
return bounds
|
return bounds
|
||||||
}, [measureBounds, pushBounds, setViewVisible])
|
}, [measureBounds, pushBounds, setViewVisible])
|
||||||
|
|
||||||
// Defensively re-push bounds whenever the underlying page state changes.
|
|
||||||
// Electron's WebContentsView can drop its laid-out rect on navigation,
|
|
||||||
// and even though main re-applies on the same events, pushing from the
|
|
||||||
// renderer ensures we use the *current* layout (in case the chat sidebar
|
|
||||||
// or window resized while the page was loading).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
syncView()
|
syncView()
|
||||||
}, [state.url, state.loading, syncView])
|
}, [activeTab?.id, activeTab?.loading, activeTab?.url, syncView])
|
||||||
|
|
||||||
// ── Visibility lifecycle ──────────────────────────────────────────────────
|
|
||||||
// Order matters: push bounds FIRST so the WCV is created at the right
|
|
||||||
// position, THEN setVisible. Otherwise the view gets attached at stale
|
|
||||||
// (or zero) cached bounds and visually spills until the next bounds push.
|
|
||||||
//
|
|
||||||
// We wait one animation frame after mount so React's commit + the browser's
|
|
||||||
// layout pass for any sibling that just mounted (e.g. the chat sidebar) are
|
|
||||||
// both reflected in getBoundingClientRect. A single RAF is enough — by then
|
|
||||||
// the renderer has painted the post-commit DOM at least once.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const rafId = requestAnimationFrame(() => {
|
const rafId = requestAnimationFrame(() => {
|
||||||
|
|
@ -170,21 +174,10 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
||||||
}
|
}
|
||||||
}, [setViewVisible, syncView])
|
}, [setViewVisible, syncView])
|
||||||
|
|
||||||
// Ongoing bounds tracking. Observes everything that can move our viewport:
|
|
||||||
// - the viewport div itself (local size changes)
|
|
||||||
// - the SidebarInset ancestor (changes when chat sidebar mounts/resizes)
|
|
||||||
// - the document root (window resize, devicePixelRatio changes)
|
|
||||||
//
|
|
||||||
// ResizeObserver fires after layout but before paint, so by the time we
|
|
||||||
// call pushBounds the rect is current. Multiple observers may fire in the
|
|
||||||
// same frame — RAF-coalesce them so we only post one IPC per frame.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = viewportRef.current
|
const el = viewportRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
|
||||||
// The sidebar-inset main element is the immediate flex parent whose
|
|
||||||
// width shrinks when a sibling appears. Walking up the tree is more
|
|
||||||
// robust than passing a ref through props.
|
|
||||||
const sidebarInset = el.closest<HTMLElement>('[data-slot="sidebar-inset"]')
|
const sidebarInset = el.closest<HTMLElement>('[data-slot="sidebar-inset"]')
|
||||||
const chatSidebar = el.ownerDocument.querySelector<HTMLElement>('[data-chat-sidebar-root]')
|
const chatSidebar = el.ownerDocument.querySelector<HTMLElement>('[data-chat-sidebar-root]')
|
||||||
const documentElement = el.ownerDocument.documentElement
|
const documentElement = el.ownerDocument.documentElement
|
||||||
|
|
@ -210,7 +203,23 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
||||||
}
|
}
|
||||||
}, [syncView])
|
}, [syncView])
|
||||||
|
|
||||||
// ── Actions ───────────────────────────────────────────────────────────────
|
const handleNewTab = useCallback(() => {
|
||||||
|
void window.ipc.invoke('browser:newTab', {}).then((res) => {
|
||||||
|
const result = res as { ok: boolean; error?: string }
|
||||||
|
if (!result.ok && result.error) {
|
||||||
|
console.error('browser:newTab failed', result.error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSwitchTab = useCallback((tabId: string) => {
|
||||||
|
void window.ipc.invoke('browser:switchTab', { tabId })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCloseTab = useCallback((tabId: string) => {
|
||||||
|
void window.ipc.invoke('browser:closeTab', { tabId })
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSubmitAddress = useCallback((e: React.FormEvent) => {
|
const handleSubmitAddress = useCallback((e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const trimmed = addressValue.trim()
|
const trimmed = addressValue.trim()
|
||||||
|
|
@ -236,10 +245,27 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background">
|
||||||
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background"
|
<div className="flex h-9 shrink-0 items-stretch border-b border-border bg-sidebar">
|
||||||
>
|
<TabBar
|
||||||
{/* Chrome row: back / forward / reload / address bar */}
|
tabs={state.tabs}
|
||||||
|
activeTabId={state.activeTabId ?? ''}
|
||||||
|
getTabTitle={getBrowserTabTitle}
|
||||||
|
getTabId={(tab) => tab.id}
|
||||||
|
onSwitchTab={handleSwitchTab}
|
||||||
|
onCloseTab={handleCloseTab}
|
||||||
|
layout="scroll"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleNewTab}
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center border-l border-border text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
aria-label="New browser tab"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex h-10 shrink-0 items-center gap-1 border-b border-border bg-sidebar px-2"
|
className="flex h-10 shrink-0 items-center gap-1 border-b border-border bg-sidebar px-2"
|
||||||
style={{ minHeight: CHROME_HEIGHT }}
|
style={{ minHeight: CHROME_HEIGHT }}
|
||||||
|
|
@ -247,10 +273,10 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleBack}
|
onClick={handleBack}
|
||||||
disabled={!state.canGoBack}
|
disabled={!activeTab?.canGoBack}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
|
'flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
|
||||||
state.canGoBack ? 'hover:bg-accent hover:text-foreground' : 'opacity-40',
|
activeTab?.canGoBack ? 'hover:bg-accent hover:text-foreground' : 'opacity-40',
|
||||||
)}
|
)}
|
||||||
aria-label="Back"
|
aria-label="Back"
|
||||||
>
|
>
|
||||||
|
|
@ -259,10 +285,10 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleForward}
|
onClick={handleForward}
|
||||||
disabled={!state.canGoForward}
|
disabled={!activeTab?.canGoForward}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
|
'flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
|
||||||
state.canGoForward ? 'hover:bg-accent hover:text-foreground' : 'opacity-40',
|
activeTab?.canGoForward ? 'hover:bg-accent hover:text-foreground' : 'opacity-40',
|
||||||
)}
|
)}
|
||||||
aria-label="Forward"
|
aria-label="Forward"
|
||||||
>
|
>
|
||||||
|
|
@ -271,10 +297,14 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleReload}
|
onClick={handleReload}
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
disabled={!activeTab}
|
||||||
|
className={cn(
|
||||||
|
'flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
|
||||||
|
activeTab ? 'hover:bg-accent hover:text-foreground' : 'opacity-40',
|
||||||
|
)}
|
||||||
aria-label="Reload"
|
aria-label="Reload"
|
||||||
>
|
>
|
||||||
{state.loading ? (
|
{activeTab?.loading ? (
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<RotateCw className="size-4" />
|
<RotateCw className="size-4" />
|
||||||
|
|
@ -291,7 +321,7 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
||||||
}}
|
}}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
addressFocusedRef.current = false
|
addressFocusedRef.current = false
|
||||||
setAddressValue(state.url)
|
setAddressValue(activeTab?.url ?? '')
|
||||||
}}
|
}}
|
||||||
placeholder="Enter URL or search..."
|
placeholder="Enter URL or search..."
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -304,11 +334,6 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
||||||
autoCapitalize="off"
|
autoCapitalize="off"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
{state.title && (
|
|
||||||
<div className="hidden max-w-[220px] shrink-0 truncate pl-2 text-xs text-muted-foreground sm:block">
|
|
||||||
{state.title}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|
@ -319,9 +344,6 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Viewport placeholder — the WebContentsView is layered on top of this
|
|
||||||
area from the main process. The div itself stays transparent so the
|
|
||||||
user sees the web page, not a React element. */}
|
|
||||||
<div
|
<div
|
||||||
ref={viewportRef}
|
ref={viewportRef}
|
||||||
className="relative min-h-0 min-w-0 flex-1"
|
className="relative min-h-0 min-w-0 flex-1"
|
||||||
|
|
|
||||||
|
|
@ -574,6 +574,34 @@ const ipcSchemas = {
|
||||||
req: z.object({ visible: z.boolean() }),
|
req: z.object({ visible: z.boolean() }),
|
||||||
res: z.object({ ok: z.literal(true) }),
|
res: z.object({ ok: z.literal(true) }),
|
||||||
},
|
},
|
||||||
|
'browser:newTab': {
|
||||||
|
req: z.object({
|
||||||
|
url: z.string().min(1).refine(
|
||||||
|
(u) => {
|
||||||
|
const lower = u.trim().toLowerCase();
|
||||||
|
if (lower.startsWith('javascript:')) return false;
|
||||||
|
if (lower.startsWith('file://')) return false;
|
||||||
|
if (lower.startsWith('chrome://')) return false;
|
||||||
|
if (lower.startsWith('chrome-extension://')) return false;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{ message: 'Unsafe URL scheme' },
|
||||||
|
).optional(),
|
||||||
|
}),
|
||||||
|
res: z.object({
|
||||||
|
ok: z.boolean(),
|
||||||
|
tabId: z.string().optional(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'browser:switchTab': {
|
||||||
|
req: z.object({ tabId: z.string().min(1) }),
|
||||||
|
res: z.object({ ok: z.boolean() }),
|
||||||
|
},
|
||||||
|
'browser:closeTab': {
|
||||||
|
req: z.object({ tabId: z.string().min(1) }),
|
||||||
|
res: z.object({ ok: z.boolean() }),
|
||||||
|
},
|
||||||
'browser:navigate': {
|
'browser:navigate': {
|
||||||
req: z.object({
|
req: z.object({
|
||||||
url: z.string().min(1).refine(
|
url: z.string().min(1).refine(
|
||||||
|
|
@ -608,20 +636,28 @@ const ipcSchemas = {
|
||||||
'browser:getState': {
|
'browser:getState': {
|
||||||
req: z.null(),
|
req: z.null(),
|
||||||
res: z.object({
|
res: z.object({
|
||||||
url: z.string(),
|
activeTabId: z.string().nullable(),
|
||||||
title: z.string(),
|
tabs: z.array(z.object({
|
||||||
canGoBack: z.boolean(),
|
id: z.string(),
|
||||||
canGoForward: z.boolean(),
|
url: z.string(),
|
||||||
loading: z.boolean(),
|
title: z.string(),
|
||||||
|
canGoBack: z.boolean(),
|
||||||
|
canGoForward: z.boolean(),
|
||||||
|
loading: z.boolean(),
|
||||||
|
})),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
'browser:didUpdateState': {
|
'browser:didUpdateState': {
|
||||||
req: z.object({
|
req: z.object({
|
||||||
url: z.string(),
|
activeTabId: z.string().nullable(),
|
||||||
title: z.string(),
|
tabs: z.array(z.object({
|
||||||
canGoBack: z.boolean(),
|
id: z.string(),
|
||||||
canGoForward: z.boolean(),
|
url: z.string(),
|
||||||
loading: z.boolean(),
|
title: z.string(),
|
||||||
|
canGoBack: z.boolean(),
|
||||||
|
canGoForward: z.boolean(),
|
||||||
|
loading: z.boolean(),
|
||||||
|
})),
|
||||||
}),
|
}),
|
||||||
res: z.null(),
|
res: z.null(),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue