improvements

This commit is contained in:
Arjun 2026-04-12 23:09:37 +05:30
parent 042ffc3410
commit 77883774b4
3 changed files with 103 additions and 38 deletions

View file

@ -53,7 +53,7 @@ export class ElectronBrowserControlService implements IBrowserControlService {
switch (input.action) { switch (input.action) {
case 'open': { case 'open': {
await browserViewManager.ensureActiveTabReady(signal); await browserViewManager.ensureActiveTabReady(signal);
const page = await browserViewManager.readPageSummary(signal) ?? undefined; const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
return buildSuccessResult('open', 'Opened the browser pane.', page); return buildSuccessResult('open', 'Opened the browser pane.', page);
} }
@ -67,7 +67,7 @@ export class ElectronBrowserControlService implements IBrowserControlService {
return buildErrorResult('new-tab', result.error ?? 'Failed to open a new tab.'); return buildErrorResult('new-tab', result.error ?? 'Failed to open a new tab.');
} }
await browserViewManager.ensureActiveTabReady(signal); await browserViewManager.ensureActiveTabReady(signal);
const page = await browserViewManager.readPageSummary(signal) ?? undefined; const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
return buildSuccessResult( return buildSuccessResult(
'new-tab', 'new-tab',
target ? `Opened a new tab for ${target}.` : 'Opened a new tab.', target ? `Opened a new tab for ${target}.` : 'Opened a new tab.',
@ -85,7 +85,7 @@ export class ElectronBrowserControlService implements IBrowserControlService {
return buildErrorResult('switch-tab', `No browser tab exists with id ${tabId}.`); return buildErrorResult('switch-tab', `No browser tab exists with id ${tabId}.`);
} }
await browserViewManager.ensureActiveTabReady(signal); await browserViewManager.ensureActiveTabReady(signal);
const page = await browserViewManager.readPageSummary(signal) ?? undefined; const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
return buildSuccessResult('switch-tab', `Switched to tab ${tabId}.`, page); return buildSuccessResult('switch-tab', `Switched to tab ${tabId}.`, page);
} }
@ -99,7 +99,7 @@ export class ElectronBrowserControlService implements IBrowserControlService {
return buildErrorResult('close-tab', `Could not close tab ${tabId}.`); return buildErrorResult('close-tab', `Could not close tab ${tabId}.`);
} }
await browserViewManager.ensureActiveTabReady(signal); await browserViewManager.ensureActiveTabReady(signal);
const page = await browserViewManager.readPageSummary(signal) ?? undefined; const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
return buildSuccessResult('close-tab', `Closed tab ${tabId}.`, page); return buildSuccessResult('close-tab', `Closed tab ${tabId}.`, page);
} }
@ -114,7 +114,7 @@ export class ElectronBrowserControlService implements IBrowserControlService {
return buildErrorResult('navigate', result.error ?? `Failed to navigate to ${target}.`); return buildErrorResult('navigate', result.error ?? `Failed to navigate to ${target}.`);
} }
await browserViewManager.ensureActiveTabReady(signal); await browserViewManager.ensureActiveTabReady(signal);
const page = await browserViewManager.readPageSummary(signal) ?? undefined; const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
return buildSuccessResult('navigate', `Navigated to ${target}.`, page); return buildSuccessResult('navigate', `Navigated to ${target}.`, page);
} }
@ -124,7 +124,7 @@ export class ElectronBrowserControlService implements IBrowserControlService {
return buildErrorResult('back', 'The active tab cannot go back.'); return buildErrorResult('back', 'The active tab cannot go back.');
} }
await browserViewManager.ensureActiveTabReady(signal); await browserViewManager.ensureActiveTabReady(signal);
const page = await browserViewManager.readPageSummary(signal) ?? undefined; const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
return buildSuccessResult('back', 'Went back in the active tab.', page); return buildSuccessResult('back', 'Went back in the active tab.', page);
} }
@ -134,14 +134,14 @@ export class ElectronBrowserControlService implements IBrowserControlService {
return buildErrorResult('forward', 'The active tab cannot go forward.'); return buildErrorResult('forward', 'The active tab cannot go forward.');
} }
await browserViewManager.ensureActiveTabReady(signal); await browserViewManager.ensureActiveTabReady(signal);
const page = await browserViewManager.readPageSummary(signal) ?? undefined; const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
return buildSuccessResult('forward', 'Went forward in the active tab.', page); return buildSuccessResult('forward', 'Went forward in the active tab.', page);
} }
case 'reload': { case 'reload': {
browserViewManager.reload(); browserViewManager.reload();
await browserViewManager.ensureActiveTabReady(signal); await browserViewManager.ensureActiveTabReady(signal);
const page = await browserViewManager.readPageSummary(signal) ?? undefined; const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
return buildSuccessResult('reload', 'Reloaded the active tab.', page); return buildSuccessResult('reload', 'Reloaded the active tab.', page);
} }
@ -171,7 +171,7 @@ export class ElectronBrowserControlService implements IBrowserControlService {
if (!result.ok) { if (!result.ok) {
return buildErrorResult('click', result.error ?? 'Failed to click the requested element.'); return buildErrorResult('click', result.error ?? 'Failed to click the requested element.');
} }
const page = await browserViewManager.readPageSummary(signal) ?? undefined; const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
return buildSuccessResult( return buildSuccessResult(
'click', 'click',
result.description ? `Clicked ${result.description}.` : 'Clicked the requested element.', result.description ? `Clicked ${result.description}.` : 'Clicked the requested element.',
@ -196,7 +196,7 @@ export class ElectronBrowserControlService implements IBrowserControlService {
if (!result.ok) { if (!result.ok) {
return buildErrorResult('type', result.error ?? 'Failed to type into the requested element.'); return buildErrorResult('type', result.error ?? 'Failed to type into the requested element.');
} }
const page = await browserViewManager.readPageSummary(signal) ?? undefined; const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
return buildSuccessResult( return buildSuccessResult(
'type', 'type',
result.description ? `Typed into ${result.description}.` : 'Typed into the requested element.', result.description ? `Typed into ${result.description}.` : 'Typed into the requested element.',
@ -221,7 +221,7 @@ export class ElectronBrowserControlService implements IBrowserControlService {
if (!result.ok) { if (!result.ok) {
return buildErrorResult('press', result.error ?? `Failed to press ${key}.`); return buildErrorResult('press', result.error ?? `Failed to press ${key}.`);
} }
const page = await browserViewManager.readPageSummary(signal) ?? undefined; const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
return buildSuccessResult( return buildSuccessResult(
'press', 'press',
result.description ? `Pressed ${result.description}.` : `Pressed ${key}.`, result.description ? `Pressed ${result.description}.` : `Pressed ${key}.`,
@ -238,14 +238,14 @@ export class ElectronBrowserControlService implements IBrowserControlService {
if (!result.ok) { if (!result.ok) {
return buildErrorResult('scroll', result.error ?? 'Failed to scroll the page.'); return buildErrorResult('scroll', result.error ?? 'Failed to scroll the page.');
} }
const page = await browserViewManager.readPageSummary(signal) ?? undefined; const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
return buildSuccessResult('scroll', `Scrolled ${input.direction ?? 'down'}.`, page); return buildSuccessResult('scroll', `Scrolled ${input.direction ?? 'down'}.`, page);
} }
case 'wait': { case 'wait': {
const duration = input.ms ?? 1000; const duration = input.ms ?? 1000;
await browserViewManager.wait(duration, signal); await browserViewManager.wait(duration, signal);
const page = await browserViewManager.readPageSummary(signal) ?? undefined; const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
return buildSuccessResult('wait', `Waited ${duration}ms for the page to settle.`, page); return buildSuccessResult('wait', `Waited ${duration}ms for the page to settle.`, page);
} }
} }

View file

@ -1,5 +1,10 @@
const SEARCH_ENGINE_BASE_URL = 'https://www.google.com/search?q='; const SEARCH_ENGINE_BASE_URL = 'https://www.google.com/search?q=';
const HAS_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
const IPV4_HOST_RE = /^\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?(?:\/.*)?$/;
const LOCALHOST_RE = /^localhost(?::\d+)?(?:\/.*)?$/i;
const DOMAIN_LIKE_RE = /^[\w.-]+\.[a-z]{2,}(?::\d+)?(?:\/.*)?$/i;
export function normalizeNavigationTarget(target: string): string { export function normalizeNavigationTarget(target: string): string {
const trimmed = target.trim(); const trimmed = target.trim();
if (!trimmed) { if (!trimmed) {
@ -16,17 +21,20 @@ export function normalizeNavigationTarget(target: string): string {
throw new Error('That URL scheme is not allowed in the embedded browser.'); throw new Error('That URL scheme is not allowed in the embedded browser.');
} }
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) { if (HAS_SCHEME_RE.test(trimmed)) {
return trimmed; return trimmed;
} }
const looksLikeHost = const looksLikeHost =
trimmed.startsWith('localhost') LOCALHOST_RE.test(trimmed)
|| /^[\w.-]+\.[a-z]{2,}/i.test(trimmed) || DOMAIN_LIKE_RE.test(trimmed)
|| /^\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?(?:\/.*)?$/.test(trimmed); || IPV4_HOST_RE.test(trimmed);
if (looksLikeHost && !/\s/.test(trimmed)) { if (looksLikeHost && !/\s/.test(trimmed)) {
return trimmed; const scheme = LOCALHOST_RE.test(trimmed) || IPV4_HOST_RE.test(trimmed)
? 'http://'
: 'https://';
return `${scheme}${trimmed}`;
} }
return `${SEARCH_ENGINE_BASE_URL}${encodeURIComponent(trimmed)}`; return `${SEARCH_ENGINE_BASE_URL}${encodeURIComponent(trimmed)}`;

View file

@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { BrowserWindow, WebContentsView, session, shell, type Session, type WebContents } from 'electron'; import { BrowserWindow, WebContentsView, session, shell, type Session } from 'electron';
import type { import type {
BrowserPageElement, BrowserPageElement,
BrowserPageSnapshot, BrowserPageSnapshot,
@ -202,6 +202,8 @@ export interface BrowserBounds {
type BrowserTab = { type BrowserTab = {
id: string; id: string;
view: WebContentsView; view: WebContentsView;
domReadyAt: number | null;
loadError: string | null;
}; };
type CachedSnapshot = { type CachedSnapshot = {
@ -483,7 +485,7 @@ export class BrowserViewManager extends EventEmitter {
return /^https?:\/\//i.test(url) || url === 'about:blank'; return /^https?:\/\//i.test(url) || url === 'about:blank';
} }
private createView(tabId: string): WebContentsView { private createView(): WebContentsView {
const view = new WebContentsView({ const view = new WebContentsView({
webPreferences: { webPreferences: {
session: this.getSession(), session: this.getSession(),
@ -494,11 +496,11 @@ export class BrowserViewManager extends EventEmitter {
}); });
view.webContents.setUserAgent(SPOOF_UA); view.webContents.setUserAgent(SPOOF_UA);
this.wireEvents(tabId, view);
return view; return view;
} }
private wireEvents(tabId: string, view: WebContentsView): void { private wireEvents(tab: BrowserTab): void {
const { id: tabId, view } = tab;
const wc = view.webContents; const wc = view.webContents;
const reapplyBounds = () => { const reapplyBounds = () => {
@ -517,17 +519,40 @@ export class BrowserViewManager extends EventEmitter {
this.emitState(); this.emitState();
}; };
wc.on('did-start-navigation', () => { wc.on('did-start-navigation', (_event, _url, _isInPlace, isMainFrame) => {
if (isMainFrame !== false) {
tab.domReadyAt = null;
tab.loadError = null;
}
this.invalidateSnapshot(tabId); this.invalidateSnapshot(tabId);
reapplyBounds(); reapplyBounds();
}); });
wc.on('did-navigate', () => { reapplyBounds(); invalidateAndEmit(); }); wc.on('did-navigate', () => { reapplyBounds(); invalidateAndEmit(); });
wc.on('did-navigate-in-page', () => { reapplyBounds(); invalidateAndEmit(); }); wc.on('did-navigate-in-page', () => { reapplyBounds(); invalidateAndEmit(); });
wc.on('did-start-loading', () => { this.invalidateSnapshot(tabId); reapplyBounds(); this.emitState(); }); wc.on('did-start-loading', () => {
tab.loadError = null;
this.invalidateSnapshot(tabId);
reapplyBounds();
this.emitState();
});
wc.on('did-stop-loading', () => { reapplyBounds(); invalidateAndEmit(); }); wc.on('did-stop-loading', () => { reapplyBounds(); invalidateAndEmit(); });
wc.on('did-finish-load', () => { reapplyBounds(); invalidateAndEmit(); }); wc.on('did-finish-load', () => { reapplyBounds(); invalidateAndEmit(); });
wc.on('dom-ready', () => {
tab.domReadyAt = Date.now();
reapplyBounds();
invalidateAndEmit();
});
wc.on('did-frame-finish-load', reapplyBounds); wc.on('did-frame-finish-load', reapplyBounds);
wc.on('did-fail-load', () => { reapplyBounds(); invalidateAndEmit(); }); wc.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
if (isMainFrame && errorCode !== -3) {
const target = validatedURL || wc.getURL() || 'page';
tab.loadError = errorDescription
? `Failed to load ${target}: ${errorDescription}.`
: `Failed to load ${target}.`;
}
reapplyBounds();
invalidateAndEmit();
});
wc.on('page-title-updated', this.emitState.bind(this)); wc.on('page-title-updated', this.emitState.bind(this));
wc.setWindowOpenHandler(({ url }) => { wc.setWindowOpenHandler(({ url }) => {
@ -593,9 +618,12 @@ export class BrowserViewManager extends EventEmitter {
const tabId = randomUUID(); const tabId = randomUUID();
const tab: BrowserTab = { const tab: BrowserTab = {
id: tabId, id: tabId,
view: this.createView(tabId), view: this.createView(),
domReadyAt: null,
loadError: null,
}; };
this.wireEvents(tab);
this.tabs.set(tabId, tab); this.tabs.set(tabId, tab);
this.tabOrder.push(tabId); this.tabOrder.push(tabId);
this.activeTabId = tabId; this.activeTabId = tabId;
@ -607,7 +635,10 @@ export class BrowserViewManager extends EventEmitter {
initialUrl === 'about:blank' initialUrl === 'about:blank'
? HOME_URL ? HOME_URL
: normalizeNavigationTarget(initialUrl); : normalizeNavigationTarget(initialUrl);
void tab.view.webContents.loadURL(targetUrl).catch(() => { void tab.view.webContents.loadURL(targetUrl).catch((error) => {
tab.loadError = error instanceof Error
? error.message
: `Failed to load ${targetUrl}.`;
this.emitState(); this.emitState();
}); });
@ -629,17 +660,29 @@ export class BrowserViewManager extends EventEmitter {
} }
private async waitForWebContentsSettle( private async waitForWebContentsSettle(
wc: WebContents, tab: BrowserTab,
signal?: AbortSignal, signal?: AbortSignal,
idleMs = POST_ACTION_IDLE_MS, idleMs = POST_ACTION_IDLE_MS,
timeoutMs = NAVIGATION_TIMEOUT_MS, timeoutMs = NAVIGATION_TIMEOUT_MS,
): Promise<void> { ): Promise<void> {
const wc = tab.view.webContents;
const startedAt = Date.now(); const startedAt = Date.now();
let sawLoading = wc.isLoading(); let sawLoading = wc.isLoading();
while (Date.now() - startedAt < timeoutMs) { while (Date.now() - startedAt < timeoutMs) {
abortIfNeeded(signal); abortIfNeeded(signal);
if (wc.isDestroyed()) return; if (wc.isDestroyed()) return;
if (tab.loadError) {
throw new Error(tab.loadError);
}
if (tab.domReadyAt != null) {
const domReadyForMs = Date.now() - tab.domReadyAt;
const requiredIdleMs = sawLoading ? idleMs : Math.min(idleMs, 200);
if (domReadyForMs >= requiredIdleMs) return;
await sleep(Math.min(100, requiredIdleMs - domReadyForMs), signal);
continue;
}
if (wc.isLoading()) { if (wc.isLoading()) {
sawLoading = true; sawLoading = true;
@ -648,15 +691,24 @@ export class BrowserViewManager extends EventEmitter {
} }
await sleep(sawLoading ? idleMs : Math.min(idleMs, 200), signal); await sleep(sawLoading ? idleMs : Math.min(idleMs, 200), signal);
if (!wc.isLoading()) return; if (tab.loadError) {
throw new Error(tab.loadError);
}
if (!wc.isLoading() || tab.domReadyAt != null) return;
sawLoading = true; sawLoading = true;
} }
} }
private async executeOnActiveTab<T>(script: string, signal?: AbortSignal): Promise<T> { private async executeOnActiveTab<T>(
script: string,
signal?: AbortSignal,
options?: { waitForReady?: boolean },
): Promise<T> {
abortIfNeeded(signal); abortIfNeeded(signal);
const activeTab = this.getActiveTab() ?? this.ensureInitialTab(); const activeTab = this.getActiveTab() ?? this.ensureInitialTab();
await this.waitForWebContentsSettle(activeTab.view.webContents, signal); if (options?.waitForReady !== false) {
await this.waitForWebContentsSettle(activeTab, signal);
}
abortIfNeeded(signal); abortIfNeeded(signal);
return activeTab.view.webContents.executeJavaScript(script, true) as Promise<T>; return activeTab.view.webContents.executeJavaScript(script, true) as Promise<T>;
} }
@ -734,7 +786,7 @@ export class BrowserViewManager extends EventEmitter {
async ensureActiveTabReady(signal?: AbortSignal): Promise<void> { async ensureActiveTabReady(signal?: AbortSignal): Promise<void> {
const activeTab = this.getActiveTab() ?? this.ensureInitialTab(); const activeTab = this.getActiveTab() ?? this.ensureInitialTab();
await this.waitForWebContentsSettle(activeTab.view.webContents, signal); await this.waitForWebContentsSettle(activeTab, signal);
} }
async newTab(rawUrl?: string): Promise<{ ok: boolean; tabId?: string; error?: string }> { async newTab(rawUrl?: string): Promise<{ ok: boolean; tabId?: string; error?: string }> {
@ -820,7 +872,7 @@ export class BrowserViewManager extends EventEmitter {
} }
async readPage( async readPage(
options?: { maxElements?: number; maxTextLength?: number }, options?: { maxElements?: number; maxTextLength?: number; waitForReady?: boolean },
signal?: AbortSignal, signal?: AbortSignal,
): Promise<{ ok: boolean; page?: BrowserPageSnapshot; error?: string }> { ): Promise<{ ok: boolean; page?: BrowserPageSnapshot; error?: string }> {
try { try {
@ -831,6 +883,7 @@ export class BrowserViewManager extends EventEmitter {
options?.maxTextLength ?? DEFAULT_READ_MAX_TEXT_LENGTH, options?.maxTextLength ?? DEFAULT_READ_MAX_TEXT_LENGTH,
), ),
signal, signal,
{ waitForReady: options?.waitForReady },
); );
return { return {
ok: true, ok: true,
@ -844,11 +897,15 @@ export class BrowserViewManager extends EventEmitter {
} }
} }
async readPageSummary(signal?: AbortSignal): Promise<BrowserPageSnapshot | null> { async readPageSummary(
signal?: AbortSignal,
options?: { waitForReady?: boolean },
): Promise<BrowserPageSnapshot | null> {
const result = await this.readPage( const result = await this.readPage(
{ {
maxElements: POST_ACTION_MAX_ELEMENTS, maxElements: POST_ACTION_MAX_ELEMENTS,
maxTextLength: POST_ACTION_MAX_TEXT_LENGTH, maxTextLength: POST_ACTION_MAX_TEXT_LENGTH,
waitForReady: options?.waitForReady,
}, },
signal, signal,
); );
@ -871,7 +928,7 @@ export class BrowserViewManager extends EventEmitter {
); );
if (!result.ok) return result; if (!result.ok) return result;
this.invalidateSnapshot(activeTab.id); this.invalidateSnapshot(activeTab.id);
await this.waitForWebContentsSettle(activeTab.view.webContents, signal); await this.waitForWebContentsSettle(activeTab, signal);
return result; return result;
} catch (error) { } catch (error) {
return { return {
@ -897,7 +954,7 @@ export class BrowserViewManager extends EventEmitter {
); );
if (!result.ok) return result; if (!result.ok) return result;
this.invalidateSnapshot(activeTab.id); this.invalidateSnapshot(activeTab.id);
await this.waitForWebContentsSettle(activeTab.view.webContents, signal); await this.waitForWebContentsSettle(activeTab, signal);
return result; return result;
} catch (error) { } catch (error) {
return { return {
@ -948,7 +1005,7 @@ export class BrowserViewManager extends EventEmitter {
wc.sendInputEvent({ type: 'keyUp', keyCode }); wc.sendInputEvent({ type: 'keyUp', keyCode });
this.invalidateSnapshot(activeTab.id); this.invalidateSnapshot(activeTab.id);
await this.waitForWebContentsSettle(wc, signal); await this.waitForWebContentsSettle(activeTab, signal);
return { return {
ok: true, ok: true,
@ -990,7 +1047,7 @@ export class BrowserViewManager extends EventEmitter {
await sleep(ms, signal); await sleep(ms, signal);
const activeTab = this.getActiveTab(); const activeTab = this.getActiveTab();
if (!activeTab) return; if (!activeTab) return;
await this.waitForWebContentsSettle(activeTab.view.webContents, signal); await this.waitForWebContentsSettle(activeTab, signal);
} }
getState(): BrowserState { getState(): BrowserState {