@mearl/browser-core 3.0.0-beta.8 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md ADDED
@@ -0,0 +1,13 @@
1
+ # @mearl/browser-core
2
+
3
+ [简体中文](README.md) | [English](README.en.md)
4
+
5
+ Page control logic and protocols shared by browsers and Node: DOM/AX snapshots, action observation, input, scrolling, navigation, request serialization, capture cursors, device simulation, generic device appearance, Cookie/domain permissions, and plugin task, page, and state contracts. Modules have no browser extension or native process installation side effects.
6
+
7
+ The core includes no preset App identities. The host validates profile IDs against its active capability catalog. App profile schemas, explicit options, revisions, and page runtime bindings are data contracts; independent capability packages supply complete bridge implementations.
8
+
9
+ The built-in installer at `@mearl/browser-core/appProfileRuntime` synchronizes only UA, navigator.appVersion, navigator.platform, and touch points. It does not install App bridges, business globals, container stacks, or authorization handlers. With a pageRuntime binding, the host must prepare the exact code from the installed capability package and execute it through the page runtime dispatcher; the base installer cannot substitute for this. See [browser bundles](../../docs/browser-bundles.en.md#page-runtimes) for resource preparation and dispatch.
10
+
11
+ The default proxy authorization addresses are localhost and 127.0.0.1. Hosts obtain permission for other sites and pass them in the authorization list. The core includes Aplus/RUM/ARMS event parsers; additional parsers register through capability packages.
12
+
13
+ Agent contracts are defined in agentChat.ts, agentSkills.ts, and pluginModels.ts and exported through the corresponding subpaths. agent-runtime uses these contracts to avoid duplicate definitions. Serializable page functions remain self-contained for shared use by the Extension and CDP.
package/README.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # @mearl/browser-core
2
2
 
3
+ [简体中文](README.md) | [English](README.en.md)
4
+
3
5
  浏览器与 Node 可共享的页面控制逻辑和协议:DOM/AX 快照、动作观察、输入、滚动、导航、请求序列化、捕获游标、设备模拟、通用设备外观、Cookie/域名权限和插件任务、页面、状态合同。模块没有浏览器扩展或本机进程的安装副作用。
4
6
 
5
- 核心不预置 App 身份;档案 ID 由宿主的已激活能力目录校验。App 档案 schema、显式选项、revision 和页面运行时绑定是数据合同,完整桥实现由独立能力包提供。
7
+ 核心不预置 App 身份;端模拟 ID 由宿主的已激活能力目录校验。端模拟配置 schema、显式选项、revision 和页面运行时绑定是数据合同,完整桥实现由独立能力包提供。
6
8
 
7
9
  `@mearl/browser-core/appProfileRuntime` 的内置安装器只同步 UA、navigator.appVersion、navigator.platform 和触摸点,不安装 App 桥、业务全局变量、容器栈或授权处理。有 pageRuntime 绑定时,宿主必须先从已安装的能力包准备确切代码,再通过页面运行时派发器执行;不能调用基础安装器代替。资源准备和派发见 [能力包](../../docs/browser-bundles.md#页面运行时)。
8
10
 
@@ -1,5 +1,7 @@
1
1
  export type AgentChatEventKind = 'lifecycle' | 'notification' | 'request' | 'error';
2
2
  export type AgentChatSessionStatus = 'idle' | 'running' | 'waiting' | 'error';
3
+ export declare function isQoderAuthenticationError(message: unknown): boolean;
4
+ export declare function isQoderMissingSessionError(message: unknown): boolean;
3
5
  export type AgentChatCapabilitySupport = boolean | 'runtime';
4
6
  export declare const AGENT_CHAT_MAX_PAGE_URL_CHARS = 16384;
5
7
  export declare const AGENT_CHAT_MAX_PAGE_TITLE_CHARS = 2048;
@@ -28,7 +30,8 @@ export interface AgentChatProviderDescriptor {
28
30
  };
29
31
  error?: string;
30
32
  setup?: {
31
- installCommand: string;
33
+ installCommand?: string;
34
+ canInstall?: boolean;
32
35
  loginCommand?: string;
33
36
  docsUrl?: string;
34
37
  };
@@ -91,6 +94,7 @@ export interface AgentChatSendOptions {
91
94
  message: string;
92
95
  images?: AgentChatImageInput[];
93
96
  displayMessage?: string;
97
+ hasAdditionalContext?: boolean;
94
98
  clientMessageId?: string;
95
99
  }
96
100
  export interface AgentChatRunOptions {
@@ -157,6 +161,9 @@ export interface AgentChatStatus {
157
161
  sessions: AgentChatSession[];
158
162
  }
159
163
  export interface AgentChatKnownEventParams {
164
+ 'session/reconnected': {
165
+ providerSessionId: string;
166
+ };
160
167
  'turn/started': {
161
168
  turn: {
162
169
  id: string;
package/dist/agentChat.js CHANGED
@@ -1,3 +1,10 @@
1
+ export function isQoderAuthenticationError(message) {
2
+ return (typeof message === 'string' &&
3
+ /^(?:Authentication (?:is )?required|Unauthenticated|Sign-in required)(?:[:.\s]|$)/i.test(message));
4
+ }
5
+ export function isQoderMissingSessionError(message) {
6
+ return typeof message === 'string' && /^(?:Invalid params:\s*)?Invalid session identifier\b/i.test(message);
7
+ }
1
8
  export const AGENT_CHAT_MAX_PAGE_URL_CHARS = 16_384;
2
9
  export const AGENT_CHAT_MAX_PAGE_TITLE_CHARS = 2_048;
3
10
  export const AGENT_CHAT_MAX_TOOLBOX_CONTEXT_CHARS = 65_536;
@@ -123,6 +130,7 @@ function readAgentChatRequestParams(params) {
123
130
  return { ...params, requestId: params.requestId };
124
131
  }
125
132
  const AGENT_CHAT_EVENT_PARAM_READERS = {
133
+ 'session/reconnected': params => typeof params.providerSessionId === 'string' ? { providerSessionId: params.providerSessionId } : null,
126
134
  'turn/started': readAgentChatTurnParams,
127
135
  'turn/completed': readAgentChatTurnParams,
128
136
  'session/model/updated': readAgentChatModelParams,
@@ -11,6 +11,18 @@ export interface AgentServiceActionMap {
11
11
  request: undefined;
12
12
  response: AgentChatStatus;
13
13
  };
14
+ agent_chat_install_provider: {
15
+ request: {
16
+ providerId: string;
17
+ };
18
+ response: AgentChatStatus;
19
+ };
20
+ agent_chat_login_qoder: {
21
+ request: undefined;
22
+ response: {
23
+ url: string | null;
24
+ };
25
+ };
14
26
  agent_chat_prepare: {
15
27
  request: AgentChatPrepareOptions;
16
28
  response: {
@@ -51,6 +63,14 @@ export interface AgentServiceActionMap {
51
63
  request: AgentChatEventsOptions;
52
64
  response: AgentChatEventsResult;
53
65
  };
66
+ agent_chat_read_visualization: {
67
+ request: {
68
+ path: string;
69
+ };
70
+ response: {
71
+ html: string;
72
+ };
73
+ };
54
74
  agent_chat_refresh: {
55
75
  request: AgentChatEventsOptions;
56
76
  response: AgentChatRefreshResult;
@@ -112,6 +132,8 @@ export type AgentServiceHandlerMap = {
112
132
  export declare const AGENT_SERVICE_ACTIONS: {
113
133
  readonly agent_chat_pick_directory: true;
114
134
  readonly agent_chat_status: true;
135
+ readonly agent_chat_install_provider: true;
136
+ readonly agent_chat_login_qoder: true;
115
137
  readonly agent_chat_prepare: true;
116
138
  readonly agent_chat_list_sessions: true;
117
139
  readonly agent_chat_start: true;
@@ -120,6 +142,7 @@ export declare const AGENT_SERVICE_ACTIONS: {
120
142
  readonly agent_chat_interrupt: true;
121
143
  readonly agent_chat_close: true;
122
144
  readonly agent_chat_events: true;
145
+ readonly agent_chat_read_visualization: true;
123
146
  readonly agent_chat_refresh: true;
124
147
  readonly agent_chat_open_app: true;
125
148
  readonly agent_chat_open_workspace: true;
@@ -1,6 +1,8 @@
1
1
  export const AGENT_SERVICE_ACTIONS = {
2
2
  agent_chat_pick_directory: true,
3
3
  agent_chat_status: true,
4
+ agent_chat_install_provider: true,
5
+ agent_chat_login_qoder: true,
4
6
  agent_chat_prepare: true,
5
7
  agent_chat_list_sessions: true,
6
8
  agent_chat_start: true,
@@ -9,6 +11,7 @@ export const AGENT_SERVICE_ACTIONS = {
9
11
  agent_chat_interrupt: true,
10
12
  agent_chat_close: true,
11
13
  agent_chat_events: true,
14
+ agent_chat_read_visualization: true,
12
15
  agent_chat_refresh: true,
13
16
  agent_chat_open_app: true,
14
17
  agent_chat_open_workspace: true,
@@ -66,9 +66,11 @@ export interface AppProfileUsage {
66
66
  url: string;
67
67
  }>;
68
68
  }
69
- /** 单容器档案(纯数据) */
69
+ /** 单个 App 容器的声明式端模拟配置。 */
70
70
  export interface AppProfileDefinition {
71
71
  name: string;
72
+ /** Preferred App ID when the capability is installed outside a Preset. */
73
+ defaultAppId?: string;
72
74
  usage?: AppProfileUsage;
73
75
  /** Content-addressed installer supplied by the selected browser capability package. */
74
76
  pageRuntime?: {
@@ -83,6 +83,7 @@ export function validateAppProfileDefinition(value) {
83
83
  'schemes',
84
84
  ];
85
85
  const optional = [
86
+ 'defaultAppId',
86
87
  'usage',
87
88
  'actionIcon',
88
89
  'labels',
@@ -114,6 +115,10 @@ export function validateAppProfileDefinition(value) {
114
115
  invalid(key);
115
116
  if (!/^[a-z][a-z0-9-]*(?:\/[a-z][a-z0-9-]*)?$/.test(value.name))
116
117
  invalid('name');
118
+ if (value.defaultAppId !== undefined &&
119
+ (typeof value.defaultAppId !== 'string' || !/^[a-z][a-z0-9-]{0,79}$/.test(value.defaultAppId) ||
120
+ ['constructor', 'prototype'].includes(value.defaultAppId)))
121
+ invalid('defaultAppId');
117
122
  if (value.usage !== undefined) {
118
123
  const usage = value.usage;
119
124
  fields(usage, [], ['description', 'limitations', 'skills', 'documentation'], 'usage');
@@ -464,9 +464,9 @@ export interface BrowserEmulationOptions {
464
464
  userAgent?: string;
465
465
  orientation?: 'portrait' | 'landscape';
466
466
  touch?: boolean;
467
- /** 在 iframe WebView 外渲染设备系统栏;默认 false,保持纯 CDP 设备模拟。 */
467
+ /** 在 iframe WebView 外渲染顶部状态栏和底部操作区;默认 false。 */
468
468
  deviceShell?: boolean;
469
- /** App 容器横屏时将根页面与业务页面分置左右 WebView;仅与 deviceShell、App 档案同时生效。 */
469
+ /** App 容器横屏时将根页面与业务页面分置左右 WebView,需要同时开启 deviceShell 和端模拟。 */
470
470
  splitScreen?: boolean;
471
471
  }
472
472
  /** Provider-defined behavior ID; core does not execute or enumerate business behaviors. */
@@ -478,7 +478,7 @@ export interface AppBridgeMethodConfig {
478
478
  noop?: boolean;
479
479
  silent?: boolean;
480
480
  }
481
- /** 追加或覆盖 App 档案中的桥方法。配置保持 JSON 可序列化,不执行配置提供的代码。 */
481
+ /** 追加或覆盖端模拟配置中的桥方法。配置保持 JSON 可序列化。 */
482
482
  export interface AppBridgeConfig {
483
483
  methods: Record<string, AppBridgeMethodConfig>;
484
484
  }
@@ -499,13 +499,13 @@ export interface TabOpenParams {
499
499
  emulation?: BrowserEmulationOptions;
500
500
  group?: string;
501
501
  /**
502
- * 客户端容器模拟档案名(由已安装能力目录提供)。只注入容器 UA、环境变量、JSBridge 与桥协议;
503
- * 设备视口和 iframe 系统外壳由 emulation.deviceShell 独立控制。可用档案通过 get_capabilities 查询。
502
+ * 端模拟配置 ID,通过 get_capabilities 查询。该配置提供容器 UA、环境变量、JSBridge 和桥协议。
503
+ * 设备视口由 emulation 配置,系统栏由 emulation.deviceShell 控制。
504
504
  */
505
505
  appProfile?: AppProfileName;
506
506
  /** 与 appProfile 配合使用;容器系统,默认 ios */
507
507
  os?: 'ios' | 'android';
508
- /** 与 appProfile 配合使用;按方法覆盖档案内置桥配置 */
508
+ /** 与 appProfile 配合使用,按方法覆盖端模拟的内置桥配置。 */
509
509
  bridgeConfig?: AppBridgeConfig;
510
510
  }
511
511
  export type TabGroupParams = {
@@ -526,18 +526,18 @@ export interface SetAppProfileParams {
526
526
  tabId: number;
527
527
  /** false 时清除该 tab 的容器模拟并刷新 */
528
528
  enabled: boolean;
529
- /** 容器档案名(enabled=true 时必填) */
529
+ /** 端模拟配置 ID(enabled=true 时必填)。 */
530
530
  profile?: AppProfileName;
531
531
  /** Immediately use the latest installed definition while preserving explicit options. */
532
532
  refreshDefinition?: boolean;
533
- /** 固定当前档案定义;默认在下次文档导航时自动采用可用更新。 */
533
+ /** 固定当前端模拟配置。默认在下次文档导航时自动采用可用更新。 */
534
534
  lockDefinition?: boolean;
535
535
  os?: 'ios' | 'android';
536
536
  appVersion?: string;
537
537
  /** 端定义支持的语言选项。 */
538
538
  language?: string;
539
539
  deviceModel?: string;
540
- /** 按方法覆盖档案内置桥配置,仅支持受控行为、固定 JSON 回包和成功空实现 */
540
+ /** 按方法覆盖端模拟的内置桥配置,仅支持受控行为、固定 JSON 回包和成功空实现。 */
541
541
  bridgeConfig?: AppBridgeConfig;
542
542
  /**
543
543
  * 可选已安装的网络控件选择;null 清除,省略时保持当前设置。
@@ -545,7 +545,7 @@ export interface SetAppProfileParams {
545
545
  */
546
546
  networkControl?: NetworkControlSelection | null;
547
547
  /**
548
- * 可选设备联动参数。传入时显式字段覆盖档案推荐设备,空对象使用推荐设备且默认开启
548
+ * 可选设备联动参数。传入时显式字段覆盖端模拟的推荐设备,空对象使用推荐设备且默认开启
549
549
  * deviceShell;省略时不设置或清除该 tab 的设备状态。
550
550
  */
551
551
  emulation?: BrowserEmulationOptions;
@@ -580,7 +580,7 @@ export interface PageClickParams {
580
580
  };
581
581
  /** CSS/text 查询的目标 frame;省略时容器模式自动使用当前业务 frame。 */
582
582
  frameId?: string;
583
- /** point 的坐标空间;默认 outer-viewport 以保持兼容。 */
583
+ /** point 的坐标空间;手机壳预览默认 active-frame,其他页面默认 outer-viewport。 */
584
584
  coordinateSpace?: 'outer-viewport' | 'active-frame';
585
585
  role?: string;
586
586
  exact?: boolean;
@@ -8,6 +8,7 @@
8
8
  * 两条路径共享 preset 表 / 默认值推导 / 入参校验,确保对外行为完全一致。
9
9
  */
10
10
  import type { BrowserDevicePresetName, BrowserEmulationOptions } from './browserActionProtocol.js';
11
+ import type { DeviceChromeViewConfig } from './deviceChromeView.js';
11
12
  export interface DevicePreset {
12
13
  width: number;
13
14
  height: number;
@@ -43,6 +44,11 @@ export type DeviceCutout = 'none' | 'dynamic-island' | 'notch' | 'side-camera';
43
44
  export interface DeviceAppearance {
44
45
  cutout: DeviceCutout;
45
46
  }
47
+ export interface ResolvedDeviceChromeConfig extends DeviceChromeViewConfig {
48
+ safeAreaInsets: SafeAreaInsets;
49
+ }
50
+ /** Shared system-bar geometry for H5 containers and local mini-programs. */
51
+ export declare function getDeviceChromeViewConfig(emulation: ResolvedEmulationConfig): ResolvedDeviceChromeConfig;
46
52
  /** Duo 除内屏竖屏外使用纵向系统栏,业务 WebView 避让屏幕右边缘。 */
47
53
  export declare function getDeviceSideBarWidth(preset: BrowserDevicePresetName | undefined, orientation: 'portrait' | 'landscape'): number;
48
54
  export declare const DEVICE_PRESETS: Record<BrowserDevicePresetName, DevicePreset>;
@@ -7,6 +7,19 @@
7
7
  *
8
8
  * 两条路径共享 preset 表 / 默认值推导 / 入参校验,确保对外行为完全一致。
9
9
  */
10
+ /** Shared system-bar geometry for H5 containers and local mini-programs. */
11
+ export function getDeviceChromeViewConfig(emulation) {
12
+ const platform = emulation.platform === 'Android' || emulation.platform === 'OpenHarmony' ? 'android' : 'ios';
13
+ const sideBarWidth = getDeviceSideBarWidth(emulation.presetName, emulation.orientation);
14
+ return {
15
+ platform,
16
+ statusBarHeight: sideBarWidth ? 0 : emulation.orientation === 'landscape' ? 24 : emulation.safeAreaInsets.top,
17
+ bottomBarHeight: emulation.safeAreaInsets.bottom,
18
+ ...(sideBarWidth ? { sideBarWidth } : {}),
19
+ safeAreaInsets: { ...emulation.safeAreaInsets },
20
+ cutout: platform === 'ios' ? emulation.appearance.cutout : 'none',
21
+ };
22
+ }
10
23
  const IPHONE_DUO_SIDE_BAR_WIDTH = 84;
11
24
  /** Duo 除内屏竖屏外使用纵向系统栏,业务 WebView 避让屏幕右边缘。 */
12
25
  export function getDeviceSideBarWidth(preset, orientation) {
@@ -180,6 +180,12 @@ export interface HostToExtensionActionMap {
180
180
  request: Record<string, unknown>;
181
181
  response: unknown;
182
182
  };
183
+ __profile_install: {
184
+ request: {
185
+ source: string;
186
+ };
187
+ response: unknown;
188
+ };
183
189
  __preset_browser: {
184
190
  request: {
185
191
  operation: 'sync';
@@ -257,6 +263,7 @@ export interface HostToExtensionActionMap {
257
263
  export declare const HOST_TO_EXTENSION_ACTIONS: {
258
264
  readonly __browser_enhancement: true;
259
265
  readonly __preset_installation: true;
266
+ readonly __profile_install: true;
260
267
  readonly __preset_browser: true;
261
268
  readonly __export_cookies: true;
262
269
  readonly __capture_download: true;
@@ -30,6 +30,7 @@ export function isExtensionUiServiceAction(value) {
30
30
  export const HOST_TO_EXTENSION_ACTIONS = {
31
31
  __browser_enhancement: true,
32
32
  __preset_installation: true,
33
+ __profile_install: true,
33
34
  __preset_browser: true,
34
35
  __export_cookies: true,
35
36
  __capture_download: true,
@@ -90,6 +91,13 @@ export function getExtensionServiceTimeout(action, data = {}) {
90
91
  return 40000;
91
92
  if (action === 'agent_chat_pick_directory')
92
93
  return 300000;
94
+ if (action === 'agent_chat_install_provider')
95
+ return 420000;
96
+ if (action === 'agent_chat_login_qoder')
97
+ return 60000;
98
+ // Installation can prepare a plugin's runtime after the Skill manager returns.
99
+ if (action === 'agent_skills_install' || action === 'agent_skills_update')
100
+ return 600000;
93
101
  if (action.startsWith('agent_skills_'))
94
102
  return 130000;
95
103
  if ([
@@ -125,8 +125,9 @@ export class PluginPageRuntimeHost {
125
125
  if (expectedDocumentId !== undefined && typeof expectedDocumentId !== 'string')
126
126
  throw new Error('Invalid plugin page document identity');
127
127
  const target = await this.adapter.resolve(Number(tabId), frame, expectedDocumentId);
128
- if (!['http:', 'https:'].includes(new URL(target.url).protocol))
129
- throw new Error('Plugin pages require an HTTP(S) document');
128
+ const url = new URL(target.url);
129
+ if (!['http:', 'https:'].includes(url.protocol) && (url.protocol !== 'file:' || url.hostname || frame !== 'main'))
130
+ throw new Error('Plugin pages require an HTTP(S) document or a main-frame local preview');
130
131
  return { ...target, browserSessionId: this.sessionId };
131
132
  }
132
133
  async current(record) {
@@ -650,7 +651,7 @@ export class PluginPageRuntimeHost {
650
651
  if (!Array.isArray(matches) ||
651
652
  !matches.length ||
652
653
  matches.length > 64 ||
653
- !matches.every(isPluginPageMatchPattern) ||
654
+ !matches.every(match => isPluginPageMatchPattern(match) && !(match.startsWith('file:') && match.includes('*'))) ||
654
655
  !Array.isArray(data.allowedMatches) ||
655
656
  !data.allowedMatches.every(isPluginPageMatchPattern))
656
657
  throw new Error('Invalid page activation URL matches');
@@ -13,6 +13,7 @@ export interface PluginPageEventBinding {
13
13
  policy?: 'serial' | 'latest' | 'parallel';
14
14
  concurrency?: number;
15
15
  maxPending?: number;
16
+ executionTimeoutMs?: number;
16
17
  }
17
18
  export interface PluginPageRuntime {
18
19
  version: 1;
@@ -20,8 +20,9 @@ export function pluginPageUnavailable(error) {
20
20
  });
21
21
  }
22
22
  const pattern = /^(https?|\*):\/\/(\*|\*\.[a-z0-9.-]+|[a-z0-9.-]+|localhost)(:\d+)?(\/[^#]*)$/i;
23
+ const localPattern = /^file:\/\/\/(?:[^/*?#\\]+\/)+(?:\*|[^/*?#\\]+)\.html$/;
23
24
  export function isPluginPageMatchPattern(value) {
24
- return typeof value === 'string' && value.length <= 2048 && pattern.test(value);
25
+ return typeof value === 'string' && value.length <= 2048 && (pattern.test(value) || localPattern.test(value));
25
26
  }
26
27
  export function matchesPluginPageURL(matches, value) {
27
28
  let url;
@@ -31,6 +32,14 @@ export function matchesPluginPageURL(matches, value) {
31
32
  catch {
32
33
  return false;
33
34
  }
35
+ if (url.protocol === 'file:') {
36
+ if (url.hostname || !url.pathname.endsWith('.html'))
37
+ return false;
38
+ url.search = '';
39
+ url.hash = '';
40
+ return matches.some(item => localPattern.test(item) && (item === url.href ||
41
+ (item.endsWith('/*.html') && item.slice(0, item.lastIndexOf('/') + 1) === url.href.slice(0, url.href.lastIndexOf('/') + 1))));
42
+ }
34
43
  if (!['http:', 'https:'].includes(url.protocol))
35
44
  return false;
36
45
  return matches.some(item => {
@@ -94,6 +94,7 @@ export const ACTIONS_SKIP_DOMAIN_CHECK = new Set([
94
94
  '__toolbox_open',
95
95
  '__extension_reload',
96
96
  '__preset_browser',
97
+ '__profile_install',
97
98
  // tab_list is a read-only enumeration; sensitive operations on specific
98
99
  // tabs (page_eval, send_request, etc.) are still individually checked.
99
100
  'tab_list',
@@ -13,6 +13,41 @@ export function installRootWebViewDocumentTransport() {
13
13
  // 无效交付地址保留原路径,不导航到未经校验的地址。
14
14
  }
15
15
  }
16
+ if (!browser.__mearlRootDocumentUrl__ &&
17
+ browser.parent === browser.top &&
18
+ browser.parent !== browser) {
19
+ const frame = browser.frameElement;
20
+ const syncShellUrl = () => {
21
+ if (location.pathname.startsWith(prefix))
22
+ return false;
23
+ try {
24
+ const shell = browser.parent;
25
+ if (shell.location.origin !== location.origin || shell.location.href === location.href)
26
+ return false;
27
+ // The iframe navigation already owns the history entry. Mirror its URL without adding another.
28
+ shell.history.replaceState(shell.history.state, '', location.href);
29
+ return true;
30
+ }
31
+ catch {
32
+ // The shell may be navigating or have an inaccessible origin.
33
+ return false;
34
+ }
35
+ };
36
+ if (frame?.hasAttribute('data-mearl-root-webview')) {
37
+ const animationId = 'mearl-root-webview-enter';
38
+ frame.getAnimations().find(animation => animation.id === animationId)?.cancel();
39
+ if (syncShellUrl() && !browser.matchMedia('(prefers-reduced-motion: reduce)').matches) {
40
+ frame.animate([{ opacity: 0.75 }, { opacity: 1 }], {
41
+ duration: 160,
42
+ easing: 'ease-out',
43
+ }).id = animationId;
44
+ }
45
+ browser.addEventListener('pageshow', event => {
46
+ if (event.persisted)
47
+ syncShellUrl();
48
+ });
49
+ }
50
+ }
16
51
  browser.__mearlRootDocumentUrl__ = (url) => {
17
52
  const navigation = performance.getEntriesByType('navigation')[0];
18
53
  const token = navigation?.serverTiming?.find(entry => entry.name === 'mearl-document')?.description;
@@ -29,10 +29,12 @@ export interface WebViewDocumentResponse {
29
29
  }
30
30
  export interface RootWebViewNavigationOptions {
31
31
  client: WebViewNavigationClient;
32
- shellHeaders(): Promise<Array<{
32
+ shellHeaders(forDocument: boolean): Promise<Array<{
33
33
  name: string;
34
34
  value: string;
35
35
  }> | undefined>;
36
+ /** The host supports loading same-origin documents directly in the root frame. */
37
+ keepSameOriginNavigation?: boolean;
36
38
  /** 开发代理把业务路径保存在外层 origin 中,不迁移该 origin。 */
37
39
  preserveOrigin?(current: URL, destination: URL): Promise<boolean> | boolean;
38
40
  }
@@ -40,6 +42,7 @@ export interface WebViewFrameTree {
40
42
  frame: {
41
43
  id: string;
42
44
  url: string;
45
+ urlFragment?: string;
43
46
  name?: string;
44
47
  parentId?: string;
45
48
  };
@@ -52,13 +55,17 @@ export interface WebViewFrameEvent {
52
55
  reason?: string;
53
56
  }
54
57
  /**
55
- * 真实导航在顶层完成网络请求,HTML 响应一次性交给根 WebView。内部交付地址避免
56
- * 重放一次性登录回调;非 HTML 和下载响应保持浏览器语义,不提交空容器。
58
+ * 根 WebView 在现有外壳内完成同源导航。顶层导航的 HTML 响应通过内部地址
59
+ * 一次性交付给根 WebView,避免重放登录回调;非 HTML 和下载保持浏览器语义。
57
60
  */
58
61
  export declare function createRootWebViewNavigation(options: RootWebViewNavigationOptions): {
62
+ isSameOriginRootNavigation: (params: WebViewNavigationRequest) => boolean;
63
+ preserveNavigationSource: ({ request }: WebViewNavigationRequest) => (() => void);
64
+ allowLocalReload(frameId: string, url: string): () => void;
59
65
  dispose(): void;
60
66
  initialize(tree: WebViewFrameTree): void;
61
- handleFrameEvent(method: string, params: WebViewFrameEvent): void;
67
+ /** Returns the committed business URL when the root navigates within the current shell. */
68
+ handleFrameEvent(method: string, params: WebViewFrameEvent): string | undefined;
62
69
  /** Mock 与真实响应共用交付入口,保持浏览器 URL 和一次性文档语义一致。 */
63
70
  fulfillResponse(params: WebViewNavigationRequest, response: WebViewDocumentResponse, requestClient?: WebViewNavigationClient): Promise<boolean>;
64
71
  handle(params: WebViewNavigationRequest, requestClient?: WebViewNavigationClient): Promise<boolean>;
@@ -49,13 +49,15 @@ async function readDocumentBody(client, requestId) {
49
49
  }
50
50
  }
51
51
  /**
52
- * 真实导航在顶层完成网络请求,HTML 响应一次性交给根 WebView。内部交付地址避免
53
- * 重放一次性登录回调;非 HTML 和下载响应保持浏览器语义,不提交空容器。
52
+ * 根 WebView 在现有外壳内完成同源导航。顶层导航的 HTML 响应通过内部地址
53
+ * 一次性交付给根 WebView,避免重放登录回调;非 HTML 和下载保持浏览器语义。
54
54
  */
55
55
  export function createRootWebViewNavigation(options) {
56
56
  let handoff;
57
+ let localReload;
57
58
  let mainFrame;
58
59
  let rootFrameId;
60
+ let committedRoot;
59
61
  const directChildFrames = new Set();
60
62
  let navigationHops = 0;
61
63
  let document;
@@ -67,6 +69,27 @@ export function createRootWebViewNavigation(options) {
67
69
  clearTimeout(expiry);
68
70
  expiry = undefined;
69
71
  };
72
+ const isSameOriginRootNavigation = (params) => {
73
+ if (!options.keepSameOriginNavigation || !mainFrame || !committedRoot ||
74
+ params.frameId !== committedRoot.id ||
75
+ params.resourceType !== 'Document' || params.request.method !== 'GET' ||
76
+ params.responseStatusCode != null || params.responseErrorReason != null ||
77
+ !/^https?:/.test(params.request.url) || !/^https?:/.test(mainFrame.url))
78
+ return false;
79
+ const destination = new URL(params.request.url);
80
+ return !destination.pathname.startsWith(DOCUMENT_PREFIX) &&
81
+ !(localReload && localReload.frameId === params.frameId && localReload.url === destination.href) &&
82
+ destination.origin === committedRoot.origin && destination.origin === new URL(mainFrame.url).origin;
83
+ };
84
+ const preserveNavigationSource = ({ request }) => {
85
+ const source = {
86
+ url: new URL(request.url).href,
87
+ headers: Object.fromEntries(Object.entries(request.headers ?? {}).filter(([name]) => /^(referer|origin)$/i.test(name))),
88
+ };
89
+ handoff = source;
90
+ return () => { if (handoff === source)
91
+ handoff = undefined; };
92
+ };
70
93
  const deliverDocument = async (params, requestClient, headers, readBody) => {
71
94
  const destination = new URL(params.request.url);
72
95
  const originalHeaders = params.responseHeaders ?? [];
@@ -127,35 +150,62 @@ export function createRootWebViewNavigation(options) {
127
150
  return true;
128
151
  };
129
152
  return {
153
+ isSameOriginRootNavigation,
154
+ preserveNavigationSource,
155
+ allowLocalReload(frameId, url) {
156
+ const destination = new URL(url);
157
+ destination.hash = '';
158
+ const reload = { frameId, url: destination.href };
159
+ localReload = reload;
160
+ return () => { if (localReload === reload)
161
+ localReload = undefined; };
162
+ },
130
163
  dispose() {
131
164
  disposed = true;
132
165
  deliveryGeneration += 1;
133
166
  clearDocument();
134
167
  handoff = undefined;
168
+ localReload = undefined;
135
169
  directChildFrames.clear();
136
170
  },
137
171
  initialize(tree) {
138
172
  mainFrame = tree.frame;
139
- rootFrameId = tree.childFrames?.find(child => child.frame.name === '__mearl_root__')?.frame
140
- .id;
173
+ const root = tree.childFrames?.find(child => child.frame.name === '__mearl_root__')?.frame;
174
+ rootFrameId = root?.id;
175
+ committedRoot = root && /^https?:/.test(root.url)
176
+ ? { id: root.id, origin: new URL(root.url).origin }
177
+ : undefined;
141
178
  directChildFrames.clear();
142
179
  for (const child of tree.childFrames ?? [])
143
180
  directChildFrames.add(child.frame.id);
144
181
  },
182
+ /** Returns the committed business URL when the root navigates within the current shell. */
145
183
  handleFrameEvent(method, params) {
146
184
  if (method === 'Page.frameNavigated' && params.frame) {
147
185
  const frame = params.frame;
148
186
  if (!frame.parentId) {
149
187
  mainFrame = frame;
150
188
  rootFrameId = undefined;
189
+ committedRoot = undefined;
151
190
  directChildFrames.clear();
152
191
  }
153
- else if (frame.parentId === mainFrame?.id) {
192
+ else if (mainFrame && frame.parentId === mainFrame.id) {
154
193
  directChildFrames.add(frame.id);
155
- if (frame.name === '__mearl_root__') {
194
+ if (frame.id === rootFrameId || frame.name === '__mearl_root__') {
156
195
  rootFrameId = frame.id;
157
- if (/^https?:/.test(frame.url))
196
+ if (/^https?:/.test(frame.url)) {
158
197
  navigationHops = 0;
198
+ const hasPreviousDocument = committedRoot?.id === frame.id;
199
+ const destination = new URL(frame.url);
200
+ committedRoot = { id: frame.id, origin: destination.origin };
201
+ if (hasPreviousDocument && !destination.pathname.startsWith(DOCUMENT_PREFIX) &&
202
+ /^https?:/.test(mainFrame.url) &&
203
+ destination.origin === new URL(mainFrame.url).origin) {
204
+ if (frame.urlFragment !== undefined)
205
+ destination.hash = frame.urlFragment;
206
+ return destination.href;
207
+ }
208
+ }
159
209
  }
160
210
  }
161
211
  }
@@ -164,10 +214,14 @@ export function createRootWebViewNavigation(options) {
164
214
  params.frameId) {
165
215
  directChildFrames.add(params.frameId);
166
216
  }
167
- else if (method === 'Page.frameDetached' && params.reason !== 'swap' && params.frameId) {
168
- directChildFrames.delete(params.frameId);
169
- if (params.frameId === rootFrameId)
170
- rootFrameId = undefined;
217
+ else if (method === 'Page.frameDetached' && params.frameId) {
218
+ if (params.frameId === committedRoot?.id)
219
+ committedRoot = undefined;
220
+ if (params.reason !== 'swap') {
221
+ directChildFrames.delete(params.frameId);
222
+ if (params.frameId === rootFrameId)
223
+ rootFrameId = undefined;
224
+ }
171
225
  }
172
226
  },
173
227
  /** Mock 与真实响应共用交付入口,保持浏览器 URL 和一次性文档语义一致。 */
@@ -178,7 +232,7 @@ export function createRootWebViewNavigation(options) {
178
232
  params.frameId !== mainFrame.id ||
179
233
  !/^https?:/.test(params.request.url))
180
234
  return false;
181
- const headers = await options.shellHeaders();
235
+ const headers = await options.shellHeaders(true);
182
236
  if (!headers || disposed)
183
237
  return false;
184
238
  return deliverDocument({
@@ -226,9 +280,14 @@ export function createRootWebViewNavigation(options) {
226
280
  }
227
281
  return true;
228
282
  }
283
+ if (!responseStage && params.request.method === 'GET' &&
284
+ params.frameId === localReload?.frameId && destination.href === localReload.url) {
285
+ localReload = undefined;
286
+ return false;
287
+ }
229
288
  if (!mainFrame)
230
289
  return false;
231
- const headers = await options.shellHeaders();
290
+ const headers = await options.shellHeaders(responseStage && params.frameId === mainFrame.id);
232
291
  if (!headers || disposed)
233
292
  return false;
234
293
  if (params.frameId === mainFrame.id) {
@@ -255,6 +314,7 @@ export function createRootWebViewNavigation(options) {
255
314
  return false;
256
315
  const current = new URL(mainFrame.url);
257
316
  if (!/^https?:$/.test(current.protocol) ||
317
+ isSameOriginRootNavigation(params) ||
258
318
  (await options.preserveOrigin?.(current, destination)))
259
319
  return false;
260
320
  if (navigationHops >= 20)
@@ -265,17 +325,14 @@ export function createRootWebViewNavigation(options) {
265
325
  responseHeaders: [{ name: 'Cache-Control', value: 'no-store' }],
266
326
  });
267
327
  navigationHops += 1;
268
- handoff = {
269
- url: destination.href,
270
- headers: Object.fromEntries(Object.entries(params.request.headers ?? {}).filter(([name]) => /^(referer|origin)$/i.test(name))),
271
- };
328
+ const clearSource = preserveNavigationSource(params);
272
329
  try {
273
330
  await options.client.sendCommand('Page.navigate', {
274
331
  url: destination.href + (params.request.urlFragment ?? ''),
275
332
  });
276
333
  }
277
334
  catch {
278
- handoff = undefined;
335
+ clearSource();
279
336
  }
280
337
  return true;
281
338
  },
package/dist/toolbox.d.ts CHANGED
@@ -39,6 +39,7 @@ export interface ToolboxSandboxPage {
39
39
  export interface ToolboxAgentConfig {
40
40
  enabled: true;
41
41
  layout?: 'drawer' | 'workspace';
42
+ pageElementActions?: boolean;
42
43
  }
43
44
  export interface ToolboxSkillUpdate {
44
45
  name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mearl/browser-core",
3
- "version": "3.0.0-beta.8",
3
+ "version": "3.0.0",
4
4
  "description": "Shared pure-logic utilities for Mearl — usable in both browser extension and Node.js environments",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -48,6 +48,7 @@
48
48
  }
49
49
  },
50
50
  "files": [
51
+ "README.en.md",
51
52
  "dist",
52
53
  "!dist/**/*.map"
53
54
  ],