feat: implement browser open functionality and related tests
This commit is contained in:
159
electron/service/browser-open-service.ts
Normal file
159
electron/service/browser-open-service.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { chromium } from 'playwright';
|
||||
import logManager from '@electron/service/logger';
|
||||
import { launchLocalChrome } from '@electron/utils/chrome/launchLocalChrome';
|
||||
|
||||
const CDP_ENDPOINT = 'http://127.0.0.1:9222';
|
||||
const NAVIGATION_TIMEOUT_MS = 15_000;
|
||||
const OPEN_URL_PATTERNS = [
|
||||
/^\s*(?:打开|打开网页|打开链接)\s*[::]?\s*(https?:\/\/\S+)\s*$/iu,
|
||||
/^\s*(?:open|visit)\s*[::]?\s*(https?:\/\/\S+)\s*$/iu,
|
||||
] as const;
|
||||
|
||||
export interface BrowserOpenIntent {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface BrowserOpenResult {
|
||||
url: string;
|
||||
pageUrl: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
function stripTrailingPunctuation(value: string): string {
|
||||
return value.trim().replace(/[)\]}>.,!?;:'",。!?;:)】》]+$/u, '');
|
||||
}
|
||||
|
||||
function normalizeHttpUrl(value: string): string {
|
||||
const raw = stripTrailingPunctuation(value);
|
||||
const parsed = new URL(raw);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error(`Unsupported URL protocol: ${parsed.protocol}`);
|
||||
}
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function isBlankLikePage(url: string): boolean {
|
||||
const normalized = String(url || '').trim().toLowerCase();
|
||||
if (!normalized) return true;
|
||||
return normalized === 'about:blank'
|
||||
|| normalized.startsWith('about:blank#')
|
||||
|| normalized.startsWith('about:blank?')
|
||||
|| normalized === 'chrome://newtab/'
|
||||
|| normalized.startsWith('chrome://newtab')
|
||||
|| normalized.startsWith('chrome://new-tab-page');
|
||||
}
|
||||
|
||||
function isSameTarget(currentUrl: string, targetUrl: string): boolean {
|
||||
if (!currentUrl || !targetUrl) return false;
|
||||
if (currentUrl.startsWith(targetUrl)) return true;
|
||||
|
||||
try {
|
||||
const current = new URL(currentUrl);
|
||||
const target = new URL(targetUrl);
|
||||
return current.origin === target.origin && current.pathname.startsWith(target.pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
throw new Error('Browser open aborted');
|
||||
}
|
||||
}
|
||||
|
||||
function toBrowserConnectError(error: unknown): Error {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (
|
||||
message.includes('connect ECONNREFUSED')
|
||||
|| message.includes('retrieving websocket url from http://127.0.0.1:9222')
|
||||
) {
|
||||
return new Error('Chrome 已启动但远程调试端口 9222 不可用,请稍后重试或重启本地 Chrome');
|
||||
}
|
||||
|
||||
return error instanceof Error ? error : new Error(message);
|
||||
}
|
||||
|
||||
export function extractBrowserOpenIntent(text: string): BrowserOpenIntent | null {
|
||||
for (const pattern of OPEN_URL_PATTERNS) {
|
||||
const match = pattern.exec(text);
|
||||
if (!match?.[1]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
url: normalizeHttpUrl(match[1]),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function openUrlInBrowser(
|
||||
url: string,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<BrowserOpenResult> {
|
||||
const normalizedUrl = normalizeHttpUrl(url);
|
||||
assertNotAborted(options?.signal);
|
||||
|
||||
await launchLocalChrome();
|
||||
assertNotAborted(options?.signal);
|
||||
|
||||
const browser = await chromium.connectOverCDP(CDP_ENDPOINT).catch((error) => {
|
||||
throw toBrowserConnectError(error);
|
||||
});
|
||||
|
||||
try {
|
||||
const context = browser.contexts()[0];
|
||||
if (!context) {
|
||||
throw new Error('No browser context available');
|
||||
}
|
||||
|
||||
let page = context.pages().find((candidate) => isSameTarget(candidate.url(), normalizedUrl));
|
||||
if (!page) {
|
||||
page = context.pages().find((candidate) => isBlankLikePage(candidate.url()));
|
||||
}
|
||||
if (!page) {
|
||||
page = await context.newPage();
|
||||
}
|
||||
|
||||
await page.bringToFront();
|
||||
assertNotAborted(options?.signal);
|
||||
|
||||
if (!isSameTarget(page.url(), normalizedUrl)) {
|
||||
await page.goto(normalizedUrl, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: NAVIGATION_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
const pageUrl = page.url() || normalizedUrl;
|
||||
const title = (await page.title().catch(() => '')).trim() || undefined;
|
||||
|
||||
logManager.info('Browser open request completed', {
|
||||
requestedUrl: normalizedUrl,
|
||||
pageUrl,
|
||||
title,
|
||||
});
|
||||
|
||||
return {
|
||||
url: normalizedUrl,
|
||||
pageUrl,
|
||||
title,
|
||||
};
|
||||
} finally {
|
||||
try {
|
||||
if (typeof browser.disconnect === 'function') {
|
||||
await browser.disconnect();
|
||||
} else {
|
||||
await browser.close();
|
||||
}
|
||||
} catch (error) {
|
||||
logManager.warn('Failed to disconnect browser after open request:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user