diff --git a/apps/x/apps/main/src/browser/control-service.ts b/apps/x/apps/main/src/browser/control-service.ts index 55f76d35..d018ea68 100644 --- a/apps/x/apps/main/src/browser/control-service.ts +++ b/apps/x/apps/main/src/browser/control-service.ts @@ -53,7 +53,7 @@ export class ElectronBrowserControlService implements IBrowserControlService { switch (input.action) { case 'open': { 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); } @@ -67,7 +67,7 @@ export class ElectronBrowserControlService implements IBrowserControlService { return buildErrorResult('new-tab', result.error ?? 'Failed to open a new tab.'); } await browserViewManager.ensureActiveTabReady(signal); - const page = await browserViewManager.readPageSummary(signal) ?? undefined; + const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; return buildSuccessResult( '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}.`); } 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); } @@ -99,7 +99,7 @@ export class ElectronBrowserControlService implements IBrowserControlService { return buildErrorResult('close-tab', `Could not close tab ${tabId}.`); } 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); } @@ -114,7 +114,7 @@ export class ElectronBrowserControlService implements IBrowserControlService { return buildErrorResult('navigate', result.error ?? `Failed to navigate to ${target}.`); } 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); } @@ -124,7 +124,7 @@ export class ElectronBrowserControlService implements IBrowserControlService { return buildErrorResult('back', 'The active tab cannot go back.'); } 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); } @@ -134,14 +134,14 @@ export class ElectronBrowserControlService implements IBrowserControlService { return buildErrorResult('forward', 'The active tab cannot go forward.'); } 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); } case 'reload': { browserViewManager.reload(); 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); } @@ -171,7 +171,7 @@ export class ElectronBrowserControlService implements IBrowserControlService { if (!result.ok) { 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( 'click', result.description ? `Clicked ${result.description}.` : 'Clicked the requested element.', @@ -196,7 +196,7 @@ export class ElectronBrowserControlService implements IBrowserControlService { if (!result.ok) { 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( 'type', result.description ? `Typed into ${result.description}.` : 'Typed into the requested element.', @@ -221,7 +221,7 @@ export class ElectronBrowserControlService implements IBrowserControlService { if (!result.ok) { 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( 'press', result.description ? `Pressed ${result.description}.` : `Pressed ${key}.`, @@ -238,14 +238,14 @@ export class ElectronBrowserControlService implements IBrowserControlService { if (!result.ok) { 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); } case 'wait': { const duration = input.ms ?? 1000; 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); } } diff --git a/apps/x/apps/main/src/browser/navigation.ts b/apps/x/apps/main/src/browser/navigation.ts index c99165ab..ac840956 100644 --- a/apps/x/apps/main/src/browser/navigation.ts +++ b/apps/x/apps/main/src/browser/navigation.ts @@ -1,5 +1,10 @@ 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 { const trimmed = target.trim(); 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.'); } - if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) { + if (HAS_SCHEME_RE.test(trimmed)) { return trimmed; } const looksLikeHost = - trimmed.startsWith('localhost') - || /^[\w.-]+\.[a-z]{2,}/i.test(trimmed) - || /^\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?(?:\/.*)?$/.test(trimmed); + LOCALHOST_RE.test(trimmed) + || DOMAIN_LIKE_RE.test(trimmed) + || IPV4_HOST_RE.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)}`; diff --git a/apps/x/apps/main/src/browser/view.ts b/apps/x/apps/main/src/browser/view.ts index 75a55cd0..24e7c037 100644 --- a/apps/x/apps/main/src/browser/view.ts +++ b/apps/x/apps/main/src/browser/view.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; 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 { BrowserPageElement, BrowserPageSnapshot, @@ -202,6 +202,8 @@ export interface BrowserBounds { type BrowserTab = { id: string; view: WebContentsView; + domReadyAt: number | null; + loadError: string | null; }; type CachedSnapshot = { @@ -483,7 +485,7 @@ export class BrowserViewManager extends EventEmitter { return /^https?:\/\//i.test(url) || url === 'about:blank'; } - private createView(tabId: string): WebContentsView { + private createView(): WebContentsView { const view = new WebContentsView({ webPreferences: { session: this.getSession(), @@ -494,11 +496,11 @@ export class BrowserViewManager extends EventEmitter { }); view.webContents.setUserAgent(SPOOF_UA); - this.wireEvents(tabId, 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 reapplyBounds = () => { @@ -517,17 +519,40 @@ export class BrowserViewManager extends EventEmitter { 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); reapplyBounds(); }); wc.on('did-navigate', () => { 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-finish-load', () => { reapplyBounds(); invalidateAndEmit(); }); + wc.on('dom-ready', () => { + tab.domReadyAt = Date.now(); + reapplyBounds(); + invalidateAndEmit(); + }); 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.setWindowOpenHandler(({ url }) => { @@ -593,9 +618,12 @@ export class BrowserViewManager extends EventEmitter { const tabId = randomUUID(); const tab: BrowserTab = { id: tabId, - view: this.createView(tabId), + view: this.createView(), + domReadyAt: null, + loadError: null, }; + this.wireEvents(tab); this.tabs.set(tabId, tab); this.tabOrder.push(tabId); this.activeTabId = tabId; @@ -607,7 +635,10 @@ export class BrowserViewManager extends EventEmitter { initialUrl === 'about:blank' ? HOME_URL : 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(); }); @@ -629,17 +660,29 @@ export class BrowserViewManager extends EventEmitter { } private async waitForWebContentsSettle( - wc: WebContents, + tab: BrowserTab, signal?: AbortSignal, idleMs = POST_ACTION_IDLE_MS, timeoutMs = NAVIGATION_TIMEOUT_MS, ): Promise { + const wc = tab.view.webContents; const startedAt = Date.now(); let sawLoading = wc.isLoading(); while (Date.now() - startedAt < timeoutMs) { abortIfNeeded(signal); 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()) { sawLoading = true; @@ -648,15 +691,24 @@ export class BrowserViewManager extends EventEmitter { } 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; } } - private async executeOnActiveTab(script: string, signal?: AbortSignal): Promise { + private async executeOnActiveTab( + script: string, + signal?: AbortSignal, + options?: { waitForReady?: boolean }, + ): Promise { abortIfNeeded(signal); const activeTab = this.getActiveTab() ?? this.ensureInitialTab(); - await this.waitForWebContentsSettle(activeTab.view.webContents, signal); + if (options?.waitForReady !== false) { + await this.waitForWebContentsSettle(activeTab, signal); + } abortIfNeeded(signal); return activeTab.view.webContents.executeJavaScript(script, true) as Promise; } @@ -734,7 +786,7 @@ export class BrowserViewManager extends EventEmitter { async ensureActiveTabReady(signal?: AbortSignal): Promise { 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 }> { @@ -820,7 +872,7 @@ export class BrowserViewManager extends EventEmitter { } async readPage( - options?: { maxElements?: number; maxTextLength?: number }, + options?: { maxElements?: number; maxTextLength?: number; waitForReady?: boolean }, signal?: AbortSignal, ): Promise<{ ok: boolean; page?: BrowserPageSnapshot; error?: string }> { try { @@ -831,6 +883,7 @@ export class BrowserViewManager extends EventEmitter { options?.maxTextLength ?? DEFAULT_READ_MAX_TEXT_LENGTH, ), signal, + { waitForReady: options?.waitForReady }, ); return { ok: true, @@ -844,11 +897,15 @@ export class BrowserViewManager extends EventEmitter { } } - async readPageSummary(signal?: AbortSignal): Promise { + async readPageSummary( + signal?: AbortSignal, + options?: { waitForReady?: boolean }, + ): Promise { const result = await this.readPage( { maxElements: POST_ACTION_MAX_ELEMENTS, maxTextLength: POST_ACTION_MAX_TEXT_LENGTH, + waitForReady: options?.waitForReady, }, signal, ); @@ -871,7 +928,7 @@ export class BrowserViewManager extends EventEmitter { ); if (!result.ok) return result; this.invalidateSnapshot(activeTab.id); - await this.waitForWebContentsSettle(activeTab.view.webContents, signal); + await this.waitForWebContentsSettle(activeTab, signal); return result; } catch (error) { return { @@ -897,7 +954,7 @@ export class BrowserViewManager extends EventEmitter { ); if (!result.ok) return result; this.invalidateSnapshot(activeTab.id); - await this.waitForWebContentsSettle(activeTab.view.webContents, signal); + await this.waitForWebContentsSettle(activeTab, signal); return result; } catch (error) { return { @@ -948,7 +1005,7 @@ export class BrowserViewManager extends EventEmitter { wc.sendInputEvent({ type: 'keyUp', keyCode }); this.invalidateSnapshot(activeTab.id); - await this.waitForWebContentsSettle(wc, signal); + await this.waitForWebContentsSettle(activeTab, signal); return { ok: true, @@ -990,7 +1047,7 @@ export class BrowserViewManager extends EventEmitter { await sleep(ms, signal); const activeTab = this.getActiveTab(); if (!activeTab) return; - await this.waitForWebContentsSettle(activeTab.view.webContents, signal); + await this.waitForWebContentsSettle(activeTab, signal); } getState(): BrowserState {