160 lines
4.3 KiB
TypeScript
160 lines
4.3 KiB
TypeScript
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);
|
||
}
|
||
}
|
||
}
|