diff --git a/src/main/browser/session.ts b/src/main/browser/session.ts index cd34c9f..5c4191d 100644 --- a/src/main/browser/session.ts +++ b/src/main/browser/session.ts @@ -2,6 +2,16 @@ import { BrowserWindow } from "electron"; import { promises as fs } from "node:fs"; import path from "node:path"; import type { ElementPickResult } from "../../shared/types"; + +const DEFAULT_ELEMENT_WAIT_MS = 5000; +let globalElementWaitMs = DEFAULT_ELEMENT_WAIT_MS; + +export function setBrowserElementWaitMs(milliseconds: number): void { + const value = Number(milliseconds); + globalElementWaitMs = Number.isFinite(value) + ? Math.min(600000, Math.max(0, Math.round(value))) + : DEFAULT_ELEMENT_WAIT_MS; +} import { RuntimeError, type BrowserElementInfo, @@ -137,6 +147,18 @@ export class BrowserSession implements BrowserLike { } private async withElement(selector: unknown, operation: string): Promise { + const deadline = Date.now() + globalElementWaitMs; + for (;;) { + try { + return await this.withElementOnce(selector, operation); + } catch (error) { + if (!(error instanceof RuntimeError && error.code === "not_found") || Date.now() >= deadline) throw error; + await this.wait(120); + } + } + } + + private async withElementOnce(selector: unknown, operation: string): Promise { const spec = selectorSpec(selector); const result = await this.evaluate<{ found: boolean; value?: T }>(`(() => { const spec = ${JSON.stringify(spec)}; @@ -241,7 +263,31 @@ export class BrowserSession implements BrowserLike { return { url: this.win.webContents.getURL(), title: this.win.webContents.getTitle() }; } - async locate(selector: unknown): Promise { + private async wait(milliseconds: number): Promise { + await new Promise((resolve) => setTimeout(resolve, milliseconds)); + } + + async locate(selector: unknown, timeoutMs?: number): Promise { + const waitMs = Math.max(0, timeoutMs ?? globalElementWaitMs); + const deadline = Date.now() + waitMs; + for (;;) { + try { + return await this.locateOnce(selector); + } catch (error) { + const canRetry = error instanceof RuntimeError && error.code === "not_found" && Date.now() < deadline; + if (!canRetry) { + if (error instanceof RuntimeError && error.code === "not_found" && waitMs > 0) { + error.detail = `已等待页面渲染 ${waitMs}ms 后仍未找到元素`; + error.recommendation = "确认页面已加载完成;如果元素在 iframe 或 Shadow DOM 中,请用拾取器重新采集"; + } + throw error; + } + await this.wait(120); + } + } + } + + private async locateOnce(selector: unknown): Promise { const spec = selectorSpec(selector); const expression = `(() => { const spec = ${JSON.stringify(spec)}; @@ -312,8 +358,8 @@ export class BrowserSession implements BrowserLike { await this.cdp("Input.dispatchMouseEvent", { type: "mouseReleased", ...point, button: "left", buttons: 0, clickCount: 1 }); } - async click(selector: unknown): Promise { - const info = await this.locate(selector); + async click(selector: unknown, timeoutMs?: number): Promise { + const info = await this.locate(selector, timeoutMs); if (!info.visible) { throw new RuntimeError("not_found", `元素不可见:${info.selector}`, { recommendation: "等待元素出现或检查页面状态" }); } @@ -343,8 +389,8 @@ export class BrowserSession implements BrowserLike { return { selector: info.selector, tag: info.tag, text: info.text }; } - async type(selector: unknown, text: string, clearFirst = true): Promise { - const info = await this.locate(selector); + async type(selector: unknown, text: string, clearFirst = true, timeoutMs?: number): Promise { + const info = await this.locate(selector, timeoutMs); await this.mouseClick(info.rect.x + info.rect.width / 2, info.rect.y + info.rect.height / 2); if (clearFirst) { await this.cdp("Input.dispatchKeyEvent", { type: "rawKeyDown", key: "a", code: "KeyA", windowsVirtualKeyCode: 65, modifiers: 2 }); @@ -374,7 +420,7 @@ export class BrowserSession implements BrowserLike { async exists(selector: unknown): Promise { try { - await this.locate(selector); + await this.locate(selector, 0); return true; } catch { return false; diff --git a/src/main/engine/types.ts b/src/main/engine/types.ts index 3b1c7a1..09ddff6 100644 --- a/src/main/engine/types.ts +++ b/src/main/engine/types.ts @@ -3,7 +3,7 @@ import type { CatalogComponent, FlowDefinition, FlowNode, RunError, RunEvent } f export interface BrowserLike { navigate(url: string): Promise<{ url: string; title: string }>; click(selector: unknown, timeoutMs?: number): Promise; - type(selector: unknown, text: string, clearFirst?: boolean): Promise; + type(selector: unknown, text: string, clearFirst?: boolean, timeoutMs?: number): Promise; getText(selector: unknown, attribute?: string): Promise; exists(selector: unknown, timeoutMs?: number): Promise; select(selector: unknown, value: string): Promise; diff --git a/src/main/index.ts b/src/main/index.ts index fad8c50..55009e1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,13 +1,14 @@ import { app, BrowserWindow, dialog, ipcMain, shell } from "electron"; import path from "node:path"; -import type { AppInfo, FlowDefinition } from "../shared/types"; -import { BrowserSession, hasBrowserSession } from "./browser/session"; +import type { AppInfo, AppSettings, FlowDefinition } from "../shared/types"; +import { BrowserSession, hasBrowserSession, setBrowserElementWaitMs } from "./browser/session"; import "./capabilities"; import { catalogPath, loadCatalog } from "./catalog"; import { createDemoFlow, createExampleFlows, createFlow, deleteFlow, getFlow, listFlows, saveFlow } from "./flows"; import { capabilityCount, capabilityIds } from "./engine/registry"; import { closeBrowserSession, startRun, stopRun } from "./run/manager"; import { fixtureUrl, startFixtureServer, stopFixtureServer } from "./fixture/server"; +import { loadSettings, updateSettings } from "./settings"; import { logMain } from "./logger"; let mainWindow: BrowserWindow | null = null; @@ -93,6 +94,12 @@ function registerIpc(): void { ipcMain.handle("run:start", (event, flow: FlowDefinition) => startRun(flow, event.sender)); ipcMain.handle("run:stop", (_event, runId?: string) => stopRun(runId)); ipcMain.handle("run:capabilities", () => ({ count: capabilityCount(), ids: capabilityIds() })); + ipcMain.handle("settings:get", () => loadSettings()); + ipcMain.handle("settings:update", (_event, patch: Partial = {}) => { + const settings = updateSettings(patch); + setBrowserElementWaitMs(settings.browserElementWaitMs); + return settings; + }); ipcMain.handle("browser:pick-element", async (_event, options: { url?: string } = {}) => { const url = options.url?.trim(); if (!hasBrowserSession() && !url) { @@ -160,6 +167,7 @@ if (!gotLock) { app.whenReady().then(async () => { logMain("app-ready", { electron: process.versions.electron, chrome: process.versions.chrome, node: process.versions.node }); + setBrowserElementWaitMs(loadSettings().browserElementWaitMs); try { await startFixtureServer(Number(process.env.RPA_FIXTURE_PORT || 17863)); } catch (error) { diff --git a/src/main/settings.test.ts b/src/main/settings.test.ts new file mode 100644 index 0000000..f3e13a3 --- /dev/null +++ b/src/main/settings.test.ts @@ -0,0 +1,35 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const electronState = vi.hoisted(() => ({ userData: "" })); +vi.mock("electron", () => ({ + app: { + getPath: () => electronState.userData + } +})); + +import { loadSettings, updateSettings } from "./settings"; + +describe("app settings", () => { + beforeEach(async () => { + electronState.userData = await mkdtemp(path.join(os.tmpdir(), "rpa-settings-")); + }); + + afterEach(async () => { + await rm(electronState.userData, { recursive: true, force: true }); + }); + + it("returns the default element wait when no settings file exists", () => { + expect(loadSettings()).toEqual({ browserElementWaitMs: 5000 }); + }); + + it("persists and clamps a user-adjustable element wait", async () => { + const saved = updateSettings({ browserElementWaitMs: 12500 }); + expect(saved).toEqual({ browserElementWaitMs: 12500 }); + expect(JSON.parse(await readFile(path.join(electronState.userData, "settings.json"), "utf8"))).toEqual(saved); + expect(updateSettings({ browserElementWaitMs: -5 })).toEqual({ browserElementWaitMs: 0 }); + expect(loadSettings()).toEqual({ browserElementWaitMs: 0 }); + }); +}); diff --git a/src/main/settings.ts b/src/main/settings.ts new file mode 100644 index 0000000..4f7d206 --- /dev/null +++ b/src/main/settings.ts @@ -0,0 +1,45 @@ +import { app } from "electron"; +import fs from "node:fs"; +import path from "node:path"; +import type { AppSettings } from "../shared/types"; + +export const DEFAULT_APP_SETTINGS: AppSettings = { + browserElementWaitMs: 5000 +}; + +let cache: AppSettings | null = null; + +function settingsPath(): string { + return path.join(app.getPath("userData"), "settings.json"); +} + +function normalizeSettings(value: unknown): AppSettings { + const source = (value && typeof value === "object" ? value : {}) as Partial; + const wait = Number(source.browserElementWaitMs); + return { + browserElementWaitMs: Number.isFinite(wait) + ? Math.min(600000, Math.max(0, Math.round(wait))) + : DEFAULT_APP_SETTINGS.browserElementWaitMs + }; +} + +export function loadSettings(): AppSettings { + try { + cache = normalizeSettings(JSON.parse(fs.readFileSync(settingsPath(), "utf8"))); + } catch { + cache = { ...DEFAULT_APP_SETTINGS }; + } + return cache; +} + +export function saveSettings(settings: AppSettings): AppSettings { + const normalized = normalizeSettings(settings); + fs.mkdirSync(app.getPath("userData"), { recursive: true }); + fs.writeFileSync(settingsPath(), JSON.stringify(normalized, null, 2), "utf8"); + cache = normalized; + return normalized; +} + +export function updateSettings(patch: Partial = {}): AppSettings { + return saveSettings({ ...(cache || loadSettings()), ...patch }); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 4f2af19..956721a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer } from "electron"; -import type { AppInfo, Catalog, FlowDefinition, FlowSummary, RunEvent } from "../shared/types"; +import type { AppInfo, AppSettings, Catalog, FlowDefinition, FlowSummary, RunEvent } from "../shared/types"; const api = { app: { @@ -27,6 +27,10 @@ const api = { pickElement: (options?: { url?: string }): Promise => ipcRenderer.invoke("browser:pick-element", options || {}) }, + settings: { + get: (): Promise => ipcRenderer.invoke("settings:get"), + update: (patch: Partial): Promise => ipcRenderer.invoke("settings:update", patch || {}) + }, run: { start: (flow: FlowDefinition): Promise<{ runId: string }> => ipcRenderer.invoke("run:start", flow), stop: (runId?: string): Promise => ipcRenderer.invoke("run:stop", runId), diff --git a/src/renderer/src/pages/SettingsPage.tsx b/src/renderer/src/pages/SettingsPage.tsx index ebf55a4..6ca1a6b 100644 --- a/src/renderer/src/pages/SettingsPage.tsx +++ b/src/renderer/src/pages/SettingsPage.tsx @@ -1,8 +1,8 @@ import { useEffect, useMemo, useState } from "react"; -import { Cpu, Database, FolderCog, Globe2, HardDrive, Info, ShieldCheck } from "lucide-react"; +import { Clock, Cpu, Database, FolderCog, Globe2, HardDrive, Info, Save, ShieldCheck } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import type { ReactNode } from "react"; -import type { AppInfo, Catalog } from "@shared/types"; +import type { AppInfo, AppSettings, Catalog } from "@shared/types"; type SettingsTab = "about" | "catalog" | "execution" | "browser"; @@ -10,12 +10,40 @@ export function SettingsPage({ catalog }: { catalog: Catalog }) { const [tab, setTab] = useState("about"); const [info, setInfo] = useState(null); const [handlers, setHandlers] = useState<{ count: number; ids: string[] } | null>(null); + const [settings, setSettings] = useState(null); + const [waitDraft, setWaitDraft] = useState("5000"); + const [waitSaving, setWaitSaving] = useState(false); + const [waitMessage, setWaitMessage] = useState(null); useEffect(() => { void window.rpa.app.info().then(setInfo).catch(() => setInfo(null)); void window.rpa.run.capabilities().then(setHandlers).catch(() => setHandlers(null)); + void window.rpa.settings.get().then((value) => { + setSettings(value); + setWaitDraft(String(value.browserElementWaitMs)); + }).catch(() => setSettings(null)); }, []); + const saveElementWait = async () => { + const value = Math.round(Number(waitDraft)); + if (!Number.isFinite(value) || value < 0 || value > 600000) { + setWaitMessage("请输入 0 - 600000 之间的毫秒数"); + return; + } + setWaitSaving(true); + setWaitMessage(null); + try { + const saved = await window.rpa.settings.update({ browserElementWaitMs: value }); + setSettings(saved); + setWaitDraft(String(saved.browserElementWaitMs)); + setWaitMessage(`已保存:${saved.browserElementWaitMs} ms`); + } catch (error) { + setWaitMessage(error instanceof Error ? error.message : String(error)); + } finally { + setWaitSaving(false); + } + }; + const executable = catalog.components.filter((item) => item.implemented).length; const groups = useMemo(() => catalog.groups.map((group) => { const components = catalog.components.filter((component) => component.group === group.id); @@ -62,6 +90,30 @@ export function SettingsPage({ catalog }: { catalog: Catalog }) { {tab === "browser" && ( <>
默认允许
全部 HTTP/HTTPS 站点
本地演示站
{info?.fixtureUrl || "未启动"}
协议限制
HTTP / HTTPS
用途
支持内网、公网和测试站点
+ +
+ + setWaitDraft(event.target.value)} + disabled={waitSaving} + /> + 所有网页元素能力共用。找不到元素时会持续等待到设定时间;0 表示不等待。 +
+
+ + {waitMessage && {waitMessage}} +
+

浏览器导航不限制域名。只拒绝 HTTP/HTTPS 以外的协议;文件协议、脚本协议和任意本地路径不会由浏览器导航组件打开。

)} diff --git a/src/shared/types.ts b/src/shared/types.ts index 71fc7bc..b0a7900 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -169,6 +169,14 @@ export interface AppInfo { fixtureUrl?: string; } +export interface AppSettings { + browserElementWaitMs: number; +} + +export interface AppSettings { + browserElementWaitMs: number; +} + export interface ElementPickResult { selector: string; tag: string;