This commit is contained in:
yu committed 2026-11-02 14:22:41 +08:00
commit e5f64c21e2
6 files changed
+194 -34

No files matched your search

+4 -1
View File
@@ -24,6 +24,8 @@ export interface ExternalLaunchOptions {
extraArgs?: string[];
/** 独立用户数据目录;缺省使用系统临时目录下的独立 profile */
userDataDir?: string;
/** 首次启动时直接打开的 URL,避免先出现 about:blank */
initialUrl?: string;
}
export interface ExternalRemoteOptions {
@@ -225,12 +227,13 @@ export async function launchExternalBrowser(options: ExternalLaunchOptions): Pro
await fs.mkdir(userDataDir, { recursive: true });
const extraArgs = (options.extraArgs ?? []).filter(Boolean);
const startUrl = options.initialUrl?.trim() || "about:blank";
const args = [
`--remote-debugging-port=${port}`,
`--user-data-dir=${userDataDir}`,
"--no-first-run",
"--no-default-browser-check",
"about:blank",
startUrl,
...extraArgs
];
+89 -11
View File
@@ -166,6 +166,16 @@ export class BrowserSession implements BrowserLike {
}
/** 当前会话是否为外部浏览器 */
/** 会话底层 CDP 是否已断开(缓存清理用) */
get closed(): boolean {
return this.transport.closed;
}
/** 缓存键,外部会话排序用 */
get sessionKey(): string {
return this.externalKey;
}
get isExternal(): boolean {
return this.external !== null;
}
@@ -1157,6 +1167,10 @@ export class BrowserSession implements BrowserLike {
let box = null;
let badge = null;
let target = null;
let locked = false;
let chain = [];
let chainIndex = 0;
let selected = null;
let timer = 0;
let finished = false;
@@ -1225,18 +1239,24 @@ export class BrowserSession implements BrowserLike {
url: location.href
};
};
const nodeName = (node) => node.getAttribute('aria-label') || node.getAttribute('placeholder') || node.getAttribute('name') || node.id || (node.innerText || '').trim().slice(0, 60) || '';
const updateBox = () => {
if (!target || !box || !badge) return;
const rect = target.getBoundingClientRect();
const node = locked ? selected : target;
if (!node || !box || !badge) return;
const rect = node.getBoundingClientRect();
box.style.left = Math.max(0, rect.left - 3) + 'px';
box.style.top = Math.max(0, rect.top - 3) + 'px';
box.style.width = Math.max(4, rect.width + 6) + 'px';
box.style.height = Math.max(4, rect.height + 6) + 'px';
const tag = target.tagName.toLowerCase();
const name = target.getAttribute('aria-label') || target.getAttribute('placeholder') || target.getAttribute('name') || target.id || '';
badge.textContent = '拾取:' + tag + (name ? ' · ' + name.slice(0, 60) : '');
const tag = node.tagName.toLowerCase();
const name = nodeName(node);
if (locked) {
badge.textContent = '已锁定 ' + (chainIndex + 1) + '/' + chain.length + ' · ' + tag + (name ? ' · ' + name.slice(0, 42) : '') + ' · [PageUp/PageDown]调层级 [空格]确认 [Esc]取消';
} else {
badge.textContent = '拾取 · ' + tag + (name ? ' · ' + name.slice(0, 42) : '') + ' · [空格]锁定 [Esc]取消';
}
badge.style.left = Math.max(0, rect.left) + 'px';
badge.style.top = Math.max(0, rect.top - 30) + 'px';
badge.style.top = Math.max(0, rect.top - 34) + 'px';
};
const interactiveSelector = [
'button', 'a', 'input', 'select', 'textarea', 'summary', 'label', 'option',
@@ -1266,20 +1286,48 @@ export class BrowserSession implements BrowserLike {
if (!deepest && event.target instanceof Element) deepest = event.target;
return closestInteractive(deepest);
};
const buildChain = (node) => {
const result = [];
let current = node;
while (current && current.nodeType === 1) {
result.unshift(current);
const root = current.getRootNode();
current = root instanceof ShadowRoot ? root.host : null;
}
return result;
};
const lock = () => {
if (locked || !target) return;
locked = true;
chain = buildChain(target);
chainIndex = Math.max(0, chain.length - 1);
selected = chain[chainIndex] || target;
updateBox();
};
const adjustLevel = (delta) => {
if (!locked || !chain.length) return;
const next = Math.min(chain.length - 1, Math.max(0, chainIndex + delta));
if (next === chainIndex) return;
chainIndex = next;
selected = chain[chainIndex];
updateBox();
};
const confirm = () => {
if (!locked || !selected) return;
cleanup();
resolve(describe(selected));
};
const move = (event) => {
if (finished) return;
if (finished || locked) return;
target = firstElement(event);
updateBox();
};
const click = (event) => {
if (finished) return;
const selected = firstElement(event);
if (!selected) return;
// 与桌面拾取对齐:鼠标只用于定位,空格锁定/确认,避免误触发页面按钮。
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
cleanup();
resolve(describe(selected));
};
const key = (event) => {
if (event.key === 'Escape') {
@@ -1287,6 +1335,25 @@ export class BrowserSession implements BrowserLike {
event.stopPropagation();
cleanup();
reject(new Error('已取消元素拾取'));
return;
}
if (event.code === 'Space' && !event.repeat) {
event.preventDefault();
event.stopPropagation();
if (locked) confirm();
else lock();
return;
}
if (locked && event.key === 'PageUp' && !event.repeat) {
event.preventDefault();
event.stopPropagation();
adjustLevel(-1);
return;
}
if (locked && event.key === 'PageDown' && !event.repeat) {
event.preventDefault();
event.stopPropagation();
adjustLevel(1);
}
};
const cleanup = () => {
@@ -1317,6 +1384,7 @@ export class BrowserSession implements BrowserLike {
window.addEventListener('click', click, true);
window.addEventListener('scroll', updateBox, true);
window.addEventListener('resize', updateBox);
window.addEventListener('keyup', () => {}, true);
window.addEventListener('keydown', key, true);
window.__rpaElementPicker = { cancel: () => { cleanup(); reject(new Error('已取消元素拾取')); } };
timer = setTimeout(() => {
@@ -1370,6 +1438,16 @@ export async function externalBrowserSession(options: ExternalBrowserOptions): P
return BrowserSession.createExternal(options);
}
export function listExternalSessions(): BrowserSession[] {
return [...externalSessionCache().values()]
.filter((session) => session.isExternal && !session.closed)
.sort((left, right) => right.sessionKey.localeCompare(left.sessionKey));
}
export function latestExternalSession(): BrowserSession | null {
return listExternalSessions()[0] || null;
}
export function hasBrowserSession(): boolean {
return Boolean(globalThis.__rpaBrowserWindow && !globalThis.__rpaBrowserWindow.isDestroyed());
}
+28 -6
View File
@@ -1,7 +1,13 @@
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
import path from "node:path";
import type { AppInfo, AppSettings, FlowDefinition, FlowSaveOptions, FlowSaveResult } from "../shared/types";
import { BrowserSession, hasBrowserSession, setBrowserElementWaitMs } from "./browser/session";
import {
BrowserSession,
externalBrowserSession,
hasBrowserSession,
latestExternalSession,
setBrowserElementWaitMs
} from "./browser/session";
import "./capabilities";
import { catalogPath, loadCatalog } from "./catalog";
import { createDemoFlow, createExampleFlows, createFlow, deleteFlow, getFlow, listFlows, saveFlow } from "./flows";
@@ -103,14 +109,30 @@ function registerIpc(): void {
setBrowserElementWaitMs(settings.browserElementWaitMs);
return settings;
});
ipcMain.handle("browser:pick-element", async (_event, options: { url?: string } = {}) => {
type BrowserPickOptions = {
url?: string;
browser?: import("./browser/external").ExternalBrowserOptions;
};
ipcMain.handle("browser:pick-element", async (_event, options: BrowserPickOptions = {}) => {
const url = options.url?.trim();
if (!hasBrowserSession() && !url) {
throw new Error("请先配置或运行「打开网页」,再拾取元素");
const browserOptions = options.browser;
let session;
if (browserOptions) {
session = await externalBrowserSession(
browserOptions.mode === "launch" && url ? { ...browserOptions, initialUrl: url } : browserOptions
);
} else {
session = latestExternalSession();
}
if (!session) {
if (!hasBrowserSession() && !url) {
throw new Error("请先配置或运行「打开网页」,再拾取元素");
}
session = await BrowserSession.create();
}
const session = await BrowserSession.create();
const currentUrl = await session.currentUrl();
if (url && (currentUrl === "about:blank" || currentUrl !== url)) {
if (url && currentUrl !== url) {
await session.navigate(url);
}
return session.pickElement();