Update project structure and dependencies; add architecture documentation and improve build scripts. Introduce new versioning and permissions for enhanced functionality.

This commit is contained in:
go0p
2026-08-06 15:11:35 +08:00
committed by go0p
parent c8380de521
commit 0371a8b802
113 changed files with 15944 additions and 6556 deletions
-578
View File
@@ -1,578 +0,0 @@
---
description: 使用 WXT、React 和 TypeScript 构建代理管理扩展的指南
globs:
alwaysApply: false
---
---
description: 使用 WXT、React 和 TypeScript 构建代理管理扩展的指南
globs: "**/*.ts, **/*.tsx, **/*.js, **/*.jsx"
---
## 概览
](https://wxt.dev/) 是一个为浏览器扩展开发提供现代开发体验的框架。本指南将帮助您使用 WXT、React 和 TypeScript 重构类似 SwitchyOmega 的代理管理扩展。
## 项目结构
推荐使用以下项目结构:
```
.
├── .output/
├── .wxt/
├── modules/
├── public/ # 包含要复制到输出文件夹的任何文件,而无需WXT处理
├── ord/ # 需要重构的旧代码
├── src/
│ │ ├── assets/
│ │ ├── components/
│ │ ├── composables/
│ │ ├── entrypoints/ # 包含所有被捆绑到扩展名的入口点
│ │ ├── hooks/ # 默认自动导入,包含项目用于 React 和 Solid 的钩子的源代码
│ │ ├── utils/
├── .env
├── .env.publish
├── app.config.ts
├── package.json
├── tsconfig.json
├── web-ext.config.ts # 配置浏览器启动
├── wxt.config.ts # WXT项目的主要配置文件
```
Different browsers provide different global variables for accessing the extension APIs (chrome provides chrome, firefox provides browser, etc).
WXT merges these two into a unified API accessed through the browser variable.
```
import { browser } from 'wxt/browser';
browser.action.onClicked.addListener(() => {
// ...
});
```
TIP
With auto-imports enabled, you don't even need to import this variable from wxt/browser!
The browser variable WXT provides is a simple export of the browser or chrome globals provided by the browser at runtime:
export const browser = globalThis.browser?.runtime?.id
? globalThis.browser
: globalThis.chrome;
This means you can use the promise-style API for both MV2 and MV3, and it will work across all browsers (Chromium, Firefox, Safari, etc).
Accessing Types
All types can be accessed via WXT's Browser namespace:
```
import { type Browser } from 'wxt/browser';
function handleMessage(message: any, sender: Browser.runtime.MessageSender) {
// ...
}
```
## 入口点设置
### 后台脚本
```typescript
// entrypoints/background/index.ts
import { defineBackground } from 'wxt/background';
import { setupProxyManagement } from './proxy';
export default defineBackground({
// 设置清单选项
type: 'module',
main() {
// 初始化代理管理
setupProxyManagement();
// 监听消息
browser.runtime.onMessage.addListener((message, sender) => {
if (message.type === 'SWITCH_PROXY') {
return handleProxySwitch(message.proxyId);
}
});
},
});
```
### 弹出窗口
```html
<!-- entrypoints/popup/index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>代理切换器</title>
<meta
name="manifest.default_icon"
content="{
16: '/icon-16.png',
48: '/icon-48.png'
}"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="./index.tsx"></script>
</body>
</html>
```
```tsx
// entrypoints/popup/index.tsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
const root = createRoot(document.getElementById('app')!);
root.render(<App />);
```
```tsx
// entrypoints/popup/App.tsx
import React, { useState, useEffect } from 'react';
import ProxySelector from '../../components/ProxySelector';
import { getProxyList, getCurrentProxy } from '../../utils/proxy';
import type { Proxy } from '../../types';
const App: React.FC = () => {
const [proxies, setProxies] = useState<Proxy[]>([]);
const [currentProxy, setCurrentProxy] = useState<string | null>(null);
useEffect(() => {
const loadData = async () => {
const proxyList = await getProxyList();
const current = await getCurrentProxy();
setProxies(proxyList);
setCurrentProxy(current);
};
loadData();
}, []);
const handleProxyChange = async (proxyId: string) => {
await browser.runtime.sendMessage({ type: 'SWITCH_PROXY', proxyId });
setCurrentProxy(proxyId);
};
return (
<div className="popup">
<h1>代理切换器</h1>
<ProxySelector
proxies={proxies}
currentProxy={currentProxy}
onChange={handleProxyChange}
/>
</div>
);
};
export default App;
```
### 选项页面
```html
<!-- entrypoints/options/index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>代理切换器设置</title>
<meta name="manifest.open_in_tab" content="true" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./index.tsx"></script>
</body>
</html>
```
```tsx
// entrypoints/options/App.tsx
import React, { useState, useEffect } from 'react';
import { getProxyList, saveProxy, deleteProxy } from '../../utils/proxy';
import type { Proxy } from '../../types';
const App: React.FC = () => {
const [proxies, setProxies] = useState<Proxy[]>([]);
const [newProxy, setNewProxy] = useState<Partial<Proxy>>({
name: '',
host: '',
port: '',
protocol: 'http'
});
useEffect(() => {
loadProxies();
}, []);
const loadProxies = async () => {
const list = await getProxyList();
setProxies(list);
};
const handleSaveProxy = async () => {
if (!newProxy.name || !newProxy.host || !newProxy.port) return;
await saveProxy(newProxy as Proxy);
loadProxies();
setNewProxy({
name: '',
host: '',
port: '',
protocol: 'http'
});
};
return (
<div className="options">
<h1>代理管理器设置</h1>
<div className="proxy-list">
{proxies.map(proxy => (
<div key={proxy.id} className="proxy-item">
<span>{proxy.name} ({proxy.protocol}://{proxy.host}:{proxy.port})</span>
<button onClick={() => deleteProxy(proxy.id)}>删除</button>
</div>
))}
</div>
<div className="add-proxy">
<h2>添加新代理</h2>
<input
type="text"
placeholder="名称"
value={newProxy.name}
onChange={e => setNewProxy({...newProxy, name: e.target.value})}
/>
<select
value={newProxy.protocol}
onChange={e => setNewProxy({...newProxy, protocol: e.target.value})}
>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
<option value="socks4">SOCKS4</option>
<option value="socks5">SOCKS5</option>
</select>
<input
type="text"
placeholder="主机"
value={newProxy.host}
onChange={e => setNewProxy({...newProxy, host: e.target.value})}
/>
<input
type="text"
placeholder="端口"
value={newProxy.port}
onChange={e => setNewProxy({...newProxy, port: e.target.value})}
/>
<button onClick={handleSaveProxy}>保存</button>
</div>
</div>
);
};
export default App;
```
### 内容脚本
```typescript
// entrypoints/content.ts
import { defineContentScript } from 'wxt/content-script';
export default defineContentScript({
matches: ['<all_urls>'],
main() {
// 在页面中执行的内容脚本逻辑
console.log('代理切换器内容脚本已加载');
// 根据需要与后台脚本通信
browser.runtime.sendMessage({ type: 'CONTENT_SCRIPT_LOADED' });
},
});
```
## 最佳实践
1. **使用 WXT 存储模块**: 利用 `@wxt-dev/storage` 管理扩展数据。
```typescript
// 安装: npm install @wxt-dev/storage
// utils/storage.ts
import { createStorage } from '@wxt-dev/storage';
export const storage = createStorage({
proxies: {
defaultValue: [],
schema: z.array(z.object({
id: z.string(),
name: z.string(),
protocol: z.enum(['http', 'https', 'socks4', 'socks5']),
host: z.string(),
port: z.string()
}))
},
currentProxyId: {
defaultValue: null,
schema: z.string().nullable()
}
});
```
2. **组件化开发**: 创建可重用的React组件。
```tsx
// components/ProxySelector.tsx
import React from 'react';
import type { Proxy } from '../types';
interface ProxySelectorProps {
proxies: Proxy[];
currentProxy: string | null;
onChange: (proxyId: string) => void;
}
const ProxySelector: React.FC<ProxySelectorProps> = ({ proxies, currentProxy, onChange }) => {
return (
<div className="proxy-selector">
{proxies.map(proxy => (
<div
key={proxy.id}
className={`proxy-item ${currentProxy === proxy.id ? 'active' : ''}`}
onClick={() => onChange(proxy.id)}
>
{proxy.name}
</div>
))}
</div>
);
};
export default ProxySelector;
```
3. **类型安全**: 为所有对象定义TypeScript接口。
```typescript
// types/index.ts
export interface Proxy {
id: string;
name: string;
protocol: 'http' | 'https' | 'socks4' | 'socks5';
host: string;
port: string;
username?: string;
password?: string;
}
export interface ProxyRule {
id: string;
name: string;
pattern: string;
proxyId: string;
}
```
4. **使用环境变量**: 为不同环境配置不同的设置。
```typescript
// wxt.config.ts
import { defineConfig } from 'wxt';
export default defineConfig({
manifest: {
name: process.env.NODE_ENV === 'development' ? '[DEV] 代理切换器' : '代理切换器',
version: '1.0.0',
description: '一个强大的浏览器代理管理扩展',
},
// 其他配置...
});
```
5. **消息通信**: 使用结构化消息系统。
```typescript
// utils/messaging.ts
export type MessageType =
| { type: 'SWITCH_PROXY'; proxyId: string }
| { type: 'GET_CURRENT_PROXY' }
| { type: 'PROXY_CHANGED'; proxyId: string };
export function sendMessage<T extends MessageType>(message: T): Promise<any> {
return browser.runtime.sendMessage(message);
}
```
6. **图标状态管理**: 根据当前代理状态更新扩展图标。
```typescript
// background/proxy.ts
function updateExtensionIcon(proxyId: string | null) {
const iconPath = proxyId
? '/icons/proxy-active.png'
: '/icons/proxy-inactive.png';
browser.action.setIcon({ path: iconPath });
}
```
7. **错误处理**: 实现良好的错误捕获和报告。
```typescript
// utils/error.ts
export async function executeWithErrorHandling<T>(
fn: () => Promise<T>,
errorMessage = '执行操作时出错'
): Promise<T | null> {
try {
return await fn();
} catch (error) {
console.error(`${errorMessage}:`, error);
browser.notifications.create({
type: 'basic',
iconUrl: '/icon-48.png',
title: '代理切换器错误',
message: errorMessage
});
return null;
}
}
```
8. **使用现代钩子**: 为React组件编写自定义钩子。
```typescript
// hooks/useProxies.ts
import { useState, useEffect } from 'react';
import { storage } from '../utils/storage';
import type { Proxy } from '../types';
export function useProxies() {
const [proxies, setProxies] = useState<Proxy[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const load = async () => {
const data = await storage.proxies.get();
setProxies(data);
setLoading(false);
};
load();
return storage.proxies.subscribe(newProxies => {
setProxies(newProxies);
});
}, []);
return { proxies, loading };
}
```
## 版本兼容性
本指南适用于:
- WXT v0.20.0 及以上
- React 18+
- TypeScript 5.0+
## 扩展功能实现
### 代理管理功能
```typescript
// utils/proxy.ts
import { storage } from './storage';
import { v4 as uuidv4 } from 'uuid';
import type { Proxy } from '../types';
export async function getProxyList(): Promise<Proxy[]> {
return await storage.proxies.get();
}
export async function getCurrentProxy(): Promise<string | null> {
return await storage.currentProxyId.get();
}
export async function switchProxy(proxyId: string | null): Promise<void> {
// 更新存储
await storage.currentProxyId.set(proxyId);
if (!proxyId) {
// 清除代理
await browser.proxy.settings.clear({});
return;
}
// 获取代理详情
const proxies = await storage.proxies.get();
const proxy = proxies.find(p => p.id === proxyId);
if (!proxy) return;
// 设置代理
await browser.proxy.settings.set({
value: {
mode: 'fixed_servers',
rules: {
proxyForHttp: {
scheme: proxy.protocol,
host: proxy.host,
port: parseInt(proxy.port)
},
proxyForHttps: {
scheme: proxy.protocol,
host: proxy.host,
port: parseInt(proxy.port)
}
}
},
scope: 'regular'
});
}
export async function saveProxy(proxy: Omit<Proxy, 'id'>): Promise<Proxy> {
const newProxy: Proxy = {
...proxy,
id: uuidv4()
};
const proxies = await storage.proxies.get();
await storage.proxies.set([...proxies, newProxy]);
return newProxy;
}
export async function deleteProxy(proxyId: string): Promise<void> {
const proxies = await storage.proxies.get();
await storage.proxies.set(proxies.filter(p => p.id !== proxyId));
// 如果删除的是当前使用的代理,清除当前代理
const currentProxyId = await storage.currentProxyId.get();
if (currentProxyId === proxyId) {
await storage.currentProxyId.set(null);
await browser.proxy.settings.clear({});
}
}
```
## 相关资源
- [WXT 官方文档](mdc:https:/wxt.dev)
- [WXT GitHub 仓库](mdc:https:/github.com/wxt-dev/wxt)
- [Chrome 扩展 API 文档](mdc:https:/developer.chrome.com/docs/extensions/reference)
- [React 文档](mdc:https:/reactjs.org)
- [TypeScript 文档](mdc:https:/www.typescriptlang.org)
+1
View File
@@ -32,3 +32,4 @@ web-ext.config.ts
ord/
.artifacts/
+175
View File
@@ -0,0 +1,175 @@
# Yakit Browser Agent Architecture
## Goals
- Reuse a user's real, authenticated browser session without exporting a complete browser profile.
- Let an AI agent inspect a deliberately shared tab and request human takeover for QR codes, MFA, CAPTCHA, or device confirmation.
- Keep proxy, Cookie, User-Agent, page-context, and page-function capabilities behind one typed command boundary.
- Make grants short lived, tab scoped, visible, and revocable.
## Layers
### Capability layer
The background service owns browser capabilities. Every remote command passes through one router before it reaches browser APIs.
| Method | Required scope | Effect |
| --- | --- | --- |
| `browser.tabs` | `browser.tabs.read` | Lists only tabs included in the active grant |
| `browser.frames` | `browser.tabs.read` | Lists main, same-origin, and cross-origin frames for a granted tab |
| `browser.context` | `browser.dom.read` | Captures a bounded structured snapshot and diff; Storage and Cookie require their own scopes |
| `browser.node.inspect` | `browser.dom.read` | Inspects a document-bound node without returning the current input value |
| `browser.node.action` | `browser.dom.write` | Clicks, focuses, scrolls, or writes a value through a current node reference |
| `browser.cookies` | `browser.cookies.read` | Reads cookies for a granted tab |
| `browser.takeover` | `browser.tab.activate` | Focuses a granted tab for a human step |
| `browser.handoff.request` | `browser.human.takeover` | Starts a visible QR/MFA/CAPTCHA/device-confirmation handoff |
| `browser.handoff.status` | `browser.human.takeover` | Reads the current task's handoff state |
| `browser.network.status/list` | `browser.network.read` | Reads capture state and request metadata |
| `browser.network.start/stop/clear` | `browser.network.capture` | Controls a bounded capture session for a granted document |
| `browser.network.export` | `browser.network.sensitive.read` | Builds a replay packet from explicitly captured headers/body |
| `browser.invoke` | `browser.page.invoke` | Calls an existing page-world function by path |
| `browser.eval` expression | `browser.page.eval.expression` | Executes one parenthesized expression in a granted page world |
| `browser.eval` program | `browser.page.eval.program` | Executes statements and side effects under an independent high-risk scope |
| `browser.observe.*` | `browser.observation.read/control/sensitive.read` | Controls bounded Fetch/XHR/Form/WebSocket/WebCrypto/CryptoJS observation |
| `proxy.list` | `browser.proxy.read` | Lists extension proxy profiles |
| `proxy.switch` | `browser.proxy.write` | Switches the browser proxy profile |
The transport never calls browser APIs directly.
### Grant layer
A grant contains:
- an unpredictable session ID;
- a task ID;
- one or more explicit tab, frame, and document IDs with their origin and grant-time URL;
- an explicit set of capability scopes;
- creation and expiration timestamps.
Expired grants are rejected and removed. Reloading or navigating a document returns `stale_document`; navigation to a different origin returns `origin_changed`. Neither condition silently retargets an operation. The UI still offers read/control presets, but those presets only create concrete scope sets and are not stored as authorization levels. A remote caller cannot expand a grant. Only extension UI initiated by the user can create or replace one.
### Transport layer
Bridge v3 supports authenticated loopback WebSocket and optional Native Messaging:
```text
Browser extension -> ws://127.0.0.1:<port>/extension -> Yak engine / AI session
```
The Yak gRPC process owns this listener and starts it on `127.0.0.1:64333` by default. Yakit controls it through the existing `RequestYakURL` RPC with the `browser-extension://` schema, so pairing, approval, device rename and revocation do not add dedicated gRPC methods.
First-time pairing uses `/pairing`. The extension generates an origin-bound ECDSA P-256 installation identity and keeps its non-extractable private key in IndexedDB. Yak keeps a persistent engine identity under the Yakit home directory with owner-only file permissions. The plugin and Yakit derive the same six-digit code from both nonces, identities, origin and public keys; the user approves only after comparing that code. No bearer token is stored or copied.
The browser-profile `installationId` is stable across disconnects and local unpairing; clearing a pairing destroys the local signing key but does not manufacture a new browser installation. A later approved pairing with the same installation ID rotates the public credential in place while preserving the Yak `deviceId`, user-visible name and creation time. If browser storage was actually erased and a new installation ID is unavoidable, Yakit must explicitly choose whether to replace a matching offline identity or add a separate browser profile. Replacement is restricted to the same extension origin and client, so a shared Chrome extension ID is never used as an unsafe global deduplication key.
Every `/extension` connection starts with a signed engine challenge. The extension verifies the approved engine public key and replies with a signature from its paired installation key. Yak verifies both the installation ID and browser extension Origin before returning `hello_ack`. The connection is not reported ready until that acknowledgement confirms protocol, capabilities, engine identity, engine instance, connection and session identities. The authentication message also carries the current task/grant identity. A disconnected installation can resume its logical session while each physical connection receives a new ID. Revoking a device immediately closes its active connection. Heartbeats carry sequence/timestamps and expose round-trip latency.
Request IDs allow concurrent calls in both directions. The extension accepts at most eight engine-initiated in-flight requests, rejects duplicate IDs, supports cancellation, and applies a 16 MiB aggregate limit. Messages above 512 KiB are split into bounded 256 KiB chunks with transfer count/timeout limits. Yak forwards context cancellation and buffers extension events in a bounded queue exposed as `browser.ExtensionWaitEvent`.
### Yakit device tasks
Pairing and device CRUD remain on `RequestYakURL`. Executable work uses one server-streaming RPC, `ExecuteBrowserExtensionTask`, with stable routing fields (`task_id`, `device_id`, `schema`, JSON payload and timeout). The initial schemas are:
- `capability.call`: invokes one extension capability with `{method, params}` and returns its JSON result;
- `yak.script`: executes Yak in the owning gRPC process and injects a request-bound `browser.ExtensionCall` and `browser.ExtensionStatus` for the selected device.
The engine supports multiple simultaneous browser connections. Calls are routed by paired device ID, pending responses are bound to the target WebSocket, and a disconnect immediately fails that device's outstanding calls. A schema handler cannot silently fall back to another online browser.
Task events use a small common vocabulary (`queued`, `running`, `log`, `result`, `warning`, `error`, `cancelled`, `completed`) with monotonic sequence and timestamp fields. The RPC bounds payload size, timeout, concurrent scripts, per-event data and aggregate output; cancelling the stream propagates through the Yak context to the extension request.
The Yak runner is a controlled in-process context, not an operating-system sandbox. It prevents process exit, recovers VM panics and enforces resource bounds, but only trusted operator-authored code should use it. Untrusted or remotely supplied scripts require a future isolated worker. The generic `ExecYakScript` path is intentionally not reused because its child process does not own the parent process's live Bridge manager.
Native Messaging uses the same Bridge v3 challenge/auth envelope and paired identity contract:
```text
Browser extension -> registered Yakit Native Host -> loopback Yak Bridge -> running Yak engine
```
The Yak repository contains `common/browser/nativehostcmd`, a stdio framing proxy with loopback/origin validation. `native-host/install.sh` and `install.ps1` register per-user Chrome/Chromium/Edge/Brave/Firefox manifests. `nativeMessaging` is optional and requested only when the user explicitly saves Native mode.
## Human takeover
Agent workflows treat human participation as an explicit, persisted state transition:
1. The agent detects a QR code, MFA prompt, CAPTCHA, or device confirmation.
2. It calls `browser.handoff.request` for a document in the active control grant.
3. The extension focuses the tab, shows a badge, expands the target page panel, and displays the same request in Popup and Options.
4. The Agent pauses without polling sensitive content.
5. The user chooses **操作已完成** or **取消任务**.
6. The extension emits `browser.handoff.changed`; Yak receives it through `ExtensionWaitEvent`.
7. The Agent matches the handoff ID, captures a fresh context, and continues only after `completed`.
`browser.takeover` remains a short-lived focus action without a completion lifecycle.
## Network capture
Network capture uses the browser `webRequest` API rather than page-world Fetch/XHR monkey patches. This preserves the actual outgoing request headers, browser-added Cookie header, request body, redirect status, cache state, and timing. The listener is filtered to Fetch/XHR, ping, and related programmatic requests; images, stylesheets, scripts, fonts, and media are not collected.
Each capture session is bound to one tab, frame, and document. Chrome MV3 stores the bounded session in `storage.session`, so Service Worker suspension does not move sensitive records into persistent settings. Firefox MV2 keeps the same data in background memory. Defaults are metadata-only, 100 entries, and no request headers or body. Explicit sensitive capture is capped at 200 entries and 64 KiB per request body; the UI currently uses 100 entries and 32 KiB.
Generating a replay packet requires captured request headers. The packet is reconstructed as HTTP/1.1 with the observed header values and bounded body bytes. Truncated or omitted bodies produce an explicit limitation warning. Sending to Yakit is a confirmed Bridge request: Yak validates a maximum 2 MiB packet, saves a Web Fuzzer page configuration in the current project database, broadcasts the new tab to Yakit, and returns its `pageId` before the extension reports success.
## Page-world code
### Structured context and node references
`browser.context` no longer returns a full HTML document. A snapshot contains a 20 KiB body-text excerpt, bounded headings/forms, up to 400 actionable nodes discovered while scanning at most 10,000 elements, a full frame inventory, optional bounded Web Storage values, optional IndexedDB database/store/key metadata, optional CacheStorage names, bounded document/SPA lifecycle events, optional Cookie values, authentication signals, and a diff against the preceding snapshot for the same tab/frame. IndexedDB and Cache values are never collected. Open Shadow Roots are traversed recursively; the extension's own edge-panel Shadow Root is excluded.
Each actionable element is registered in the page's MAIN world and identified by `captureId + tabId + frameId + documentId + nodeId`. `browser.node.inspect` and `browser.node.action` resolve the registered `Element` directly instead of re-running a CSS selector. A new capture replaces the registry, a detached element is rejected, and a changed document fails target resolution. These paths return `stale_node` or `stale_document`; they never silently retarget a similar element.
Frame inventory combines `webNavigation.getAllFrames` with a bounded packaged probe in every accessible frame. Grants store an explicit target for each selected `tabId + frameId + documentId + origin`; selecting a tab authorizes only its main frame until the user separately selects child frames. `webNavigation.getFrame` verifies each remote operation against the current frame URL and document. Cross-origin navigation returns `origin_changed`, while same-origin document replacement returns `stale_document`.
Node inspection returns bounded identity, safe attributes, visibility, state, and viewport bounds. It deliberately excludes the current input value. Node actions support `click`, `focus`, `scroll`, and `setValue`; `setValue` uses native value setters plus input/change events, rejects file inputs, requires `browser.dom.write`, and never sends the supplied value to the audit writer. Programmatic click is a page-world click and is not represented as a trusted physical mouse event.
The authentication classification is a heuristic based on bounded DOM controls plus explicitly requested Cookie names and Storage keys. It is useful for workflow routing, but it is not proof that the server accepts the current session.
`PageExecutionAdapter` selects an execution mechanism at build time. Production/store Chrome builds use the Web Store-permitted User Scripts API:
```text
Background capability router
-> userScripts.execute({ world: "MAIN" })
-> structured { ok, result | error }
```
The default production build declares Chrome 138+, requires the user to enable Allow User Scripts, and physically omits `page-main-world.js`. It never silently falls back to direct Eval.
User Scripts receive the selected expression or program as direct script source; the Store path never calls `eval` on Bridge-provided text. Expression mode automatically returns its expression. Program mode is an async function body and requires an explicit `return` to produce a value; without one it returns `undefined`.
Development and local Firefox MV2 builds use WXT's packaged injection pattern. Enterprise Chrome prefers User Scripts and retains this pattern only as a managed fallback:
```text
Background capability router
| tabs.sendMessage (extension-only)
Isolated content script
| correlated CustomEvent on the injected script element
Unlisted page-main-world script
| indirect eval / function invocation
The page's real window context
```
The old extension established the essential behavior by injecting `inject.js` and forwarding `CONTENT_EVAL_CODE` through `window.postMessage`. The current bridge preserves that capability while adding request IDs, Promise resolution, response timeouts, error propagation, cycle-safe result serialization, output limits, and content-script lifecycle cleanup. A timeout stops the extension from waiting for an asynchronous result; JavaScript cannot safely interrupt synchronous code, so an infinite loop can still block the target page.
Both adapters share the same expression/program return rules, result serializer, Promise behavior, timeout bounds, and error envelope. Local Eval is initiated by an explicit user action. Remote expression and program modes require separate scopes and a target whose tab, frame, document and origin still match. Because the page controls its JavaScript environment, all results remain untrusted input.
The public Firefox MV3 AMO channel is invoke-only at the extension boundary: it requests neither `userScripts` nor general page invocation/Eval, does not package `page-main-world.js`, and advertises neither Bridge capability. This follows Mozilla's current restriction of `userScripts` to user-script managers. Structured context, stable node commands, network capture, observation and human handoff remain available.
The same page bridge supports `browser.invoke` for the narrower case where the Agent already knows a concrete global function path. Fetch/XHR/Form/WebSocket/WebCrypto/CryptoJS observation uses the same grant and lifecycle boundaries through a separate bounded MAIN-world observer.
## Page UI loading
The content script is a roughly 10-12.2 KiB native DOM shell. It owns the Yak launcher, bridge indicator, drag position, left/right snapping, and handoff-triggered expansion. React, Radix, and the floating workbench are loaded in `floating.html` only after the user expands the launcher or a handoff targets that tab; the iframe is released after 60 seconds collapsed. Build auditing prevents the content script from exceeding its size budget.
Popup, Options, and the floating workbench share one token-based design system in `src/styles/`: `tokens.css` defines the palette, type scale (11-20px), radii, and shadows, including a full dark set under `[data-theme='dark']`; `ui.css` styles the shared Radix-backed components. The vivid brand orange is reserved for non-text accents; filled primary buttons and text links use a deeper AA-contrast orange. All surfaces are light-first — the orange yak mark is shown bare without a backing tile. The theme preference (`system`/`light`/`dark`) lives in its own `settings.appearance.v1` local-storage key, is written only from extension UI, and is applied to `<html data-theme>` by each entrypoint through `src/platform/storage/appearance.ts`; the content-script launcher reads the same key in-page (falling back to the OS scheme) to theme its shadow-DOM shell.
## Audit boundary
Audit events live under a separate storage key and are serialized independently from settings and active session state. The bounded log retains the latest 500 events. It records category, method/action, outcome, task ID, tab ID, duration, error code, and a fixed safe summary where applicable. Capability parameters and results are never passed to the audit writer. The Options activity view reads the latest 200 entries and lets the user clear them locally.
## Production operations
- State v7 uses separate durable proxy/UA/Bridge/panel keys and separate session grant/Bridge/action keys; mutation is serialized across domains and no legacy migration path exists.
- Agent actions have a session timeline and user pause/resume/revoke controls. Persistent audit remains metadata-only.
- Managed storage can lock transport, endpoint/host, grant duration/origins, program Eval and panel availability. Enforcement is in background handlers.
- Aggregate Service Worker, Bridge, heartbeat and capability metrics stay local. Explicit diagnostics export omits URLs, values, payloads, Eval code and task/grant identifiers.
- Public review artifacts live under `docs/store-review`; privacy, permission and enterprise deployment contracts live under `docs/`.
- Store/Enterprise Chromium E2E covers 320/390/desktop UI, service-worker restart, frame/document/origin boundaries, request/observation workflows, handoff, audit/diagnostic redaction and state concurrency. Go tests cover Bridge v3 pairing, code derivation, signed challenge/auth, revocation, YakURL control, chunking/session recovery and Native Messaging proxy framing.
+69
View File
@@ -0,0 +1,69 @@
# Design System: Yakit Browser Agent
**Project ID:** yakit-chrome-client (derived from codebase design tokens, `src/styles/tokens.css` — no Stitch project)
## 1. Visual Theme & Atmosphere
A **focused security instrument panel**: utilitarian, information-dense, and calm. The aesthetic philosophy is "console first, chrome second" — content surfaces stay quiet and neutral so that state (connection, capture, risk) can carry all the visual signal. The mood is airy-but-dense: compact 13px typography and tight 8px-rhythm spacing, balanced by generous card padding and breathing room between functional groups.
The brand presence is deliberately restrained: a light, continuous surface carries every view, signed by the bare orange yak mark and a single ember-orange accent reserved for moments of genuine emphasis. Nothing glows, nothing gradients, no black slabs; depth comes from whisper-soft shadows and hairline separators, not borders. The system ships in twin themes — a cool light canvas and a true-dark console — with identical geometry and hierarchy, switched by a user preference (`system` / `light` / `dark`).
## 2. Color Palette & Roles
### Light theme (default)
- **Canvas Mist (#f3f4f6)** — application background; lets white cards float without borders.
- **Card White (#ffffff)** — primary content surfaces: cards, tables, panels, inputs.
- **Inset Pebble (#eceef1)** — recessed fills: stat tiles, code-free inset areas, toggle-off track.
- **Ink (#1d232a)** — primary text and strong values.
- **Slate Note (#68727d)** — secondary text, descriptions, timestamps.
- **Label Slate (#474f59)** — field labels, section labels, ghost-button text.
- **Hairline (#e1e4e8)** — non-structural separators (table rows, list dividers); used sparingly.
- **Frame Line (#c8cfd6)** — input strokes and secondary-button outlines.
- **Yak Orange (#ee7815)** — brand accent for *non-text* signal only: toggle-on tracks, active nav indicator, icon highlights, focus halo. Never carries text.
- **Ember (#b54f08)** — the accessible action orange: filled primary buttons (white text, 5.1:1 AA) and text links on light surfaces.
- **Ember Deep (#9e4607)** — hover state for filled primary buttons.
- **Ember Wash (#fdf0e1)** — soft selection tint: active list rows, selected table lines.
- **Pine (#1e7f52)** on **Mint Mist (#e4f3eb)** — connected, captured, success states.
- **Umber (#94650d)** on **Parchment (#fcf2d9)** — warning states and the human-handoff surface.
- **Brick (#bf3d3d)** on **Blush (#fbeaea)** — destructive actions, errors, failed states.
- **Bare Yak (the orange brand mark, #f97a04 family)** — shown directly on the surface with no backing tile; it is the only persistent brand signature.
### Dark theme (`[data-theme='dark']`)
- **Deep Space (#0e1116)** — application background; true console dark, not navy.
- **Panel Slate (#161b21)** — cards and surfaces.
- **Raised Slate (#1e242c)** — inset fills and hover states.
- **Fog Text (#e2e7ec)** — primary text; **Ash (#8a949f)** secondary; **Mist Strong (#b2bcc5)** labels.
- **Ember Glow (#f5832a)** — filled primary buttons with **Roasted Ink (#201205)** text (7.5:1 AA); brighter than light theme to hold contrast on dark.
- **Ember Light (#f7a15c)** — text links and code accents on dark surfaces.
- Semantic tints deepen to translucent darkness: **Pine Glow (#45b981 / #122a1f)**, **Amber Glow (#d9a441 / #2c2311)**, **Coral Glow (#e06e6e / #2f1b1b)**.
## 3. Typography Rules
- **Family:** A system-native sans stack (Inter falling back to ui-sans-serif, system-ui, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Noto Sans CJK SC) — chosen for crisp CJK rendering at small sizes without bundling font files. Code and packets use a mono stack (ui-monospace, SF Mono, Consolas).
- **Scale (six steps, no more):** 11px for uppercase micro-labels only, 12px secondary/description, **13px as the reading base**, 14px emphasized values, 16px section titles, 20px page titles.
- **Weight hierarchy:** 500 for navigation, 600 for interactive text and labels, 650 for card titles and strong values, 700 reserved for page titles and hero numerals.
- **Micro-labels:** 11px, weight 650, letter-spacing .04em, uppercase, Slate Note color — the "eyebrow" voice used above data.
- **Rhythm:** line-heights stay tight (1618px for body); Chinese text is never set below 12px except uppercase micro-labels.
## 4. Component Stylings
* **Buttons:** 36px tall with gently squared corners (6px radius) and 13px semibold labels. The *filled primary* is Ember (light) / Ember Glow (dark) with contrasting text — strictly one per view. *Secondary* buttons are Card White with a Frame Line stroke. *Ghost* buttons are transparent until hovered. *Danger* is a Brick outline that fills with Blush on hover. Small (30px) and icon (34px square) variants share the same geometry.
* **Cards/Containers:** Generously rounded corners (12px radius), Card White fill, and a whisper-soft two-layer shadow (a 1px key line of shade plus a faint 4px lift) — no borders. Recessed stat tiles inside cards use Inset Pebble with softly rounded corners (8px). Nothing nests a shadowed card inside another.
* **Inputs/Forms:** 36px tall, 6px corner radius, 1px Frame Line stroke on Card White; textareas keep the same stroke. Focus never shows a hard outline — instead a soft ember halo (3px of translucent Yak Orange). Field labels are 12px semibold Label Slate; hints in 12px Slate Note.
* **Toggles:** Pill-shaped switches (40×22px), Pebble track when off, Yak Orange track when on, white 16px thumb gliding on a short ease.
* **Navigation rail:** A 238px rail in the same surface as the workspace, separated by a single hairline. Items are 40px rows with softly rounded corners (8px); the active item shows a subtle raised fill plus a 3px inset Yak Orange indicator bar on its leading edge, with its icon tinted orange.
* **Status pills:** Fully rounded (pill-shaped, 999px) badges pairing each semantic color with its soft wash — connected/capturing in Pine-on-Mint, waiting/warning in Umber-on-Parchment, error in Brick-on-Blush.
* **Code & packets:** Deep slate panels (#171b20, light mono text) with 8px rounded corners; they remain dark in both themes as "terminal territory."
* **Handoff surface:** A Parchment card with a 3px Umber leading edge and the warning icon — the single interruptive pattern in the system, reserved for QR/MFA/CAPTCHA human takeover.
## 5. Layout Principles
- **Shell:** A fixed 238px dark rail plus a fluid workspace. The workspace column is capped at a comfortable 1440px reading width and **horizontally centered**, so ultra-wide monitors frame the console instead of stretching tables into unreadability.
- **Grid alignment:** The 60px sticky topbar shares the exact content grid — its padding is computed from the same 1440px cap (`max(28px, (100% 1440px)/2 + 28px)`), keeping the target-tab chip and the page content on one vertical line.
- **Spacing rhythm:** An 8px base unit; 16px gaps between cards, 1620px inner card padding, 2228px page padding. Groups are separated by space and shadow, not rules.
- **Two-column workbenches:** Data pages (network, cookies, context, engine) use a fluid primary column with a 320440px inspector column that sticks below the topbar; below 1080px they stack to a single column.
- **Grid discipline:** Every single-column vertical grid declares an explicit `minmax(0, 1fr)` track, so long URLs and code strings truncate with ellipses instead of overflowing narrow (320390px) viewports.
- **Popup:** A fixed 390px single-sheet column — sections divided by hairlines, not floating cards — designed to a strict 600px height budget, keeping every action including the bottom primary capture button visible without scrolling.
- **Floating panel:** A 46px edge launcher — a white stadium orb with the bare yak mark (dark in dark theme, theme-aware in-page) — that expands to a 326px rounded workbench over the page; its header shares the panel surface with a single hairline seam.
- **Motion:** Short (140180ms) ease transitions on color and slide only; `prefers-reduced-motion` collapses all animation.
+83 -2
View File
@@ -1,3 +1,84 @@
# WXT + React
# Yakit Browser Agent
This template should help get you started developing with React in WXT.
Browser security tools and a consent-gated context bridge for Yak AI agents.
The WXT extension includes proxy profiles and PAC routing rules, Cookie and User-Agent tools, a Shadow DOM edge panel, authenticated-tab context capture, and controlled execution in the page's real JavaScript world. Structured context uses bounded text, forms, authentication signals, open Shadow DOM traversal, context diffs, and document-bound node references instead of exporting full page HTML. AI access is bound to a concrete tab, frame, document, origin, task, scope set, and expiration time. Yak/Yakit product assets are kept in `public/` and exposed to content scripts through explicit web-accessible resources.
When an Agent reaches a QR code, MFA, CAPTCHA, or device confirmation, it can create a human handoff. The target tab is focused, the extension presents the request in Popup, Options, and the edge panel, and the Agent receives a completion or cancellation event after the user decides. The network workspace can capture a granted document's real Fetch/XHR requests and open an authenticated replay packet in Yakit Web Fuzzer. Sensitive headers, Cookie, and body capture are off by default and session-only. A separate local audit log stores only method, target, timing, and outcome metadata; it does not store page content, Cookie values, Eval source, network payloads, arguments, or results.
## Development
```bash
pnpm install
pnpm dev
```
WXT intentionally refuses to launch browsers automatically when it detects WSL, even when WSLg and a Linux Chrome are available. Use the project runner instead:
```bash
pnpm dev:wsl
```
It keeps the development profile in `.wxt/chrome-wsl-profile`. Official Chrome 137+ no longer accepts `--load-extension`, so the first run opens `chrome://extensions`: enable Developer mode and load `.output/chrome-mv3-dev` once. The profile remembers it on later runs.
Chromium and Chrome for Testing still support automatic loading. Select one with:
```bash
CHROME_PATH=/path/to/chromium pnpm dev:wsl
```
Production builds:
```bash
# Chrome Web Store: User Scripts MAIN, no direct Eval bridge
pnpm build
# Explicitly named store output
pnpm build:store
# Managed/local deployment: User Scripts MAIN with packaged bridge fallback
pnpm build:enterprise
# Local/enterprise Firefox MV2 injected bridge
pnpm build:firefox
# Public Firefox MV3 AMO invoke-only package
pnpm build:firefox:amo
```
Chrome 138+ requires the user to enable **Allow User Scripts** on the extension details page before the store build can run page-world Eval. The extension reports this condition explicitly and does not fall back to direct Eval.
Production verification:
```bash
pnpm verify:production
pnpm verify:ui:store
pnpm verify:ui:enterprise
pnpm verify:ui:enterprise:fallback
pnpm verify:native
```
`verify:production` runs Vitest and enforces content-script, background, total-size, permission, managed-policy, execution-channel, `webRequest`, and web-accessible-resource budgets across four packages. Browser E2E covers Chrome Store User Scripts, Enterprise User Scripts, and the Enterprise injected fallback, including document-bound grants, context diff, stable node operations, expression/program scope separation, pause/resume/revoke, human handoff, request/crypto observation, Yakit workflows, split storage, Service Worker restart, audit/diagnostic redaction, strict CSP, fail-closed tab teardown, and 320/390/desktop UI bounds. `verify:native` builds the Go host and exercises Chromium Native Messaging through the host into a loopback Yak Bridge fixture; because Playwright cannot operate Chrome's toolbar permission prompt, only its disposable test copy pre-grants `nativeMessaging`, while the source Store package is asserted to remain optional.
Browser verification prefers `CHROMIUM_PATH`, then `CHROME_PATH`, Playwright's Chromium cache, Chrome for Testing, or system Chromium. It deliberately does not auto-select stable Google Chrome because current stable Chrome ignores unattended `--load-extension` startup flags.
## Pair with Yak and Yakit
The Yak gRPC process owns the local browser Bridge. The standard command starts Bridge v3 on `127.0.0.1:64333` automatically, so there is no separate Bridge script or shared token to configure:
```bash
go run common/yak/cmd/yak.go grpc --host 0.0.0.0
```
Open **系统设置 -> 浏览器集成** in Yakit, then open **引擎连接** in the extension and choose **查找本机 Yakit**. Both surfaces display the same six-digit verification code. Compare the code and approve the pending browser in Yakit. The approval persists an origin-bound device identity; later connections authenticate automatically with signed challenges. Removing the device in Yakit immediately disconnects it and requires a new approval.
To run a browser task, create a sharing grant for the target tab in the extension, return to **系统设置 -> 浏览器集成**, and click the online browser row. The device workspace can call a scoped capability directly or run Yak code with a request-bound `browser.ExtensionCall`. Task state, logs, JSON results, cancellation, and errors are streamed in that workspace. Do not use the generic `ExecYakScript`/`grpc_execYak` runner for this flow: that runner starts a child Yak process and cannot own the parent gRPC process's live browser connections.
Advanced transport settings remain available for a non-default loopback port or Native Messaging deployment. `--browser-extension-bridge-port` changes the Yak listener, and `--disable-browser-extension-bridge` disables it explicitly.
## Native Host and deployment
Build the Native Messaging transport from the Yak repository and register it with the signed or unpacked extension ID:
```bash
go build -o yakit-browser-agent-host ./common/browser/nativehostcmd
./native-host/install.sh --host-binary /absolute/path/to/yakit-browser-agent-host --extension-id YOUR_EXTENSION_ID
```
Windows uses `native-host/install.ps1`. Native Messaging is an optional browser permission requested only when Native mode is selected. See [Native Host installation](native-host/README.md), [enterprise policy](docs/ENTERPRISE_POLICY.md), [permissions](docs/PERMISSIONS.md), [privacy](docs/PRIVACY_POLICY.md), and the [release review packet](docs/store-review/RELEASE_CHECKLIST.md).
+38
View File
@@ -0,0 +1,38 @@
# Enterprise Deployment
The extension includes `managed-storage-schema.json`. Managed values are read-only and rechecked by background command handlers, not only reflected in disabled UI controls.
Supported policies:
| Key | Type | Effect |
| --- | --- | --- |
| `bridgeTransport` | `native` or `websocket` | Locks transport. |
| `bridgeEndpoint` | string | Locks the explicit loopback WebSocket endpoint. |
| `nativeHost` | string | Locks the Native Messaging host name. |
| `autoConnect` | boolean | Locks startup connection behavior. |
| `disableWebSocket` | boolean | Requires Native Messaging. |
| `floatingPanelEnabled` | boolean | Enables or disables the page panel. |
| `maxGrantMinutes` | integer, 5-1440 | Caps every grant even if the UI requests longer. |
| `grantAllowedOrigins` | origin array | Rejects grants containing any other origin. |
| `allowProgramEval` | boolean | Can prohibit the independent program Eval scope. |
Example managed policy values:
```json
{
"bridgeTransport": "native",
"nativeHost": "com.yaklang.browser_agent",
"autoConnect": true,
"disableWebSocket": true,
"maxGrantMinutes": 60,
"grantAllowedOrigins": ["https://security-lab.example"],
"allowProgramEval": false,
"floatingPanelEnabled": true
}
```
Chrome/Edge administrators distribute these values using the platform's extension managed-storage policy and can force-install the signed extension ID. Firefox administrators use the `3rdparty.Extensions` policy for the add-on ID `[email protected]`. Native Host registration remains an operating-system deployment step; see [native-host/README.md](../native-host/README.md).
The Chrome enterprise package prefers User Scripts MAIN for CSP-compatible page execution and retains the packaged injected bridge as a fallback when User Scripts is unavailable. Administrators should enable User Scripts for the extension in managed Chrome deployments when strict-site CSP execution is required; the fallback remains suitable for explicitly managed sites whose CSP permits it.
Device pairing identities are deliberately excluded from managed storage. Pair each extension installation locally through Yakit. The extension keeps its non-extractable private key in IndexedDB and Yak stores only the approved public device identity, so a broadly readable policy backend never becomes a credential store.
+22
View File
@@ -0,0 +1,22 @@
# Permission Inventory
Every permission maps to a shipped, user-facing feature. Future functionality is not a reason to retain an unused permission.
| Permission | Purpose | User control |
| --- | --- | --- |
| `proxy` | Apply direct/system/fixed/PAC profiles and deterministic routing rules. | Profiles and rules are visible and switchable; passwords are session-only. |
| `storage` | Store split settings, active session, bounded audit and aggregate metrics. | Audit, action timeline and metrics can be cleared; diagnostics export is explicit. |
| `tabs` | Resolve the exact user-selected tab and open Options/Yakit workflow pages. | Grant and target picker identify the tab. |
| `scripting` | Run packaged frame probes, stable-node operations and page observation. | Page operations are explicit and scoped. |
| `cookies` | Provide the Cookie Editor and explicitly granted authentication context. | Values are hidden and exports redacted by default. |
| `declarativeNetRequest` | Change the real outbound User-Agent request header. | Named UA rules are visible and removable. |
| `webRequest` | Capture bounded Fetch/XHR/Form metadata and proxy rule hits. | Capture starts explicitly; headers/body are off by default. |
| `webNavigation` | Track frame/document identity and SPA/document lifecycle. | Used to reject stale or cross-origin targets. |
| `webRequestAuthProvider` (Chrome) / `webRequestBlocking` (Firefox) | Answer proxy authentication challenges. | Username is in the profile; password is browser-session-only. |
| `userScripts` (Chrome Store/Enterprise) | Execute user/Agent-selected page code through Chrome's documented MAIN-world User Scripts API. | Chrome also requires the user to enable Allow User Scripts; expression/program grants are separate. |
| `nativeMessaging` (optional) | Connect to the installed local Yakit Native Host. | Requested only when the user selects Native Host in Options. |
| `<all_urls>` host access | Support authenticated testing on the HTTP(S) site selected by the user, the floating task control, frame inventory and request capture. | Site panel rules and task-bound grants narrow actual Agent access. Browser-internal pages remain unavailable. |
`activeTab` is intentionally not requested. Firefox AMO does not request `userScripts`; its public build is invoke-only and excludes general page function invocation/Eval. Chrome Store does not package the injected Eval bridge.
References: [Chrome minimum permission policy FAQ](https://developer.chrome.com/docs/webstore/program-policies/user-data-faq), [Chrome MV3 requirements](https://developer.chrome.com/docs/webstore/program-policies/mv3-requirements), and [Mozilla Add-on Policies](https://extensionworkshop.com/documentation/publish/add-on-policies/).
+54
View File
@@ -0,0 +1,54 @@
# Yakit Browser Agent Privacy Policy
Effective date: 2026-07-17
Yakit Browser Agent is a browser security-testing extension that connects browser context selected by the user to a Yak/Yakit engine running on the same computer. This policy describes the extension source in this repository and its official packaged builds.
## Data the extension handles
Depending on the command the user selects and the grant scopes they enable, the extension can handle:
- page URL, title, frame and document identity;
- bounded page text, forms, interactive element metadata, open Shadow DOM metadata, and authentication signals;
- Cookie metadata and values, including HttpOnly cookies exposed by the browser Cookies API;
- localStorage/sessionStorage keys, IndexedDB database/store/key inventory, and CacheStorage names; database and cache values are not collected;
- request URL, method, timing and status, plus request headers, Cookie and body only when sensitive capture is explicitly enabled;
- temporary Fetch/XHR/Form/WebSocket/WebCrypto/CryptoJS observations; value previews require a separate sensitive scope;
- proxy, User-Agent header, floating-panel and Bridge settings;
- local operational metrics such as aggregate Bridge latency, connection errors, capability duration and Service Worker starts.
## How data is used
Data is used only to provide user-facing browser security workflows: inspect an authenticated page, operate explicitly selected elements, replay a selected request in Yakit, analyze authentication/signing behavior, or let an Agent continue after user-controlled QR/MFA/CAPTCHA handling. It is not used for advertising, credit decisions, user profiling, sale, or unrelated analytics.
## Data transmission
The extension has no developer-operated telemetry or analytics endpoint. Browser context is sent only after a user creates a time-limited grant or invokes a clearly labeled workflow. The destination is the user-configured Native Messaging host or an explicit loopback WebSocket endpoint. The default is `ws://127.0.0.1:64333/extension`.
The Native Host is a local transport to that loopback Yak Bridge. Bridge v3 still verifies the paired extension identity, browser extension Origin, task, grant, target and capability scopes. A website cannot access this channel.
## Local storage and retention
- Proxy, User-Agent, Bridge and floating-panel settings remain until the user changes them or removes the extension.
- The paired engine public identity and device ID are local settings. The extension's non-extractable P-256 private key remains in extension-owned IndexedDB; no reusable bearer token is stored. Proxy passwords, active grants, handoffs, action timelines, captured requests and observation values are session-scoped.
- Audit storage retains at most 500 metadata-only records. It omits page content, URLs, request parameters, Cookie/token values, Eval code, arguments and results.
- Context and request buffers are bounded and replaced or cleared by document, grant and session lifecycle.
- Operational metrics are aggregate local counters. They are included only when the user explicitly exports a diagnostics file.
## User control
The user selects the tab/frame, scopes and expiration for every Agent grant and can pause, resume or revoke it. Sensitive network fields, observation values and program Eval each require separate controls or scopes. Cookie values are hidden by default. Exports are redacted by default. The floating panel can be disabled globally, restricted to active tasks, or controlled with an allowlist/denylist.
Removing the extension deletes browser-managed extension storage. The Native Host installer has an uninstall option that removes its per-user manifests and copied executable.
## Security
The WebSocket Bridge accepts explicit loopback hosts only. First-time pairing requires the user to compare a six-digit code in the extension and Yakit. Later handshakes use mutually verified P-256 signatures and identify the engine, extension installation, connection and resumable session. Revoking a paired device closes its active connection. Grants bind task, tab, frame, document, origin, scopes and expiry. Messages have runtime schemas, concurrency limits, cancellation, bounded payloads and chunk reassembly limits.
No system can guarantee absolute security. Do not use the extension against systems you are not authorized to test, and do not include secrets in public bug reports.
## Changes and contact
Material policy changes must accompany a product update and updated store disclosures. Questions or security reports can be opened at [yaklang/yaklang issues](https://github.com/yaklang/yaklang/issues); use a private security-reporting channel for sensitive vulnerability details.
Official policy references: [Chrome Web Store User Data FAQ](https://developer.chrome.com/docs/webstore/program-policies/user-data-faq), [Chrome Limited Use guidance](https://developer.chrome.com/docs/webstore/user_data), and [Mozilla Add-on Policies](https://extensionworkshop.com/documentation/publish/add-on-policies/).
+728
View File
@@ -0,0 +1,728 @@
# Yakit Browser Agent 产品与架构路线
> 状态:Phase 1-4 源码、构建、测试与审核产物已完成;仅剩外部账号、签名与商店人工审核
> 更新时间:2026-07-17
> 适用仓库:`yaklang-chrome-extension`、Yak `common/browser`/`common/yak/yakurl` 与 Yakit 浏览器集成页
## 0. 2026-07-17 实施快照
本项目尚未正式发布,因此当前重构不承担旧状态、旧消息或旧 Bridge 协议的迁移兼容。破坏性变更直接形成新的生产基线,避免长期保留双字段、双协议和回退分支。
本轮已经落地:
- 状态模型直接切换到 v7;代理、UA、Bridge、面板设置与 grant/Bridge/action session 分域存储,不读取旧聚合 key;
- content script、嵌入式 floating page、Popup/Options 三类发送者使用不同的标签页绑定策略;
- Options 顶栏显式选择目标标签页,从 Popup/悬浮面板进入时携带 `tabId`
- RequestMap + Valibot 严格校验 extension runtime 消息和 Bridge method params
- 授权改为 `targets + origin + scopes + taskId + expiresAt`,跨来源导航后失效;
- background 状态写入串行化,避免并发 `get -> modify -> set` 丢更新;
- Bridge v3 使用 `engine challenge -> extension auth -> hello_ack`,以双方 P-256 身份签名绑定 extension Origin、installation、engine、connection、session、task 与 grant
- Yak gRPC 默认托管 loopback BridgeYakit 复用 `RequestYakURL``browser-extension://` schema 完成配对窗口、审批、重命名和撤销,没有增加成组 gRPC RPC;
- Bridge 运行时支持多浏览器同时在线并按 `deviceId` 隔离路由;Yakit 点击设备行可进入能力调用/Yak 脚本工作台,单一 `ExecuteBrowserExtensionTask` 流式 RPC 负责 schema 分发、日志、结果、取消和错误回程;
- 浏览器 Yak 任务在拥有 Bridge 的 gRPC 进程内执行,请求级注入选中设备的 `browser.ExtensionCall`,并限制脚本体积、并发、超时、单事件和总输出;不再借用会 fork 子进程的通用 Exec Yak 链路;
- 插件与 Yakit 展示同一六位校验码,审批后自动连接;不再配置、复制或轮换 bearer token,设备撤销会立即断开当前会话;
- 页面执行抽象为 Chrome User Scripts MAIN、受管 injected MAIN fallback 与 Firefox AMO invoke-only 渠道;
- 默认 production/store 构建使用 User Scripts,物理移除 `page-main-world.js`enterprise 使用 User Scripts 优先并保留 injected fallbackdev 与 Firefox MV2 保留 injected bridge
- 常驻 content script 从约 480KB 降到 Store 约 10.4 KiB;React 浮动工作台仅在展开时加载;
- Chrome Store/User Scripts 与 injected bridge 均通过真实 Chromium E2E
- 构建预算、商店执行策略和资源暴露策略已经加入自动审计。
- grant target 已绑定 `tabId + frameId + documentId + origin`,同源刷新返回 `stale_document`,跨来源导航返回 `origin_changed`
- Bridge 已支持 cancel、8 请求并发上限、重复 ID 拒绝、16 MiB 收发上限和断线清理;
- 人工接管具备 `waiting_for_user -> completed/cancelled` 状态、三处 UI 提示和扩展到 Yak 的事件回程;
- 审计流使用独立 storage key,最多保留 500 条脱敏元数据,Options 提供操作记录视图;
- Store 与 Enterprise E2E 已覆盖接管完成事件、取消、审计脱敏和 document 边界。
- 基于 `webRequest` 的 document-bound Fetch/XHR 捕获已经落地,默认只保存有界元数据;请求头、Cookie 和 body 需要显式开启;
- Options 已提供网络时间线、原始请求检查器和复制功能;
- 插件与 Yak 已支持双向 request/response`yakit.web_fuzzer.open` 会保存配置、打开 Yakit Web Fuzzer 并返回 `pageId`
- Store E2E 已验证真实 HttpOnly Cookie、请求头、POST body、Agent scope 读取、Web Fuzzer 回执和审计不泄漏。
- 页面上下文已从最多 500 KiB HTML 改为有界结构化快照,正文摘要上限 20 KiB,可操作节点上限 400;
- `captureId + documentId + frameId + nodeId` 稳定引用、`browser.node.inspect/action``stale_node` 已落地;
- open Shadow DOM 遍历、认证信号、context diff 与登录态工作区已经完成,并通过真实 Chromium 节点写入/点击测试。
- main/同源/跨源 frame inventory、显式 frame 授权与跨 frame context 已完成;
- IndexedDB database/store/key 概况、CacheStorage 名称清单和 SPA history/fragment 生命周期已完成,数据库与 Cache 值不会被采集。
- Fetch/XHR/Form/WebSocket/WebCrypto/CryptoJS 独立 MAIN-world 观测器、敏感值独立 scope、Yak PoC 与无值 AI 分析上下文已完成;
- Cookie 三格式导入导出、UA 请求头边界、PAC 分流/认证/冲突/统计已完成;
- Bridge v3 已支持 512 KiB 阈值分片、16 MiB 总上限、心跳延迟、设备签名认证和逻辑 session 恢复;
- expression/program Eval 独立 scope、Agent session action timeline 与暂停/恢复/撤销已完成;
- 任务型 Overview、320/390px 导航、站点策略/活动任务/全屏/快捷展开悬浮面板已完成;
- Native Host 可执行程序、Linux/macOS/Windows 安装器、企业 managed policy、本地指标、脱敏诊断、权限/隐私/Limited Use/商店审核包已完成;
- Vitest 23 项、四渠道构建审计、Store/Enterprise Chromium E2E、Service Worker 重启验证、Native Messaging v3 真实链路与 Yak Go 确定性包测试已完成。
外部发布动作不属于源码可自动完成的状态:开发者账号、签名证书、稳定隐私政策 URL、Windows/macOS/Linux 真机签名包、Chrome Web Store/AMO 上传、审查往返与批准。执行清单位于 `docs/store-review/RELEASE_CHECKLIST.md`
## 1. 当前判断
当前版本已经从一年前的实验性浏览器插件演进为可提交审核的生产候选基线:
- WXT、React、Chrome MV3 与 Firefox 构建链路已经建立;
- Popup、Options 和网页悬浮面板使用统一的品牌与 UI 体系;
- Yak/Yakit 原始品牌资产已经恢复;
- 代理、Cookie、User-Agent、页面上下文和 Bridge 已经形成基础能力;
- Chrome Store User Scripts、Enterprise User Scripts + injected fallback 与 Firefox AMO invoke-only 发布边界已经物理分包;
- Bridge v3、Yakit 配对控制面、Native Host、task/grant/session 身份和授权有效期已经打通;
- 只读、表达式 Eval、程序 Eval、敏感网络与观测值分别授权;
- 扫码、MFA、CAPTCHA 接管和 Agent 暂停/恢复/撤销已经形成可观察状态机;
- 生产剩余风险已经收敛为外部签名、真机兼容与商店审核,而不是未实现的核心架构。
## 2. 产品北极星
Yakit Browser Agent 不应该被设计成另一个通用浏览器工具箱。
Cookie Editor、UA 修改、编码解码和代理切换都是辅助功能。产品真正有差异化的价值是:
> 将用户真实登录后的浏览器环境,以明确授权、可观察、可暂停、可人工接管、可审计的方式交给 Yakit 和 AI Agent。
所有架构和 UI 决策都应服务于以下主流程:
```text
用户选择目标标签页
-> 创建与 AI task 绑定的授权
-> Agent 读取结构化页面和认证上下文
-> 捕获请求、签名或加密逻辑
-> 发送到 Yakit Fuzzer / Repeater / AI
-> 遇到二维码、MFA、CAPTCHA 时请求人工接管
-> 用户完成并显式恢复任务
-> Agent 获取新上下文并继续
-> 授权到期或用户主动撤销
```
## 3. P0:继续扩展功能前必须处理
### 3.1 请求必须绑定发送者标签页
原实现的 background handler 忽略 `runtime.MessageSender`content script 发出的 `tab.active``context.capture` 等请求会重新查询当前活动标签页。本轮已经完成 sender、frame 与 document 级绑定。
这会造成一个真实风险:后台标签页加载 content script 时,如果用户已经切到另一个标签页,悬浮面板可能显示、授权或采集错误的页面。
目标规则:
```text
content script 请求
-> 默认使用 sender.tab.id + sender.frameId + sender.documentId
popup / options 请求
-> 必须显式传 tabId,或由 UI 明确选择 active tab
Bridge 请求
-> 必须显式传 tabId,并验证它属于当前 grant
```
所有页面能力都应接受统一目标:
```ts
interface BrowserTarget {
tabId: number;
frameId?: number;
documentId?: string;
}
```
导航后旧 `documentId` 应返回 `stale_document`,不能静默操作新页面。
### 3.2 授权从两级改为 capability scopes
原有 `read | control` 太粗。当前状态已经保存具体 scope,UI 的“只读/控制”仅作为创建 scope 集合的快捷预设;Eval 表达式与程序已经拆成独立 scope。
建议 scope
```text
context.read
cookies.read
storage.read
network.read
page.invoke
page.eval.expression
page.eval.program
page.interact
proxy.read
proxy.write
human.takeover
```
授权至少包含:
```ts
interface BrowserGrant {
id: string;
taskId: string;
agentId?: string;
targets: BrowserTarget[];
origins: string[];
scopes: CapabilityScope[];
createdAt: number;
expiresAt: number;
}
```
`page.eval.program` 应独立授权。首次高风险执行应允许用户预览代码和目标 origin。
### 3.3 消息协议必须运行时校验
TypeScript 类型不会校验来自 runtime、content script、Native Messaging 或 WebSocket 的真实数据。
当前已建立严格 request map
```ts
interface RequestMap {
'context.eval': {
input: EvalRequest;
output: PageEvalResult;
};
'proxy.switch': {
input: { id: string };
output: ExtensionState;
};
}
```
配合 Zod、Valibot 或等价 schema 校验:
- Bridge envelope 和 protocol version
- `tabId``frameId``documentId`
- Eval 代码长度、模式、超时和并发数量;
- Cookie URL、domain、path 和 expiration
- 代理 host、port、scheme、PAC 数据;
- loopback WebSocket endpoint、配对状态、双方公钥与签名 envelope;
- Grant scope、origin、task 和有效期;
- 单请求和返回值大小。
### 3.4 Storage 避免并发覆盖
原有状态写入为 `get -> modify -> set`,并发写可能丢失更新。当前已经串行化所有跨域 mutation,按代理、UA、Bridge、面板拆分长期 key,并把 grant/handoff、Bridge session、Agent timeline、代理密码/统计放入 session key;审计和本地聚合指标使用独立有界 key。
当前按领域拆 key
```text
settings.proxy
settings.userAgent
settings.bridge
ui.floatingPanel
session.activeGrant
session.audit
```
写操作由 background 串行执行。临时会话与长期配置分开存储。
### 3.5 Bridge 必须有正式握手
目标握手:
```text
engine signed challenge
-> extension verifies paired engine identity
-> extension signed auth (origin + installation + task/grant)
-> engine verifies paired device identity
-> hello_ack + protocol/capability negotiation
-> ready
```
在收到 `hello_ack` 之前不能显示“引擎已连接”。
Bridge v3 已具备双方 P-256 身份校验、Origin/installation 绑定、protocol/capability/版本协商、request cancel、8 个并发请求上限、16 MiB 总上限和有界事件回程;超过 512 KiB 的消息按 256 KiB 分片。握手携带 installation/task/grant/resume session,回执携带 engine identity/instance/connection/session 身份;心跳记录序号、时间和延迟。断线中的具体调用明确失败,重连恢复逻辑 task/grant session 身份,不伪装恢复已经中断的调用栈。
## 4. Eval 发布策略
### 4.1 先区分“构建渠道”和“执行机制”
`store``enterprise``dev` 是三个发布渠道,不是三个完全独立的 JavaScript 语义。
底层执行机制主要有三种:
1. 当前的 injected MAIN-world bridge
2. `userScripts.execute({ world: "MAIN" })`
3. 仅允许预定义的 `page.invoke` / structured commands。
推荐矩阵:
| 构建渠道 | 首选执行机制 | 备用机制 |
| --- | --- | --- |
| Chrome Web Store | User Scripts MAIN | Invoke-only |
| Enterprise managed | User Scripts MAIN | 受管策略允许的 injected bridge |
| Local development | Injected MAIN bridge | User Scripts MAIN 对照测试 |
| Firefox MV3 AMO | Invoke-only / structured commands | 无通用 Eval 回退 |
| Firefox 本地/受管 | Injected bridge | Invoke-only |
| Firefox MV2 | Injected bridge | Invoke-only |
### 4.2 三种渠道的使用效果是否完全一样
结论:目标能力可以接近,但不完全一样。
#### A. 当前 injected MAIN-world bridge
执行链路:
```text
Bridge/background
-> isolated content script
-> CustomEvent
-> packaged page-main-world.js
-> indirect eval(code)
```
优点:
- 可以访问页面真实 `window`、闭包外全局对象和页面函数;
- 可以等待 Promise
- 可以自定义循环对象、DOM Node、BigInt 等序列化;
- Chrome、现有 Firefox MV2 构建都可使用;
- 开发时不依赖用户开启 User Scripts 权限。
不足:
- 页面可以观察、修改或干扰 MAIN world 逻辑;
- 页面可以伪造 CustomEvent 响应;
- 请求和返回需要自己维护关联、超时、序列化;
- 同步死循环无法中断;
- 从 Bridge 获取代码再调用 `eval()` 很可能不符合 Chrome Web Store MV3 政策;
- 更适合本地开发、自托管或受管环境,不适合作为公开商店版默认机制。
#### B. User Scripts MAIN
执行链路:
```text
background
-> browser.userScripts.execute({
target,
world: "MAIN",
js: [{ code }]
})
-> browser InjectionResult[]
```
与当前方案相同或接近的部分:
- `world: "MAIN"` 可以访问页面真实 `window` 和页面全局函数;
- 可以执行动态代码;
- 可以指定 tab、frame 或 document
- 可以等待 Promise
- 可以返回每个 frame 的执行结果;
- 可以在代码外包一层统一 serializer,保持现有 `PageEvalResult` 格式。
统一语义为:`expression` 自动返回表达式值;`program` 是 async 函数体,必须显式 `return` 才产生返回值,否则为 `undefined`。这避免在 Store MAIN world 内二次调用 `eval`,也让 User Scripts 与 injected fallback 的程序行为一致。
不同点:
- Chrome 需要 `userScripts` permission
- Chrome 138+ 用户必须在扩展详情页开启 “Allow User Scripts”;
- Firefox 技术上提供 `userScripts`,但当前 AMO 政策将其限定为用户脚本管理器;本产品公开 Firefox 包不使用该 API
- Chrome 的一次性 `userScripts.execute()` 需要 Chrome 135+
- 当前项目 Firefox 输出是 MV2,不能直接复用 Firefox 的新 MV3 User Scripts 路径;
- Chrome 和 Firefox 的返回值 clone/serialization 细节不同,跨浏览器应主动返回 JSON string 或统一 envelope
- MAIN world 依旧能被页面观察和干扰,User Scripts 不是可信执行环境;
- User Scripts 的 one-shot injection 与当前常驻事件桥生命周期不同。
因此,在支持的浏览器上,以下使用体验可以做到基本一致:
```text
输入代码
选择目标标签页/frame
访问页面 window
等待 Promise
得到统一 PageEvalResult
```
但权限开启流程、版本覆盖、frame result、错误格式和底层生命周期不会完全相同。
特别注意:`world: "USER_SCRIPT"` 不是当前 Eval 的等价替代。它与页面隔离,不能直接读取页面框架、加密库和业务全局变量。Yakit 需要复用页面签名或登录态逻辑时,必须选择 `MAIN`
#### C. Invoke-only / structured commands
示例:
```text
page.invoke
dom.query
dom.click
form.fill
network.findRequest
storage.get
```
优点:
- 权限最容易解释;
- 审计和参数脱敏更容易;
- 对 AI 更稳定,减少生成任意代码;
- 更容易通过公开商店审核;
- 可以对每种能力做明确 schema 和测试。
不足:
- 不能等价替代任意 Eval
- 遇到未知框架、混淆代码、临时加密逻辑时能力受限;
- 需要持续扩充结构化命令。
因此 Invoke-only 应该是 Agent 的首选路径,而不是删除 Eval 后的完全替代。
### 4.3 推荐的统一 Eval API
上层不应知道底层是 User Scripts 还是 injected bridge
```ts
interface PageExecutionAdapter {
availability(): Promise<ExecutionAvailability>;
execute(request: PageExecutionRequest): Promise<PageExecutionResult[]>;
}
```
请求:
```ts
interface PageExecutionRequest {
target: BrowserTarget;
mode: 'expression' | 'program';
code: string;
timeoutMs: number;
maxResultBytes: number;
}
```
Adapter
```text
ChromeUserScriptsAdapter
FirefoxUserScriptsAdapter
InjectedMainWorldAdapter
InvokeOnlyAdapter
```
UI 和 Bridge 始终调用同一个 `page.execute` capability。Adapter 根据构建渠道、浏览器版本、User Scripts 是否开启以及当前 grant 自动选择。
### 4.4 Chrome Web Store 风险
Chrome MV3 政策明确将以下行为列为常见违规:
- 使用 `eval()` 执行从远程来源获得的字符串;
- 构建解释器执行从远程来源获得的复杂命令;
- 让扩展完整功能无法从提交代码中被审核者理解。
政策明确列出的远程逻辑执行豁免 API 是:
- Debugger API
- User Scripts API。
官方资料:
- [Additional Requirements for Manifest V3](https://developer.chrome.com/docs/webstore/program-policies/mv3-requirements)
- [chrome.userScripts](https://developer.chrome.com/docs/extensions/reference/api/userScripts)
- [Enabling chrome.userScripts is changing](https://developer.chrome.com/blog/chrome-userscript)
- [MDN userScripts](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/userScripts)
- [MDN userScripts.execute](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/userScripts/execute)
源码预审、实际 Store 包审计、隐私/Limited Use/权限说明和 reviewer test packet 已完成;正式批准仍必须通过 Chrome Web Store 开发者账号上传和人工审核,不能由本地测试替代。
## 5. Page Context 目标模型
页面上下文已经从“一次返回完整 HTML”切换为有界结构化快照,并完成 frame 与浏览器存储 inventory。
建议层次:
```text
Page summary
Accessibility/DOM action tree
Forms and actionable elements
Frames and shadow roots
Authentication signals
Storage inventory
Network request summary
Crypto/signing observations
Relevant excerpts on demand
```
必须补齐:
- [已完成] main frame、同源 frame、跨源 frame 清单;
- [已完成] `frameId``documentId` 和 origin
- [已完成] open Shadow Root 遍历;
- [已完成] IndexedDB database/store/key 概况;
- [已完成] CacheStorage 概况;
- [已完成] SPA route 和 document 生命周期;
- [已完成] 页面登录状态信号;
- [已完成] 结构化可操作元素引用;
- [已完成] context diff,而不是每次返回完整快照。
元素引用建议:
```text
captureId + documentId + frameId + nodeId
```
页面变化后返回 `stale_node`,不能退化为可能误命中的 CSS selector。
## 6. 高价值产品功能
### 6.1 浏览器请求到 Yakit 工作流
浏览器请求到 Yakit 的生产链路已经闭环:`webRequest` 捕获 Fetch/XHR/Form navigation,用户显式开启敏感字段后生成 HTTP/1.1 重放包,并通过带回执的 Bridge 在 Yakit 中打开 Web Fuzzer、生成可运行 Yak PoC,或生成不含认证值的 AI 分析上下文。AI Agent 可结合附近的 WebCrypto/CryptoJS/WebSocket 观测分析鉴权、签名、重放和对象级越权风险。
优先完成:
```text
捕获 fetch / XHR / form 请求
-> 发送到 Web Fuzzer
-> 发送到 Repeater
-> 生成 Yak PoC
-> 交给 AI 分析鉴权、签名和越权风险
```
这是浏览器插件与 Yakit 结合最直接的产品价值。
### 6.2 前端加密与签名观测
在明确授权期间临时观测:
- `fetch` / XHR
- WebSocket
- `crypto.subtle`
- 常见 CryptoJS
- 请求签名前后的字段;
- 调用栈和脚本来源。
上述能力已经通过独立 WXT MAIN-world entrypoint 落地。观测器使用最多 200 条的有界 ring buffer,默认只记录算法、方向、大小、调用栈和脚本来源;短时值预览需要独立敏感 scope,授权到期、撤销或用户停止时恢复原始页面 API 并销毁预览。
### 6.3 登录态工作区
建立可见 session
```text
目标 origin
关联标签页
账号线索
Cookie/Storage 概况
CSRF/token 来源
当前代理环境
共享给哪个 Agent/task
授权和过期时间
最近上下文变化
```
默认不导出原始认证值。快照导出必须显式确认并支持脱敏。
### 6.4 人机接管状态机
当前已经完成可运行闭环:Agent 创建 `browser.handoff.request`,插件在目标标签页、Popup 和 Options 显示 `waiting_for_user`,用户完成或取消后发送 `browser.handoff.changed`Yak 通过 `ExtensionWaitEvent` 消费;独立 Agent runtime 同时记录 running/paused/revoked 与有界 action timeline。
目标状态:
```text
agent_running
needs_human
waiting_for_human
human_resumed
agent_resuming
completed / cancelled / expired
```
流程:
1. Agent 说明需要扫码、MFA、CAPTCHA 或设备确认的原因;
2. 扩展聚焦目标标签页并显示任务;
3. Agent 停止读取敏感内容和重复轮询;
4. 用户完成操作并点击“已完成”;
5. 扩展发送 `human_resumed`
6. Agent 获取新 context diff 并继续。
## 7. 现有工具完善方向
### Cookie Editor
- [已完成] 搜索、排序和过滤;
- [已完成] 批量删除;
- [已完成] 编辑现有 Cookie
- [已完成] JSON/Netscape/Raw Set-Cookie 有界导入导出,默认脱敏,原始值导出需显式开启;
- [已完成] Partitioned Cookie
- [已完成] SameSite 与过期时间;Priority/SameParty 可在交换格式中识别和展示,浏览器 Cookies API 无法写回时返回明确 warning;
- [已完成] 按 domain/path 分组;
- [已完成] 默认隐藏 value,点击后显示。
### User-Agent 与设备身份
当前产品明确选择第一种边界:UI 已命名为 “User-Agent 请求头”,只承诺通过 DNR 修改真实网络请求头,不暗示页面 JS、timezone、viewport、touch 或 geolocation 已被完整伪装。
完整设备指纹伪装不属于当前插件承诺;如果未来引入,必须作为独立能力重新设计 scope、页面注入生命周期和浏览器兼容测试,不能与单一 UA header 规则混为一谈。
### 代理规则
- [已完成] 优先级与拖动排序;
- [已完成] 冲突检测;
- [已完成] 当前 URL 命中预览;
- [已完成] PAC 编译结果查看;
- [已完成] 代理认证,用户名持久化、密码仅保存在浏览器 session;
- [已完成] JSON 导入导出,不包含代理密码;
- [已完成] 默认出口和 fail-open/fail-closed 行为;
- [已完成] 规则命中统计。
## 8. UI/UX 改进
### 8.1 字号
已清除 8px/9px 字号;正文、辅助说明、表格和技术元数据按下面基线执行,并由 320/390/桌面截图验证。
目标:
- 工作台正文不低于 12px
- 辅助说明不低于 11px
- 表格正文 12px
- tag、时间戳、技术元数据最低 10px;
- 不再使用 8px。
### 8.2 Overview 改为当前任务工作台
Overview 已改为任务工作台,第一屏展示:
```text
当前站点与登录环境
当前代理和流量状态
正在共享给哪个 Agent
Agent 最近动作
需要用户完成的步骤
抓请求 / 采集上下文 / 发送 Fuzzer
```
### 8.3 移动和窄视口导航
窄视口使用不换行的横向滚动导航;320px 与 390px E2E 检查 document overflow 和导航标签换行。
### 8.4 悬浮面板
已完成:
- 当前站点单独隐藏;
- allowlist/denylist
- 仅在活动 task 中显示;
- 快捷键展开;
- 页面全屏、演示、视频场景自动收起;
- 与网页边缘控件冲突时调整位置;
- 显示当前 task 和授权风险,而不仅是代理状态。
## 9. 目标代码目录
```text
src/
app/background/
index.ts
entrypoints/
background.ts
popup/
options/
agent.content/
page-main-world.ts
features/
proxy/
cookies/
identity/
page-context/
page-observation/
network-capture/
grants/
handoff/
diagnostics/
agent-runtime/
engine-bridge/
floating-panel/
platform/
browser/
storage/
messaging/
policy/
protocol/
components/ui/
components/brand/
shared/errors.ts
```
原则:
- WXT background entrypoint 只负责注册并调用 `app/background`
- 每个高风险 feature 拥有 service,纯编译器/交换器与测试放在 feature 内;
- browser API 通过 platform adapter 隔离;
- background 应用 router 只编排 domain service,不在 entrypoint 内实现浏览器业务;
- UI、Bridge 和测试共享同一份协议 schema。
## 10. 测试策略
### 单元测试
- [已完成] PAC compiler、URL pattern 和冲突优先级;
- [已完成] 无迁移 clean bootstrap、storage 分域和并发写;
- [已完成] Bridge envelope、extension RequestMap 与 managed policy validation
- [已完成] Grant scope/策略判断与 expression/program Eval serializer
- [已完成] Cookie URL/脱敏交换与 UA DNR 规则生成。
### 协议测试
- [已完成] pairing code、engine challenge、extension auth、hello_ack、身份字段和 protocol version mismatch
- [已完成] read/program scope、origin/tab/frame/document 越权;
- [已完成] timeout/cancel、并发、重复 ID、payload 上限与双向 chunk
- [已完成] 设备审批/撤销、断线 session 恢复、task 到期/撤销与 Native Host framing
- [已完成] Chromium `connectNative` -> Go Host -> loopback Yak Bridge -> Bridge v3 challenge/auth/identity/heartbeat 的真实端到端验证(生产包仍为 optional permission,只有不可交互的临时测试副本预授权)。
### 浏览器 E2E
- [已完成] Chrome Store/User Scripts 与 Enterprise User Scripts + injected fallback 模式;
- [已完成] Firefox MV2 injected 与 Firefox MV3 AMO invoke-only 构建/静态策略审计;
- [已完成] CSP 严格页面、SPA、同源/跨源 iframe 与 open Shadow DOM
- [已完成] 页面伪造消息不扩权、Service Worker 停启保留 session、标签页关闭/导航 Eval fail-closed
- [已完成] 320px、390px 和桌面视口 UI、面板边界与资源像素/加载检查。
当前容器没有 Firefox 可执行程序或 macOS/Windows 环境;Firefox 真机安装、AMO 签名包和三平台 Native Host 签名属于 `RELEASE_CHECKLIST.md` 的外部发布门禁,不能用 Chromium 模拟结果冒充通过。
## 11. 分阶段落地
### Phase 1:安全与架构基线
- [已完成] sender tab/frame/document 绑定与 stale-document
- [已完成] RequestMap 和运行时 schema
- [已完成] Storage 分域、session/local 生命周期与串行写;
- [已完成] capability scopes 与 origin 绑定;
- [已完成] Bridge hello_ack 和版本协商;
- [已完成] Store/enterprise/dev 构建渠道;
- [已完成] PageExecutionAdapter
- [已完成] Chrome User Scripts MAIN,并通过浏览器 E2E。
### Phase 2:核心产品闭环
- [已完成] Fetch/XHR request capture
- [已完成] 发送 Yakit Web Fuzzer/Repeater 工作区;
- [已完成] task-bound grant
- [已完成] human handoff 状态机;
- [已完成] context diff
- [已完成] Agent action timeline、暂停/恢复/撤销与脱敏持久审计。
### Phase 3:浏览器现场深度
- [已完成] frame/document/node 引用与显式跨 frame 授权;
- [已完成] open Shadow DOM
- [已完成] IndexedDB/CacheStorage inventory
- [已完成] Fetch/XHR/Form/WebSocket/WebCrypto/CryptoJS 有界观测与独立敏感 scope;
- [已完成] 登录态工作区;
- [已完成] Cookie、UA 请求头边界和代理规则完善。
### Phase 4:分发与运营
- [已完成] Native Host 可执行程序、framing proxy、Chrome/Firefox argv 来源校验、Linux/macOS/Windows 安装器与 Chromium 真实传输 E2E
- [已完成] managed storage schema、后台强制企业策略与 UI 锁定状态;
- [已完成] Chrome Store 实包自动预审和 reviewer packet;实际上传/批准为外部门禁;
- [已完成] Firefox MV3 AMO invoke-only 实包与 review packet;真机/签名/批准为外部门禁;
- [已完成] 权限说明、隐私政策和 Limited Use 披露;
- [已完成] 脱敏审计、session action timeline 与显式诊断导出;
- [已完成] Service Worker 启动、Bridge 连接错误、心跳延迟和 capability 聚合指标(仅本地,不远传)。
## 12. 验收原则
正式产品版本至少满足:
- 不会因 active tab 切换而操作错误页面;
- 每个高风险能力都能追溯到 user、task、grant、target 和 scope
- 页面不能通过伪造普通消息扩展自己的权限;
- Store build 不通过通用 `eval(remoteCode)` 执行 Bridge 代码;
- User Scripts 未开启时给出明确降级和开启路径;
- Agent 默认使用 structured commandsEval 是最后手段;
- 用户能看见、暂停、恢复和撤销 Agent 对浏览器的操作;
- 默认不记录或导出 Cookie、token、Eval 参数和页面正文;
- Chrome Store、Enterprise User Scripts 与 Enterprise injected fallback 关键路径有真实 Chromium E2EFirefox 真机安装/运行是发布前外部门禁,不能由 Chromium 或静态审计替代;
- Native Host 与 Yakit 实例身份、版本和连接状态可信。
+43
View File
@@ -0,0 +1,43 @@
# Chrome Web Store Review Packet
## Single purpose
Yakit Browser Agent provides consent-gated browser context and request workflows for authorized security testing with a local Yak/Yakit engine. Cookie, proxy, UA, observation and request tools support that single authenticated-browser testing workflow; they do not provide unrelated browsing, advertising or content features.
## Remote code policy
The Store build is produced by `pnpm build:store`.
- It requires Chrome 138+ and uses the documented `userScripts.execute({ world: "MAIN" })` path.
- `page-main-world.js` is absent from the package and web-accessible resources.
- Expression and program Eval use independent grant scopes; program mode is not in the default control preset.
- If Allow User Scripts is disabled, the UI reports the condition and does not fall back to injected Eval.
- Page results are untrusted and bounded. Structured context/node commands are preferred.
Chrome's MV3 policy names User Scripts as an API permitted to execute remote logic when used for its documented purpose: [Additional Requirements for Manifest V3](https://developer.chrome.com/docs/webstore/program-policies/mv3-requirements).
## User data and Limited Use
The listing and privacy form must disclose authentication information, browsing activity, website content, Cookie/storage data, request data and local Native Messaging transmission. Data is handled only for the user-facing security workflow, sent only to the user's explicit local endpoint, never sold, never used for advertising, and not sent to developer analytics. Local processing still requires disclosure under the [User Data FAQ](https://developer.chrome.com/docs/webstore/program-policies/user-data-faq).
## Reviewer test
1. Build with `pnpm build:store` and load `.output/chrome-mv3-store`.
2. Enable Allow User Scripts on the extension details page.
3. Open an HTTP(S) page, Options, and select the target tab.
4. Create a five-minute read grant and verify context succeeds but Eval is denied.
5. Create a control grant. Expression Eval succeeds; program Eval remains denied until separately enabled.
6. Start metadata-only request capture. Headers/body appear only after their explicit switches are enabled.
7. Trigger and complete a handoff; verify the action timeline and audit contain metadata only.
8. Inspect the Store artifact: no `page-main-world.js`, no `activeTab`, and `nativeMessaging` is optional.
Automated equivalent: `pnpm verify:ui:store`.
## Submission fields still requiring owner action
- Developer account ownership and verified contact details.
- Stable privacy-policy URL hosting `docs/PRIVACY_POLICY.md`.
- Final signed extension ID for Native Host allowlisting.
- Store screenshots/promotional assets selected from `.artifacts/ui`.
- Privacy questionnaire answers matching this packet.
- Actual upload, reviewer correspondence and approval.
+13
View File
@@ -0,0 +1,13 @@
# Firefox AMO Review Packet
The public Firefox artifact is `pnpm build:firefox:amo`, producing Firefox MV3 in `.output/firefox-mv3-store`.
Mozilla's current Add-on Policies reserve `userScripts` for user-script managers. Yakit Browser Agent is not marketed as one, so the AMO artifact does not request `userScripts`, does not package `page-main-world.js`, and does not advertise `browser.invoke` or `browser.eval`. It retains structured context, document-bound node commands, request capture, observation, Cookie/UA/proxy tools and human handoff. Local or enterprise Firefox builds can use the injected adapter outside the public AMO channel.
The manifest targets Firefox 140+ and declares required built-in data consent categories: authentication information, browsing activity, website activity and website content. There is no remote technical/user-interaction telemetry; operational metrics stay local until the user exports a diagnostics file.
Official references: [Mozilla Add-on Policies](https://extensionworkshop.com/documentation/publish/add-on-policies/) and [Firefox built-in data consent](https://extensionworkshop.com/documentation/develop/firefox-builtin-data-consent/).
Reviewer steps mirror the Chrome structured-command flow but must confirm that no function-call/Eval tabs or Bridge capabilities are present. Native Messaging remains optional and any data sent to the local host remains subject to the same disclosure and user controls.
Owner-only remaining work: AMO account, signed submission, source-code archive if requested, hosted privacy URL, reviewer correspondence and approval.
@@ -0,0 +1,14 @@
# Limited Use Disclosure
Yakit Browser Agent handles browsing activity, website content, authentication information, Cookie/browser-storage data and selected network request data only to provide its prominently disclosed authenticated-browser security-testing features.
The extension's use of this data complies with the following commitments:
- Data is used only to display browser context to the user, execute the user's bounded security workflow, or transmit an explicitly granted operation to the user's local Yak/Yakit engine.
- Data is not sold or transferred for advertising, marketing, creditworthiness, lending, or unrelated profiling.
- Humans do not read user data except when the user deliberately includes a redacted diagnostic artifact in a support request, or when required for security, abuse prevention or law.
- There is no developer-operated telemetry endpoint. Aggregate operational metrics remain on device.
- Sensitive request fields and observation values are off by default. Cookie exports are redacted by default. Program Eval has a separate high-risk scope.
- The local Native Host receives only the same purpose-bound messages the user authorized; it is not an independent data collector.
Store privacy-form answers, listing text and the hosted privacy policy must remain consistent with this disclosure and actual packaged behavior. See the [Chrome Limited Use guidance](https://developer.chrome.com/docs/webstore/user_data) and [Mozilla Add-on Policies](https://extensionworkshop.com/documentation/publish/add-on-policies/).
+28
View File
@@ -0,0 +1,28 @@
# Release Checklist
## Automated gates
- `pnpm verify:production`
- `pnpm verify:ui:store`
- `pnpm verify:ui:enterprise`
- `pnpm verify:ui:enterprise:fallback`
- `pnpm verify:native` (real Chromium -> Native Host -> Yak Bridge transport; temporary test copy pre-grants the otherwise optional browser permission)
- `go test ./common/browser/... ./common/ai/aid/aitool/buildinaitools/yakscripttools` in Yak
- Store package has no injected Eval bridge.
- Firefox AMO package has no `page-main-world.js`, `userScripts`, `browser.invoke` or `browser.eval` capability.
- Required permissions match `docs/PERMISSIONS.md`; Native Messaging is optional.
- Windows manifests are written as UTF-8 without BOM and Chrome, Chromium, Edge, Brave and Firefox registrations are per-user.
- Diagnostic and audit fixtures contain no secrets, URLs, request payloads or Eval source.
## Human gates
- Review listing text, screenshots and single-purpose statement.
- Host and link the privacy policy.
- Complete Chrome privacy/Limited Use and Firefox data consent declarations.
- Build/sign Native Host binaries for Windows, macOS and Linux; scan and publish checksums.
- Replace unpacked extension IDs in Native Host manifests with signed IDs.
- Test current stable Chrome, Firefox, Windows, macOS and Linux packages on real machines.
- Run `go test ./common/ai/aid/aitool/buildinaitools/...` against a seeded, writable Yakit profile database; the recursive integration package expects existing built-in tools and is not a clean-profile unit test.
- Submit to Chrome Web Store and AMO, answer reviewer questions, and record approval/version IDs.
The human gates require external accounts, signing keys, store systems and operating systems. They cannot be truthfully marked approved from a source workspace; the repository contains the implementation and reviewer artifacts needed to execute them.
+25
View File
@@ -0,0 +1,25 @@
# Yakit Browser Agent Native Host
The Native Host is a small stdio-to-loopback-WebSocket transport. It does not store pairing credentials and does not execute browser commands itself. It forwards the normal Bridge v3 server challenge and extension authentication messages; Yak validates the origin-bound paired device signature and returns the engine identity, engine instance, connection, session, task, grant, protocol, and capability identity.
Build from the Yak repository:
```bash
go build -o yakit-browser-agent-host ./common/browser/nativehostcmd
```
Install for Linux or macOS, using the ID shown by `chrome://extensions` for an unpacked build:
```bash
./native-host/install.sh \
--host-binary /absolute/path/to/yakit-browser-agent-host \
--extension-id YOUR_CHROME_EXTENSION_ID
```
On Windows, run PowerShell without administrator privileges:
```powershell
.\native-host\install.ps1 -HostBinary C:\path\yakit-browser-agent-host.exe -ExtensionId YOUR_CHROME_EXTENSION_ID
```
The installer registers Chrome, Chromium, Edge, Brave, and Firefox per-user locations. Chrome supplies its extension origin to the host. Firefox supplies the Native Host manifest path and add-on ID; the host verifies that ID against `allowed_extensions` before deriving its Bridge origin. It writes only the loopback endpoint to the user configuration directory. Run with `--uninstall` on POSIX or `-Uninstall` on Windows to remove the registrations; uninstall does not require an extension ID. When Chrome runs on Windows and development runs in WSL, build/install the Windows host with `install.ps1`; a Linux Native Host cannot be launched by Windows Chrome.
+71
View File
@@ -0,0 +1,71 @@
param(
[string]$ExtensionId = "",
[string]$HostBinary = "yakit-browser-agent-host.exe",
[string]$FirefoxId = "[email protected]",
[string]$Endpoint = "ws://127.0.0.1:64333/extension",
[switch]$Uninstall
)
$ErrorActionPreference = "Stop"
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
$HostName = "com.yaklang.browser_agent"
$InstallRoot = Join-Path $env:LOCALAPPDATA "Yakit\BrowserAgent"
$ConfigRoot = Join-Path $env:APPDATA "yakit"
$ManifestPath = Join-Path $InstallRoot "$HostName.json"
$RegistryTargets = @(
"HKCU:\Software\Google\Chrome\NativeMessagingHosts\$HostName",
"HKCU:\Software\Chromium\NativeMessagingHosts\$HostName",
"HKCU:\Software\Microsoft\Edge\NativeMessagingHosts\$HostName",
"HKCU:\Software\BraveSoftware\Brave-Browser\NativeMessagingHosts\$HostName",
"HKCU:\Software\Mozilla\NativeMessagingHosts\$HostName"
)
function Write-JsonFile {
param([Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][object]$Value)
[System.IO.File]::WriteAllText($Path, ($Value | ConvertTo-Json -Depth 4), $script:Utf8NoBom)
}
if ($Uninstall) {
foreach ($Target in $RegistryTargets) { Remove-Item $Target -Recurse -Force -ErrorAction SilentlyContinue }
Remove-Item $InstallRoot -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item (Join-Path $ConfigRoot "browser-agent-native-host.json") -Force -ErrorAction SilentlyContinue
Write-Host "Removed $HostName."
exit 0
}
if ($Endpoint -notmatch '^wss?://(127\.0\.0\.1|localhost|\[::1\])(:[0-9]+)?/') {
throw "Endpoint must use ws:// or wss:// with an explicit loopback host."
}
if ([string]::IsNullOrWhiteSpace($ExtensionId)) {
throw "ExtensionId is required when installing the Native Host."
}
$ResolvedBinary = (Resolve-Path $HostBinary).Path
New-Item $InstallRoot -ItemType Directory -Force | Out-Null
New-Item $ConfigRoot -ItemType Directory -Force | Out-Null
$InstalledBinary = Join-Path $InstallRoot "yakit-browser-agent-host.exe"
Copy-Item $ResolvedBinary $InstalledBinary -Force
Write-JsonFile -Path (Join-Path $ConfigRoot "browser-agent-native-host.json") -Value @{ endpoint = $Endpoint }
Write-JsonFile -Path $ManifestPath -Value @{
name = $HostName
description = "Yakit Browser Agent Native Host"
path = $InstalledBinary
type = "stdio"
allowed_origins = @("chrome-extension://$ExtensionId/")
}
$FirefoxManifestPath = Join-Path $InstallRoot "$HostName.firefox.json"
Write-JsonFile -Path $FirefoxManifestPath -Value @{
name = $HostName
description = "Yakit Browser Agent Native Host"
path = $InstalledBinary
type = "stdio"
allowed_extensions = @($FirefoxId)
}
foreach ($Target in $RegistryTargets) {
New-Item $Target -Force | Out-Null
$Value = if ($Target -like "*Mozilla*") { $FirefoxManifestPath } else { $ManifestPath }
Set-Item $Target -Value $Value
}
Write-Host "Installed $HostName at $InstalledBinary"
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
set -euo pipefail
host_name="com.yaklang.browser_agent"
host_binary=""
extension_id=""
firefox_id="[email protected]"
endpoint="ws://127.0.0.1:64333/extension"
uninstall=false
usage() {
printf '%s\n' "Usage: $0 --extension-id ID [--host-binary PATH] [--endpoint WS_URL] [--firefox-id ID] [--uninstall]"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--host-binary) host_binary="${2:-}"; shift 2 ;;
--extension-id) extension_id="${2:-}"; shift 2 ;;
--firefox-id) firefox_id="${2:-}"; shift 2 ;;
--endpoint) endpoint="${2:-}"; shift 2 ;;
--uninstall) uninstall=true; shift ;;
-h|--help) usage; exit 0 ;;
*) printf 'Unknown option: %s\n' "$1" >&2; usage >&2; exit 2 ;;
esac
done
case "$(uname -s)" in
Darwin)
install_root="$HOME/Library/Application Support/Yakit/BrowserAgent"
config_root="$HOME/Library/Application Support/yakit"
chrome_roots=(
"$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts"
"$HOME/Library/Application Support/Chromium/NativeMessagingHosts"
"$HOME/Library/Application Support/Microsoft Edge/NativeMessagingHosts"
"$HOME/Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts"
)
firefox_root="$HOME/Library/Application Support/Mozilla/NativeMessagingHosts"
;;
Linux)
install_root="${XDG_DATA_HOME:-$HOME/.local/share}/yakit/browser-agent"
config_root="${XDG_CONFIG_HOME:-$HOME/.config}/yakit"
chrome_roots=(
"$HOME/.config/google-chrome/NativeMessagingHosts"
"$HOME/.config/chromium/NativeMessagingHosts"
"$HOME/.config/microsoft-edge/NativeMessagingHosts"
"$HOME/.config/BraveSoftware/Brave-Browser/NativeMessagingHosts"
)
firefox_root="$HOME/.mozilla/native-messaging-hosts"
;;
*) printf 'Unsupported operating system. Use install.ps1 on Windows.\n' >&2; exit 1 ;;
esac
manifest_name="$host_name.json"
if [[ "$uninstall" == true ]]; then
for directory in "${chrome_roots[@]}" "$firefox_root"; do rm -f "$directory/$manifest_name"; done
rm -f "$install_root/yakit-browser-agent-host" "$config_root/browser-agent-native-host.json"
printf 'Removed %s manifests and host binary.\n' "$host_name"
exit 0
fi
if [[ -z "$extension_id" ]]; then printf '%s\n' '--extension-id is required for Chrome/Chromium.' >&2; exit 2; fi
if [[ -z "$host_binary" ]]; then host_binary="$(command -v yakit-browser-agent-host || true)"; fi
if [[ -z "$host_binary" || ! -x "$host_binary" ]]; then
printf '%s\n' 'Host binary not found. Build it with: go build -o yakit-browser-agent-host ./common/browser/nativehostcmd' >&2
exit 1
fi
if [[ ! "$endpoint" =~ ^wss?://(127\.0\.0\.1|localhost|\[::1\])(:[0-9]+)?/ ]]; then
printf '%s\n' 'Endpoint must use ws:// or wss:// with an explicit loopback host.' >&2
exit 2
fi
mkdir -p "$install_root" "$config_root"
install -m 0755 "$host_binary" "$install_root/yakit-browser-agent-host"
printf '{"endpoint":"%s"}\n' "$endpoint" > "$config_root/browser-agent-native-host.json"
for directory in "${chrome_roots[@]}"; do
mkdir -p "$directory"
printf '{\n "name": "%s",\n "description": "Yakit Browser Agent Native Host",\n "path": "%s",\n "type": "stdio",\n "allowed_origins": ["chrome-extension://%s/"]\n}\n' \
"$host_name" "$install_root/yakit-browser-agent-host" "$extension_id" > "$directory/$manifest_name"
done
mkdir -p "$firefox_root"
printf '{\n "name": "%s",\n "description": "Yakit Browser Agent Native Host",\n "path": "%s",\n "type": "stdio",\n "allowed_extensions": ["%s"]\n}\n' \
"$host_name" "$install_root/yakit-browser-agent-host" "$firefox_id" > "$firefox_root/$manifest_name"
printf 'Installed %s at %s\n' "$host_name" "$install_root/yakit-browser-agent-host"
+33 -12
View File
@@ -2,31 +2,52 @@
"name": "yakit-chrome-client",
"description": "Yakit Browser Extension",
"private": true,
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "wxt",
"dev:wsl": "node scripts/dev-wsl.mjs",
"dev:firefox": "wxt -b firefox",
"build": "wxt build",
"build:store": "wxt build -b chrome --mode store",
"build:enterprise": "wxt build -b chrome --mode enterprise",
"build:firefox": "wxt build -b firefox",
"build:firefox:amo": "wxt build -b firefox --mv3 --mode store",
"zip": "wxt zip",
"zip:firefox": "wxt zip -b firefox",
"compile": "tsc --noEmit",
"test": "vitest run",
"audit:build": "node scripts/audit-build.mjs",
"verify:production": "pnpm test && pnpm compile && pnpm build:store && pnpm build:enterprise && pnpm build:firefox && pnpm build:firefox:amo && pnpm audit:build",
"verify:ui": "node scripts/verify-ui.mjs",
"verify:ui:store": "EXTENSION_PATH=.output/chrome-mv3-store node scripts/verify-ui.mjs",
"verify:ui:enterprise": "EXTENSION_PATH=.output/chrome-mv3-enterprise node scripts/verify-ui.mjs",
"verify:ui:enterprise:fallback": "ENABLE_USER_SCRIPTS=0 EXTENSION_PATH=.output/chrome-mv3-enterprise node scripts/verify-ui.mjs",
"verify:native": "node scripts/verify-native-host.mjs",
"postinstall": "wxt prepare"
},
"dependencies": {
"@ant-design/icons": "^6.0.0",
"antd": "^5.24.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"uuid": "^11.1.0"
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.3.3",
"@radix-ui/react-tabs": "^1.1.17",
"@radix-ui/react-tooltip": "^1.2.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.24.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"uuid": "^14.0.1",
"valibot": "^1.4.2"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/uuid": "^10.0.0",
"@wxt-dev/module-react": "^1.1.3",
"typescript": "^5.8.3",
"wxt": "^0.20.0"
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/ws": "^8.18.1",
"@wxt-dev/module-react": "^1.2.2",
"playwright-core": "^1.61.1",
"typescript": "^7.0.2",
"vitest": "^4.1.10",
"ws": "^8.21.1",
"wxt": "^0.20.27"
}
}
+2334 -3051
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
{
"$schema": "http://json-schema.org/draft-03/schema#",
"type": "object",
"properties": {
"bridgeTransport": {
"title": "Bridge transport",
"description": "Lock the extension to native or loopback WebSocket transport.",
"type": "string",
"enum": ["native", "websocket"]
},
"bridgeEndpoint": {
"title": "Bridge endpoint",
"description": "Managed loopback WebSocket endpoint.",
"type": "string"
},
"nativeHost": {
"title": "Native Messaging host",
"type": "string"
},
"autoConnect": {
"title": "Connect automatically",
"type": "boolean"
},
"disableWebSocket": {
"title": "Require Native Messaging",
"type": "boolean"
},
"floatingPanelEnabled": {
"title": "Enable the page floating panel",
"type": "boolean"
},
"maxGrantMinutes": {
"title": "Maximum grant duration in minutes",
"type": "integer",
"minimum": 5,
"maximum": 1440
},
"grantAllowedOrigins": {
"title": "Origins that may be shared with an Agent",
"type": "array",
"items": { "type": "string" }
},
"allowProgramEval": {
"title": "Allow program Eval grants",
"type": "boolean"
}
}
}
+94
View File
@@ -0,0 +1,94 @@
import { access, readFile, stat } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { gzipSync } from 'node:zlib';
const root = resolve(import.meta.dirname, '..');
const MIB = 1024 * 1024;
// Extension Service Workers do not support runtime import(). Bridge v3 identity verification must stay in the startup bundle.
const BRIDGE_BACKGROUND_BUDGET = 144 * 1024;
const BRIDGE_BACKGROUND_GZIP_BUDGET = 44 * 1024;
const targets = [
{ name: 'store', dir: '.output/chrome-mv3-store', contentBudget: 12 * 1024, backgroundBudget: BRIDGE_BACKGROUND_BUDGET, backgroundGzipBudget: BRIDGE_BACKGROUND_GZIP_BUDGET, totalBudget: MIB, directEval: false, userScripts: true, execution: 'user-scripts' },
{ name: 'enterprise', dir: '.output/chrome-mv3-enterprise', contentBudget: 16 * 1024, backgroundBudget: BRIDGE_BACKGROUND_BUDGET, backgroundGzipBudget: BRIDGE_BACKGROUND_GZIP_BUDGET, totalBudget: MIB, directEval: true, userScripts: true, execution: 'user-scripts+injected-fallback' },
{ name: 'firefox', dir: '.output/firefox-mv2', contentBudget: 16 * 1024, backgroundBudget: BRIDGE_BACKGROUND_BUDGET, backgroundGzipBudget: BRIDGE_BACKGROUND_GZIP_BUDGET, totalBudget: MIB, directEval: true, userScripts: false, execution: 'injected-bridge' },
{ name: 'firefox-amo', dir: '.output/firefox-mv3-store', contentBudget: 12 * 1024, backgroundBudget: BRIDGE_BACKGROUND_BUDGET, backgroundGzipBudget: BRIDGE_BACKGROUND_GZIP_BUDGET, totalBudget: MIB, directEval: false, userScripts: false, execution: 'invoke-only' },
];
async function fileSize(path) {
return (await stat(path)).size;
}
async function exists(path) {
try {
await access(path);
return true;
} catch {
return false;
}
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
async function directorySize(path) {
const { readdir } = await import('node:fs/promises');
let total = 0;
for (const entry of await readdir(path, { withFileTypes: true })) {
const child = join(path, entry.name);
total += entry.isDirectory() ? await directorySize(child) : await fileSize(child);
}
return total;
}
const report = [];
for (const target of targets) {
const isFirefox = target.name.startsWith('firefox');
const output = resolve(root, target.dir);
assert(await exists(output), `${target.name} 产物不存在,请先运行对应构建命令`);
const manifest = JSON.parse(await readFile(join(output, 'manifest.json'), 'utf8'));
const contentBytes = await fileSize(join(output, 'content-scripts/agent.js'));
const backgroundSource = await readFile(join(output, 'background.js'));
const backgroundBytes = backgroundSource.byteLength;
const backgroundGzipBytes = gzipSync(backgroundSource).byteLength;
const observerBytes = await fileSize(join(output, 'page-observer-main-world.js'));
const totalBytes = await directorySize(output);
const directEvalExists = await exists(join(output, 'page-main-world.js'));
const resources = (manifest.web_accessible_resources || []).flatMap((entry) => typeof entry === 'string' ? [entry] : entry.resources || []);
const dynamicResourceGroup = (manifest.web_accessible_resources || []).find((entry) => typeof entry !== 'string' && entry.resources?.includes('floating.html'));
assert(contentBytes <= target.contentBudget, `${target.name} 常驻 content script ${contentBytes}B 超过预算 ${target.contentBudget}B`);
assert(backgroundBytes <= target.backgroundBudget, `${target.name} background ${backgroundBytes}B 超过 ${target.backgroundBudget / 1024}KiB 原始预算`);
assert(backgroundGzipBytes <= target.backgroundGzipBudget, `${target.name} background gzip ${backgroundGzipBytes}B 超过 ${target.backgroundGzipBudget / 1024}KiB 预算`);
assert(observerBytes <= 12 * 1024, `${target.name} MAIN-world observer ${observerBytes}B 超过 12KiB 预算`);
assert(totalBytes <= target.totalBudget, `${target.name} 总产物 ${totalBytes}B 超过 ${target.totalBudget / MIB}MiB 预算`);
assert(directEvalExists === target.directEval, `${target.name} page-main-world.js 存在状态不符合构建策略`);
assert(resources.includes('page-main-world.js') === target.directEval, `${target.name} page-main-world.js 暴露状态不符合构建策略`);
assert((manifest.permissions || []).includes('userScripts') === target.userScripts, `${target.name} userScripts 权限不符合构建策略`);
if (target.name === 'store' || target.name === 'firefox-amo') {
assert(!backgroundSource.toString().includes('(0,eval)'), `${target.name} background 不得包含间接 Eval 实现`);
}
assert((manifest.permissions || []).includes('webRequest'), `${target.name} 缺少网络捕获所需 webRequest 权限`);
assert((manifest.permissions || []).includes('webNavigation'), `${target.name} 缺少 frame/document 生命周期所需 webNavigation 权限`);
assert(!(manifest.permissions || []).includes('activeTab'), `${target.name} 不应申请未使用的 activeTab 权限`);
assert(!(manifest.permissions || []).includes('nativeMessaging') && (manifest.optional_permissions || []).includes('nativeMessaging'), `${target.name} Native Messaging 必须按需授权`);
assert((manifest.permissions || []).includes(isFirefox ? 'webRequestBlocking' : 'webRequestAuthProvider'), `${target.name} 缺少代理认证权限`);
assert(manifest.storage?.managed_schema === 'managed-storage-schema.json', `${target.name} 缺少企业 managed storage schema`);
assert(await exists(join(output, 'managed-storage-schema.json')), `${target.name} managed storage schema 未打包`);
if (!isFirefox) assert(!(manifest.permissions || []).includes('webRequestBlocking'), `${target.name} 不应申请阻断或修改网络请求的 webRequestBlocking 权限`);
assert(resources.includes('floating.html'), `${target.name} 没有公开按需浮动页`);
if (manifest.manifest_version === 3) assert(dynamicResourceGroup?.use_dynamic_url === true, `${target.name} 浮动页必须使用动态资源 URL`);
report.push({
target: target.name,
contentScriptKiB: Number((contentBytes / 1024).toFixed(2)),
backgroundKiB: Number((backgroundBytes / 1024).toFixed(2)),
backgroundGzipKiB: Number((backgroundGzipBytes / 1024).toFixed(2)),
observerKiB: Number((observerBytes / 1024).toFixed(2)),
totalKiB: Number((totalBytes / 1024).toFixed(2)),
execution: target.execution,
});
}
console.log(JSON.stringify(report, null, 2));
+81
View File
@@ -0,0 +1,81 @@
import { createInterface } from 'node:readline';
import { spawn } from 'node:child_process';
import { access, mkdir, realpath } from 'node:fs/promises';
import { constants } from 'node:fs';
import { resolve } from 'node:path';
const root = process.cwd();
const output = resolve(root, '.output/chrome-mv3-dev');
const manifest = resolve(output, 'manifest.json');
const profile = resolve(root, '.wxt/chrome-wsl-profile');
const chrome = process.env.CHROME_PATH || '/usr/bin/google-chrome';
await access(chrome, constants.X_OK).catch(() => {
throw new Error(`Chrome is not executable: ${chrome}. Set CHROME_PATH to override it.`);
});
await mkdir(profile, { recursive: true });
const resolvedChrome = await realpath(chrome);
const isBrandedChrome = resolvedChrome.startsWith('/opt/google/chrome/');
const wxt = spawn(process.execPath, [resolve(root, 'node_modules/wxt/bin/wxt.mjs')], {
cwd: root,
env: process.env,
stdio: ['inherit', 'pipe', 'pipe'],
});
const pipeLines = (stream, destination) => {
const reader = createInterface({ input: stream });
reader.on('line', (line) => {
if (!line.includes('Cannot open browser when using WSL')) destination.write(`${line}\n`);
});
};
pipeLines(wxt.stdout, process.stdout);
pipeLines(wxt.stderr, process.stderr);
const waitForManifest = async () => {
for (let attempt = 0; attempt < 200; attempt += 1) {
if (wxt.exitCode !== null) throw new Error(`WXT exited before producing ${manifest}`);
try {
await access(manifest, constants.R_OK);
return;
} catch {
await new Promise((resolveWait) => setTimeout(resolveWait, 100));
}
}
throw new Error(`Timed out waiting for ${manifest}`);
};
let chromeProcess;
const shutdown = async (signal) => {
if (chromeProcess?.exitCode === null) chromeProcess.kill(signal);
if (wxt.exitCode === null) wxt.kill(signal);
};
process.once('SIGINT', () => void shutdown('SIGINT'));
process.once('SIGTERM', () => void shutdown('SIGTERM'));
try {
await waitForManifest();
const chromeArgs = [
`--user-data-dir=${profile}`,
'--disable-gpu',
'--no-first-run',
'--no-default-browser-check',
];
if (!isBrandedChrome || process.env.WXT_AUTO_LOAD_EXTENSION === '1') {
chromeArgs.push(`--disable-extensions-except=${output}`, `--load-extension=${output}`, 'about:blank');
} else {
chromeArgs.push('chrome://extensions');
}
chromeProcess = spawn(chrome, chromeArgs, { cwd: root, env: process.env, stdio: 'inherit' });
if (isBrandedChrome && process.env.WXT_AUTO_LOAD_EXTENSION !== '1') {
process.stdout.write(`\nOpened official Chrome with the persistent WXT profile.\nChrome 137+ ignores --load-extension in branded builds. On first run, enable Developer mode and load:\n${output}\nProfile: ${profile}\n`);
} else {
process.stdout.write(`\nOpened ${chrome} with the WXT development extension.\nProfile: ${profile}\n`);
}
} catch (error) {
await shutdown('SIGTERM');
throw error;
}
await new Promise((resolveExit) => wxt.once('exit', resolveExit));
if (chromeProcess?.exitCode === null) chromeProcess.kill('SIGTERM');
+44
View File
@@ -0,0 +1,44 @@
import { constants } from 'node:fs';
import { access, readdir } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
async function executable(path) {
if (!path) return false;
try {
await access(path, constants.X_OK);
return true;
} catch {
return false;
}
}
export async function resolveChromiumPath() {
for (const candidate of [process.env.CHROMIUM_PATH, process.env.CHROME_PATH]) {
if (await executable(candidate)) return candidate;
}
const cacheRoot = process.env.PLAYWRIGHT_BROWSERS_PATH || join(homedir(), '.cache', 'ms-playwright');
let entries = [];
try {
entries = (await readdir(cacheRoot, { withFileTypes: true }))
.filter((entry) => entry.isDirectory() && entry.name.startsWith('chromium-'))
.map((entry) => entry.name)
.sort((left, right) => Number(right.slice(9)) - Number(left.slice(9)));
} catch {
// The final error below lists the supported configuration options.
}
for (const entry of entries) {
for (const relative of ['chrome-linux64/chrome', 'chrome-linux/chrome']) {
const candidate = join(cacheRoot, entry, relative);
if (await executable(candidate)) return candidate;
}
}
for (const command of ['google-chrome-for-testing', 'chromium', 'chromium-browser']) {
const resolved = spawnSync('which', [command], { encoding: 'utf8' }).stdout.trim();
if (await executable(resolved)) return resolved;
}
throw new Error('Unpacked-capable Chromium not found. Set CHROMIUM_PATH or install Chromium/Playwright Chromium.');
}
+241
View File
@@ -0,0 +1,241 @@
import { spawnSync } from 'node:child_process';
import { randomBytes, webcrypto } from 'node:crypto';
import { cp, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { chromium } from 'playwright-core';
import { WebSocketServer } from 'ws';
import { resolveChromiumPath } from './resolve-chromium.mjs';
const root = resolve(import.meta.dirname, '..');
const yakRoot = resolve(process.env.YAK_REPO || root, process.env.YAK_REPO ? '.' : '../../go/yaklang');
const extensionPath = resolve(root, process.env.EXTENSION_PATH || '.output/chrome-mv3-store');
const executablePath = await resolveChromiumPath();
const temporary = await mkdtemp(join(tmpdir(), 'yakit-native-host-e2e-'));
const home = join(temporary, 'home');
const profile = join(temporary, 'profile');
const hostBinary = join(temporary, 'yakit-browser-agent-host');
const testExtensionPath = join(temporary, 'extension');
const hostName = 'com.yaklang.browser_agent';
const packagedManifest = JSON.parse(await readFile(join(extensionPath, 'manifest.json'), 'utf8'));
if (!packagedManifest.optional_permissions?.includes('nativeMessaging') || packagedManifest.permissions?.includes('nativeMessaging')) {
throw new Error('Native Messaging is not packaged as an optional permission');
}
// Chrome's optional-permission prompt is browser chrome and cannot be accepted by
// Playwright. Pre-grant it only in a disposable copy so the native transport itself
// can still be exercised through the real extension and browser APIs.
await cp(extensionPath, testExtensionPath, { recursive: true });
const testManifest = structuredClone(packagedManifest);
testManifest.permissions = [...new Set([...(testManifest.permissions || []), 'nativeMessaging'])];
testManifest.optional_permissions = (testManifest.optional_permissions || []).filter((value) => value !== 'nativeMessaging');
await writeFile(join(testExtensionPath, 'manifest.json'), JSON.stringify(testManifest, null, 2));
const build = spawnSync('go', ['build', '-o', hostBinary, './common/browser/nativehostcmd'], {
cwd: yakRoot, encoding: 'utf8', env: process.env,
});
if (build.status !== 0) throw new Error(`Native Host build failed:\n${build.stderr || build.stdout}`);
const bridgeHTTPServer = createServer();
const pairingServer = new WebSocketServer({ noServer: true });
const bridgeServer = new WebSocketServer({ noServer: true });
bridgeHTTPServer.on('upgrade', (request, socket, head) => {
const pathname = new URL(request.url || '/', 'http://127.0.0.1').pathname;
const target = pathname === '/pairing' ? pairingServer : pathname === '/extension' ? bridgeServer : undefined;
if (!target) return socket.destroy();
target.handleUpgrade(request, socket, head, (webSocket) => target.emit('connection', webSocket, request));
});
await new Promise((resolveListen) => bridgeHTTPServer.listen(0, '127.0.0.1', resolveListen));
const address = bridgeHTTPServer.address();
const endpoint = `ws://127.0.0.1:${address.port}/extension`;
const protocolVersion = 3;
const engineIdentityId = 'native-e2e-engine-identity';
const engineInstanceId = 'native-e2e-engine-instance';
const engineKeys = await webcrypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
const rawEngineJWK = await webcrypto.subtle.exportKey('jwk', engineKeys.publicKey);
const enginePublicKey = { kty: 'EC', crv: 'P-256', x: rawEngineJWK.x, y: rawEngineJWK.y };
let pairedClient;
let authenticatedConnections = 0;
let resolveHello;
const helloReceived = new Promise((resolveMessage) => { resolveHello = resolveMessage; });
const toBase64URL = (value) => Buffer.from(value).toString('base64url');
const engineChallengePayload = (challenge, timestamp) => ['yak-browser-bridge-v3', 'engine-challenge', engineIdentityId, engineInstanceId, challenge, String(timestamp)].join('\n');
const clientAuthPayload = (origin, challenge, auth) => [
'yak-browser-bridge-v3', 'client-auth', origin, engineIdentityId, engineInstanceId, challenge,
auth.installationId || '', auth.client || '', auth.version || '', [...(auth.capabilities || [])].sort().join(','),
auth.taskId || '', auth.grantId || '', auth.resumeSessionId || '',
].join('\n');
pairingServer.on('connection', (socket, request) => socket.once('message', async (raw) => {
const pairing = JSON.parse(raw.toString());
if (pairing.type !== 'pair_request' || pairing.protocolVersion !== protocolVersion) return socket.close(1008, 'invalid pairing request');
const requestId = 'native-e2e-pairing';
const serverNonce = toBase64URL(randomBytes(32));
const transcript = [
'yak-browser-pairing-v1', engineIdentityId, requestId, request.headers.origin, pairing.installationId,
pairing.nonce, serverNonce, pairing.publicKey.kty, pairing.publicKey.crv, pairing.publicKey.x, pairing.publicKey.y,
].join('\n');
const digest = Buffer.from(await webcrypto.subtle.digest('SHA-256', Buffer.from(transcript)));
const code = String(digest.readBigUInt64BE() % 1_000_000n).padStart(6, '0');
pairedClient = { installationId: pairing.installationId, publicKey: pairing.publicKey };
socket.send(JSON.stringify({
type: 'pair_pending', protocolVersion, requestId, serverNonce, engineIdentityId, code,
expiresAt: Date.now() + 60_000, publicKey: enginePublicKey,
}));
setTimeout(() => socket.send(JSON.stringify({
type: 'pair_approved', requestId, deviceId: 'native-e2e-device', engineIdentityId, publicKey: enginePublicKey,
})), 50);
}));
bridgeServer.on('connection', async (socket, request) => {
const challenge = toBase64URL(randomBytes(32));
const timestamp = Date.now();
socket.send(JSON.stringify({
type: 'challenge', protocolVersion, challenge, timestamp, engineIdentityId, engineInstanceId, publicKey: enginePublicKey,
signature: toBase64URL(await webcrypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' }, engineKeys.privateKey, Buffer.from(engineChallengePayload(challenge, timestamp)),
)),
}));
socket.once('message', (raw) => void (async () => {
const hello = JSON.parse(raw.toString());
if (!request.headers.origin?.startsWith('chrome-extension://') || hello.type !== 'auth' || hello.protocolVersion !== protocolVersion || !hello.installationId || hello.challenge !== challenge || !pairedClient) {
socket.close(1008, 'invalid native e2e handshake');
return;
}
const clientKey = await webcrypto.subtle.importKey('jwk', pairedClient.publicKey, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
const verified = await webcrypto.subtle.verify(
{ name: 'ECDSA', hash: 'SHA-256' }, clientKey, Buffer.from(hello.signature, 'base64url'),
Buffer.from(clientAuthPayload(request.headers.origin, challenge, hello)),
);
if (!verified || pairedClient.installationId !== hello.installationId) return socket.close(1008, 'invalid native e2e signature');
socket.send(JSON.stringify({
type: 'hello_ack', protocolVersion, version: 'native-e2e-engine', capabilities: [],
sessionId: 'native-e2e-session', engineIdentityId, engineInstanceId,
connectionId: 'native-e2e-connection', resumed: false,
}));
socket.on('message', (payload) => {
const message = JSON.parse(payload.toString());
if (message.type === 'ping') socket.send(JSON.stringify({
type: 'pong', id: message.id, sequence: message.sequence, timestamp: message.timestamp, replyTimestamp: Date.now(),
}));
});
authenticatedConnections += 1;
if (authenticatedConnections >= 2) resolveHello({ hello, origin: request.headers.origin });
})());
});
await mkdir(join(home, '.config', 'yakit'), { recursive: true });
await writeFile(join(home, '.config', 'yakit', 'browser-agent-native-host.json'), JSON.stringify({ endpoint }));
let context;
try {
const launch = () => chromium.launchPersistentContext(profile, {
executablePath,
headless: true,
env: { ...process.env, HOME: home },
args: [`--disable-extensions-except=${testExtensionPath}`, `--load-extension=${testExtensionPath}`, '--no-first-run'],
});
context = await launch();
let worker = context.serviceWorkers()[0];
if (!worker) worker = await context.waitForEvent('serviceworker', { timeout: 15_000 });
const extensionId = new URL(worker.url()).host;
const manifest = {
name: hostName,
description: 'Yakit Browser Agent Native Host E2E',
path: hostBinary,
type: 'stdio',
allowed_origins: [`chrome-extension://${extensionId}/`],
};
const manifestDirectories = [
...['google-chrome', 'google-chrome-for-testing', 'chromium'].map((product) => join(home, '.config', product, 'NativeMessagingHosts')),
join(profile, 'NativeMessagingHosts'),
];
for (const directory of manifestDirectories) {
const path = join(directory, `${hostName}.json`);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify(manifest));
}
// Chromium caches native-host registrations at process startup.
await context.close();
context = await launch();
worker = context.serviceWorkers()[0];
if (!worker) worker = await context.waitForEvent('serviceworker', { timeout: 15_000 });
const restartedExtensionId = new URL(worker.url()).host;
if (restartedExtensionId !== extensionId) throw new Error('Extension ID changed after Native Host registration');
const options = await context.newPage();
await options.goto(`chrome-extension://${extensionId}/options.html#engine`);
try {
await options.evaluate(async ({ bridgeEndpoint }) => {
const send = async (action, payload) => {
const response = await chrome.runtime.sendMessage({ action, payload });
if (!response?.ok) throw new Error(response?.error || action);
return response.data;
};
const state = await send('state.get');
await send('bridge.config.save', {
transport: 'websocket', nativeHost: 'com.yaklang.browser_agent', endpoint: bridgeEndpoint,
autoConnect: false, installationId: state.bridge.installationId,
});
await send('bridge.pair');
for (let attempt = 0; attempt < 50; attempt += 1) {
const [next, status] = await Promise.all([send('state.get'), send('bridge.status')]);
if (next.bridge.pairedEngine && status.state === 'connected') {
await send('bridge.disconnect');
await send('bridge.config.save', { ...next.bridge, transport: 'native', autoConnect: false });
await send('bridge.connect');
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error('Browser extension pairing did not complete');
}, { bridgeEndpoint: endpoint });
} catch (error) {
const diagnostics = await options.evaluate(async () => ({
body: document.body.innerText,
permissions: await chrome.permissions.getAll(),
bridge: await chrome.runtime.sendMessage({ action: 'bridge.status' }),
}));
throw new Error(`Native Host pairing and settings failed: ${JSON.stringify(diagnostics)}`, { cause: error });
}
let connection;
try {
connection = await Promise.race([
helloReceived,
new Promise((_, reject) => setTimeout(() => reject(new Error('Native Host did not reach Yak Bridge')), 15_000)),
]);
} catch (error) {
const diagnostics = await options.evaluate(async () => ({
body: document.body.innerText,
permissions: await chrome.permissions.getAll(),
bridge: await chrome.runtime.sendMessage({ action: 'bridge.status' }),
}));
throw new Error(`Native Host transport failed: ${JSON.stringify(diagnostics)}`, { cause: error });
}
const status = await options.evaluate(async () => {
const response = await chrome.runtime.sendMessage({ action: 'bridge.status' });
if (!response?.ok) throw new Error(response?.error || 'bridge.status');
return response.data;
});
if (status.state !== 'connected' || status.engineInstanceId !== 'native-e2e-engine-instance' || status.connectionId !== 'native-e2e-connection') {
throw new Error(`Native Host identity did not reach the extension: ${JSON.stringify(status)}`);
}
console.log(JSON.stringify({
extensionId,
endpoint,
permissionFixture: 'pre-granted only in temporary E2E copy; production package remains optional',
connection,
status,
}, null, 2));
} finally {
await context?.close();
for (const client of bridgeServer.clients) client.terminate();
for (const client of pairingServer.clients) client.terminate();
bridgeServer.close();
pairingServer.close();
bridgeHTTPServer.close();
await rm(temporary, { recursive: true, force: true });
}
File diff suppressed because it is too large Load Diff
+603
View File
@@ -0,0 +1,603 @@
import { browser, type Browser } from 'wxt/browser';
import {
clearNetworkRequests, exportNetworkRequest, listNetworkRequests, networkCaptureStatus,
startNetworkCapture, stopNetworkCapture, stopNetworkCapturesForGrant,
} from '@/features/network-capture/service';
import { capturedRequestEnginePayload } from '@/features/network-capture/workflows';
import {
clearPageObservations, listPageObservations, pageObservationStatus, startPageObservation,
stopPageObservation, stopPageObservationsForGrant,
} from '@/features/page-observation/service';
import type { ExtensionRequest, ExtensionResponse } from '@/types/messages';
import { parseExtensionRequest } from '@/protocol/extension';
import type {
BridgeGrantTarget, BrowserRequestAnalysisBundle, BrowserTarget, YakPocGenerateResult, YakitFuzzerOpenResult,
} from '@/types/models';
import { engineBridge } from '@/features/engine-bridge/service';
import { getFrameInventory } from '@/features/page-context/frames';
import { getActiveTab, getTab, resolveDocumentTarget } from '@/platform/browser/targets';
import {
actOnPageNode, capturePageContext, evalInPage, inspectPageNode, invokePageFunction,
} from '@/features/page-context/service';
import { listCookies, removeCookie, setCookie } from '@/features/cookies/service';
import { exportCookies, importCookies } from '@/features/cookies/transfer';
import {
applyProxyRules, clearProxyRuleStats, compileProxyRules, getProxyRuleStats, hasProxyAuthPassword,
previewProxyRules, setProxyAuthPassword, switchProxy,
} from '@/features/proxy/service';
import { getState, updateState } from '@/platform/storage/state';
import { applyUserAgentRules } from '@/features/identity/user-agent';
import { errorCode, ExtensionError } from '@/shared/errors';
import { appendAuditEvent, clearAuditEvents, listAuditEvents } from '@/features/diagnostics/audit';
import {
clearAgentActions, getAgentRuntime, setAgentRuntimeState, startAgentRuntime,
} from '@/features/agent-runtime/service';
import {
applyPolicyToBridge, applyPolicyToState, assertGrantPolicy, getEnterprisePolicy,
} from '@/platform/policy/managed';
import { createDiagnosticsBundle } from '@/features/diagnostics/export';
import { getRuntimeMetrics, recordServiceWorkerStart, resetRuntimeMetrics } from '@/features/diagnostics/metrics';
function ok<T>(data?: T): ExtensionResponse<T> {
return { ok: true, data };
}
function fail(error: unknown): ExtensionResponse {
return { ok: false, error: error instanceof Error ? error.message : String(error), errorCode: errorCode(error) };
}
function isFloatingSender(sender: Browser.runtime.MessageSender): boolean {
try {
const parsed = new URL(sender.url || '');
return parsed.origin === new URL(browser.runtime.getURL('/')).origin && parsed.pathname === '/floating.html';
} catch {
return false;
}
}
function senderBoundTabId(sender: Browser.runtime.MessageSender): number | undefined {
const extensionOrigin = new URL(browser.runtime.getURL('/')).origin;
const senderUrl = sender.url || '';
try {
const parsed = new URL(senderUrl);
if (parsed.origin === extensionOrigin && parsed.pathname !== '/floating.html') return undefined;
} catch {
// Non-URL senders remain bound to their browser tab below.
}
return sender.tab?.id;
}
function targetTabId(requested: number | undefined, sender: Browser.runtime.MessageSender): number | undefined {
const senderTabId = senderBoundTabId(sender);
if (senderTabId && requested && senderTabId !== requested) {
throw new Error('页面内请求不能操作其他标签页');
}
return senderTabId || requested;
}
async function requestTarget(
input: { tabId?: number; frameId?: number; documentId?: string },
sender: Browser.runtime.MessageSender,
): Promise<BrowserTarget | undefined> {
const boundTabId = senderBoundTabId(sender);
if (boundTabId && input.tabId && boundTabId !== input.tabId) {
throw new ExtensionError('target_denied', '页面内请求不能操作其他标签页');
}
if (boundTabId && !isFloatingSender(sender)) {
const frameId = sender.frameId ?? 0;
if (input.frameId !== undefined && input.frameId !== frameId) throw new ExtensionError('target_denied', '页面内请求不能操作其他 frame');
if (input.documentId && sender.documentId && input.documentId !== sender.documentId) {
throw new ExtensionError('stale_document', '目标页面已经刷新或导航,请重新选择');
}
return { tabId: boundTabId, frameId, documentId: sender.documentId };
}
const tabId = boundTabId || input.tabId;
if (!tabId) return undefined;
return resolveDocumentTarget({ tabId, frameId: input.frameId ?? 0, documentId: input.documentId });
}
async function requiredRequestTarget(
input: { tabId?: number; frameId?: number; documentId?: string },
sender: Browser.runtime.MessageSender,
): Promise<BrowserTarget> {
const target = await requestTarget(input, sender);
if (!target) throw new ExtensionError('target_unavailable', '请选择一个可访问的 HTTP(S) 标签页');
return target;
}
function originOf(url: string): string {
const parsed = new URL(url);
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('只能授权 HTTP(S) 标签页');
return parsed.origin;
}
async function createGrantTargets(inputs: Array<{ tabId: number; frameId: number }>): Promise<BridgeGrantTarget[]> {
const unique = [...new Map(inputs.map((target) => [`${target.tabId}:${target.frameId}`, target])).values()];
const tabIds = [...new Set(unique.map((target) => target.tabId))];
const inventories = new Map(await Promise.all(tabIds.map(async (tabId) => [tabId, await getFrameInventory(tabId)] as const)));
return Promise.all(unique.map(async (input) => {
const tab = await getTab(input.tabId);
const frame = inventories.get(input.tabId)?.find((item) => item.frameId === input.frameId);
if (!frame?.accessible || !frame.documentId || !frame.origin) {
throw new ExtensionError('target_unavailable', `Frame ${input.frameId} 当前不可访问,不能加入共享会话`);
}
originOf(`${frame.origin}/`);
return {
tabId: input.tabId,
frameId: frame.frameId,
documentId: frame.documentId,
origin: frame.origin,
grantedUrl: frame.url,
title: frame.isTop ? tab.title : `${tab.title} · ${frame.title || frame.name || `Frame ${frame.frameId}`}`,
};
}));
}
async function handleRequest(request: ExtensionRequest, sender: Browser.runtime.MessageSender): Promise<ExtensionResponse> {
switch (request.action) {
case 'state.get': return ok(await getState());
case 'tab.active': {
const boundTabId = senderBoundTabId(sender);
return ok(boundTabId ? await getTab(boundTabId) : await getActiveTab());
}
case 'tab.get': return ok(await getTab(targetTabId(request.payload.tabId, sender)));
case 'tab.list': return ok((await browser.tabs.query({})).filter((tab) => tab.id && /^https?:/i.test(tab.url || '')).map((tab) => ({
id: tab.id!, windowId: tab.windowId, title: tab.title || '未命名页面', url: tab.url!, favIconUrl: tab.favIconUrl, lastAccessed: tab.lastAccessed,
})));
case 'frame.list': return ok(await getFrameInventory(targetTabId(request.payload.tabId, sender)!));
case 'proxy.save': {
const profile = request.payload;
return ok(await updateState((state) => ({
...state,
proxyProfiles: [...state.proxyProfiles.filter((item) => item.id !== profile.id), profile],
})));
}
case 'proxy.delete': {
const { id } = request.payload;
return ok(await updateState((state) => ({
...state,
proxyProfiles: state.proxyProfiles.filter((item) => item.id !== id || item.builtin),
proxyRules: state.proxyRules.filter((rule) => rule.proxyProfileId !== id),
})));
}
case 'proxy.switch':
await switchProxy(request.payload.id);
return ok(await getState());
case 'proxy.rule.save': {
const rule = request.payload;
const profiles = (await getState()).proxyProfiles;
if (!profiles.some((profile) => profile.id === rule.proxyProfileId && ['direct', 'fixed_servers'].includes(profile.kind))) {
throw new Error('规则 PAC 只能使用直接连接或固定代理出口');
}
return ok(await updateState((state) => ({
...state,
proxyRules: [...state.proxyRules.filter((item) => item.id !== rule.id), rule],
})));
}
case 'proxy.rule.delete': {
const { id } = request.payload;
return ok(await updateState((state) => ({ ...state, proxyRules: state.proxyRules.filter((item) => item.id !== id) })));
}
case 'proxy.rules.apply':
await applyProxyRules();
return ok(await getState());
case 'proxy.rules.preview': {
const state = await getState();
return ok(previewProxyRules(request.payload.url, state.proxyRules, state.proxyProfiles, state.proxyRouting));
}
case 'proxy.rules.compile': {
const state = await getState();
return ok(compileProxyRules(state.proxyRules, state.proxyProfiles, state.proxyRouting));
}
case 'proxy.rules.reorder': {
const ids = request.payload.ids;
const state = await getState();
if (ids.length !== state.proxyRules.length || new Set(ids).size !== ids.length || ids.some((id) => !state.proxyRules.some((rule) => rule.id === id))) {
throw new Error('规则排序必须包含当前全部规则且不能重复');
}
const byId = new Map(state.proxyRules.map((rule) => [rule.id, rule]));
return ok(await updateState((current) => ({
...current,
proxyRules: ids.map((id, index) => ({ ...byId.get(id)!, priority: (ids.length - index) * 10 })),
})));
}
case 'proxy.rules.settings': {
const input = request.payload;
const state = await getState();
if (!state.proxyProfiles.some((profile) => profile.id === input.defaultProfileId && ['direct', 'fixed_servers'].includes(profile.kind))) throw new Error('默认出口必须是直接连接或固定代理');
return ok(await updateState((current) => ({ ...current, proxyRouting: input })));
}
case 'proxy.rules.stats': return ok(getProxyRuleStats());
case 'proxy.rules.stats.clear':
await clearProxyRuleStats();
return ok();
case 'proxy.auth.set':
await setProxyAuthPassword(request.payload.profileId, request.payload.password);
return ok({ configured: hasProxyAuthPassword(request.payload.profileId) });
case 'proxy.auth.status': return ok({ configured: hasProxyAuthPassword(request.payload.profileId) });
case 'proxy.config.export': {
const state = await getState();
return ok({ version: 1 as const, profiles: state.proxyProfiles, rules: state.proxyRules, routing: state.proxyRouting });
}
case 'proxy.config.import': {
const configuration = request.payload.configuration;
const profileIds = new Set(configuration.profiles.map((profile) => profile.id));
if (profileIds.size !== configuration.profiles.length || !profileIds.has(configuration.routing.defaultProfileId)) throw new Error('代理配置包含重复或缺失的出口 ID');
if (configuration.rules.some((rule) => !profileIds.has(rule.proxyProfileId))) throw new Error('代理规则引用了不存在的出口');
const routableIds = new Set(configuration.profiles.filter((profile) => ['direct', 'fixed_servers'].includes(profile.kind)).map((profile) => profile.id));
if (!routableIds.has(configuration.routing.defaultProfileId) || configuration.rules.some((rule) => !routableIds.has(rule.proxyProfileId))) throw new Error('规则 PAC 只能使用直接连接或固定代理出口');
return ok(await updateState((current) => ({
...current,
proxyProfiles: configuration.profiles,
proxyRules: configuration.rules,
proxyRouting: configuration.routing,
activeProxyId: 'direct',
})));
}
case 'cookie.list': return ok(await listCookies(request.payload.url));
case 'cookie.set': return ok(await setCookie(request.payload));
case 'cookie.remove': {
const input = request.payload;
await removeCookie(input);
return ok();
}
case 'cookie.removeMany': {
const results = await Promise.allSettled(request.payload.cookies.map((cookie) => removeCookie(cookie)));
const removed = results.filter((result) => result.status === 'fulfilled').length;
return ok({ removed, failed: results.length - removed });
}
case 'cookie.import': return ok(await importCookies(request.payload.url, request.payload.format, request.payload.text));
case 'cookie.export': return ok(exportCookies(await listCookies(request.payload.url), request.payload.format, request.payload.includeValues));
case 'ua.save': {
const rule = request.payload;
const state = await updateState((current) => ({
...current,
userAgentRules: [...current.userAgentRules.filter((item) => item.id !== rule.id), rule],
}));
if (state.activeGrant) await startAgentRuntime(state.activeGrant);
await applyUserAgentRules(state.userAgentRules);
return ok(state);
}
case 'ua.delete': {
const state = await updateState((current) => ({
...current,
userAgentRules: current.userAgentRules.filter((item) => item.id !== request.payload.id),
}));
await applyUserAgentRules(state.userAgentRules);
return ok(state);
}
case 'ua.apply': {
const state = await getState();
await applyUserAgentRules(state.userAgentRules);
return ok(state);
}
case 'context.capture': {
const { tabId, frameId, documentId, ...options } = request.payload;
const target = await requiredRequestTarget({ tabId, frameId, documentId }, sender);
const context = await capturePageContext(options, target);
void appendAuditEvent({
category: 'capability', action: 'context.capture', outcome: 'success', targetTabId: target.tabId,
summary: `${context.document.interactive.length} 个节点,${context.diff.kind}`,
});
return ok(context);
}
case 'context.node.inspect': {
const input = request.payload;
const target = await requiredRequestTarget(input, sender);
return ok(await inspectPageNode(input.captureId, input.nodeId, target));
}
case 'context.node.action': {
const input = request.payload;
const target = await requiredRequestTarget(input, sender);
const result = await actOnPageNode(input.captureId, input.nodeId, input.action, target, input.value);
void appendAuditEvent({
category: 'capability', action: `context.node.${input.action}`, outcome: 'success', targetTabId: target.tabId,
summary: input.nodeId,
});
return ok(result);
}
case 'context.invoke': {
const input = request.payload;
return ok(await invokePageFunction(input.path, input.args, await requestTarget(input, sender), input.timeoutMs));
}
case 'context.eval': {
const input = request.payload;
return ok(await evalInPage(input.code, input.mode, await requestTarget(input, sender), input.timeoutMs));
}
case 'panel.update': {
const input = request.payload;
const policy = (await getEnterprisePolicy()).policy;
return ok(await updateState((current) => applyPolicyToState({
...current, floatingPanel: {
enabled: input.enabled ?? current.floatingPanel.enabled,
side: input.side ?? current.floatingPanel.side,
y: typeof input.y === 'number' ? Math.min(Math.max(input.y, 0.08), 0.92) : current.floatingPanel.y,
displayMode: input.displayMode ?? current.floatingPanel.displayMode,
siteMode: input.siteMode ?? current.floatingPanel.siteMode,
siteOrigins: input.siteOrigins
? [...new Set(input.siteOrigins.map((origin) => new URL(origin).origin))]
: current.floatingPanel.siteOrigins,
shortcutEnabled: input.shortcutEnabled ?? current.floatingPanel.shortcutEnabled,
autoCollapseFullscreen: input.autoCollapseFullscreen ?? current.floatingPanel.autoCollapseFullscreen,
},
}, policy)));
}
case 'grant.create': {
const input = request.payload;
const boundTabId = senderBoundTabId(sender);
if (boundTabId && input.targets.some((target) => target.tabId !== boundTabId)) {
throw new Error('页面内请求只能授权当前标签页');
}
const now = Date.now();
const targets = await createGrantTargets(input.targets);
const policy = (await getEnterprisePolicy()).policy;
const durationMinutes = assertGrantPolicy(policy, {
durationMinutes: input.durationMinutes,
origins: targets.map((target) => target.origin),
programEval: input.scopes.includes('browser.page.eval.program'),
});
const before = await getState();
const state = await updateState((current) => ({
...current,
activeGrant: {
id: crypto.randomUUID(),
taskId: input.taskId || `manual-${crypto.randomUUID()}`,
targets,
scopes: [...new Set(input.scopes)],
createdAt: now,
expiresAt: now + durationMinutes * 60_000,
},
handoff: current.handoff?.state === 'waiting_for_user'
? { ...current.handoff, state: 'cancelled', resolvedAt: now }
: current.handoff,
}));
if (before.activeGrant) {
await Promise.all([
stopNetworkCapturesForGrant(before.activeGrant.id),
stopPageObservationsForGrant(before.activeGrant.id),
]);
}
if (before.handoff?.state === 'waiting_for_user' && state.handoff) {
await browser.action.setBadgeText({ text: '', tabId: before.handoff.target.tabId });
engineBridge.emitEvent('browser.handoff.changed', state.handoff);
void appendAuditEvent({
category: 'handoff', action: 'handoff.cancelled', outcome: 'cancelled',
taskId: before.handoff.taskId, targetTabId: before.handoff.target.tabId,
summary: '创建新授权会话时取消',
});
}
void appendAuditEvent({
category: 'grant', action: 'grant.create', outcome: 'success', taskId: state.activeGrant?.taskId,
targetTabId: state.activeGrant?.targets[0]?.tabId,
summary: `${state.activeGrant?.targets.length || 0} 个标签页,${state.activeGrant?.scopes.length || 0} 项能力`,
});
return ok(state);
}
case 'grant.revoke': {
const before = await getState();
engineBridge.cancelActiveRequests();
const state = await updateState((current) => ({
...current,
activeGrant: undefined,
handoff: current.handoff?.state === 'waiting_for_user'
? { ...current.handoff, state: 'cancelled', resolvedAt: Date.now() }
: current.handoff,
}));
if (before.activeGrant) {
await Promise.all([
stopNetworkCapturesForGrant(before.activeGrant.id),
stopPageObservationsForGrant(before.activeGrant.id),
]);
}
await setAgentRuntimeState('revoked', before.activeGrant);
if (state.handoff && before.handoff?.state === 'waiting_for_user') engineBridge.emitEvent('browser.handoff.changed', state.handoff);
if (before.handoff?.state === 'waiting_for_user') {
await browser.action.setBadgeText({ text: '', tabId: before.handoff.target.tabId });
void appendAuditEvent({
category: 'handoff', action: 'handoff.cancelled', outcome: 'cancelled',
taskId: before.handoff.taskId, targetTabId: before.handoff.target.tabId,
summary: '撤销授权会话时取消',
});
}
void appendAuditEvent({
category: 'grant', action: 'grant.revoke', outcome: 'success', taskId: before.activeGrant?.taskId,
targetTabId: before.activeGrant?.targets[0]?.tabId,
});
return ok(state);
}
case 'handoff.resolve': {
const input = request.payload;
const state = await updateState((current) => {
if (!current.handoff || current.handoff.id !== input.id || current.handoff.state !== 'waiting_for_user') {
throw new ExtensionError('handoff_not_waiting', '人工接管请求不存在或已经结束');
}
return {
...current,
handoff: { ...current.handoff, state: input.outcome, resolvedAt: Date.now() },
};
});
const handoff = state.handoff!;
await setAgentRuntimeState(input.outcome === 'completed' ? 'running' : 'paused', state.activeGrant);
await browser.action.setBadgeText({ text: '', tabId: handoff.target.tabId });
engineBridge.emitEvent('browser.handoff.changed', handoff);
void appendAuditEvent({
category: 'handoff', action: `handoff.${input.outcome}`, outcome: input.outcome === 'completed' ? 'success' : 'cancelled',
taskId: handoff.taskId, targetTabId: handoff.target.tabId,
});
return ok(state);
}
case 'network.capture.start': {
const input = request.payload;
const target = await requiredRequestTarget(input, sender);
const status = await startNetworkCapture(target, input);
void appendAuditEvent({
category: 'capability', action: 'network.capture.start', outcome: 'success', targetTabId: target.tabId,
summary: input.captureHeaders || input.captureBody ? '包含用户明确启用的敏感字段' : '仅元数据',
});
return ok(status);
}
case 'network.capture.status': return ok(await networkCaptureStatus(await requiredRequestTarget(request.payload, sender)));
case 'network.capture.list': {
const target = await requiredRequestTarget(request.payload, sender);
return ok(await listNetworkRequests(target, request.payload.limit));
}
case 'network.capture.clear': {
const target = await requiredRequestTarget(request.payload, sender);
const status = await clearNetworkRequests(target);
void appendAuditEvent({ category: 'capability', action: 'network.capture.clear', outcome: 'success', targetTabId: target.tabId });
return ok(status);
}
case 'network.capture.stop': {
const target = await requiredRequestTarget(request.payload, sender);
const status = await stopNetworkCapture(target);
void appendAuditEvent({ category: 'capability', action: 'network.capture.stop', outcome: 'success', targetTabId: target.tabId });
return ok(status);
}
case 'network.capture.export': {
const target = await requiredRequestTarget(request.payload, sender);
const exported = await exportNetworkRequest(target, request.payload.id);
void appendAuditEvent({ category: 'capability', action: 'network.capture.export', outcome: 'success', targetTabId: target.tabId });
return ok(exported);
}
case 'network.capture.send': {
const target = await requiredRequestTarget(request.payload, sender);
try {
const exported = await exportNetworkRequest(target, request.payload.id);
const result = await engineBridge.requestEngine<YakitFuzzerOpenResult>('yakit.web_fuzzer.open', {
rawRequestBase64: exported.rawRequestBase64,
isHttps: exported.isHttps,
tabName: `Browser · ${new URL(exported.url).hostname}`,
});
void appendAuditEvent({
category: 'capability', action: 'network.capture.send_to_fuzzer', outcome: 'success', targetTabId: target.tabId,
summary: `Web Fuzzer ${result.pageId}`,
});
return ok(result);
} catch (error) {
void appendAuditEvent({
category: 'capability', action: 'network.capture.send_to_fuzzer', outcome: 'error',
targetTabId: target.tabId, errorCode: errorCode(error),
});
throw error;
}
}
case 'network.capture.poc': {
const target = await requiredRequestTarget(request.payload, sender);
const result = await engineBridge.requestEngine<YakPocGenerateResult>(
'yakit.poc.generate',
await capturedRequestEnginePayload(target, request.payload.id, false),
);
void appendAuditEvent({ category: 'capability', action: 'network.capture.generate_poc', outcome: 'success', targetTabId: target.tabId });
return ok(result);
}
case 'network.capture.analysis': {
const target = await requiredRequestTarget(request.payload, sender);
const result = await engineBridge.requestEngine<BrowserRequestAnalysisBundle>(
'yakit.browser_request.prepare_analysis',
await capturedRequestEnginePayload(target, request.payload.id, true),
);
void appendAuditEvent({ category: 'capability', action: 'network.capture.prepare_analysis', outcome: 'success', targetTabId: target.tabId });
return ok(result);
}
case 'observation.start': {
const input = request.payload;
const target = await requiredRequestTarget(input, sender);
const status = await startPageObservation(target, input);
void appendAuditEvent({
category: 'capability', action: 'observation.start', outcome: 'success', targetTabId: target.tabId,
summary: input.captureValues ? '包含用户明确启用的短时值预览' : '仅元数据',
});
return ok(status);
}
case 'observation.status': return ok(await pageObservationStatus(await requiredRequestTarget(request.payload, sender)));
case 'observation.list': {
const target = await requiredRequestTarget(request.payload, sender);
return ok(await listPageObservations(target, request.payload.limit, true));
}
case 'observation.clear': {
const target = await requiredRequestTarget(request.payload, sender);
const status = await clearPageObservations(target);
void appendAuditEvent({ category: 'capability', action: 'observation.clear', outcome: 'success', targetTabId: target.tabId });
return ok(status);
}
case 'observation.stop': {
const target = await requiredRequestTarget(request.payload, sender);
const status = await stopPageObservation(target);
void appendAuditEvent({ category: 'capability', action: 'observation.stop', outcome: 'success', targetTabId: target.tabId });
return ok(status);
}
case 'audit.list': return ok(await listAuditEvents(request.payload.limit));
case 'audit.clear': {
await clearAuditEvents();
return ok();
}
case 'agent.runtime.get': return ok(await getAgentRuntime());
case 'agent.pause': {
const state = await getState();
if (!state.activeGrant) throw new ExtensionError('grant_expired', '没有可暂停的浏览器共享会话');
engineBridge.cancelActiveRequests();
const runtime = await setAgentRuntimeState('paused', state.activeGrant);
void appendAuditEvent({ category: 'grant', action: 'agent.pause', outcome: 'success', taskId: state.activeGrant.taskId });
return ok(runtime);
}
case 'agent.resume': {
const state = await getState();
if (!state.activeGrant || state.activeGrant.expiresAt <= Date.now()) throw new ExtensionError('grant_expired', '浏览器共享会话不存在或已经过期');
const runtime = await setAgentRuntimeState('running', state.activeGrant);
void appendAuditEvent({ category: 'grant', action: 'agent.resume', outcome: 'success', taskId: state.activeGrant.taskId });
return ok(runtime);
}
case 'agent.actions.clear': return ok(await clearAgentActions());
case 'policy.status': return ok(await getEnterprisePolicy());
case 'diagnostics.export': return ok(await createDiagnosticsBundle(engineBridge.getStatus()));
case 'metrics.get': return ok(await getRuntimeMetrics());
case 'metrics.reset': return ok(await resetRuntimeMetrics());
case 'bridge.config.save': {
const config = applyPolicyToBridge(request.payload, (await getEnterprisePolicy()).policy);
const state = await updateState((current) => ({ ...current, bridge: config }));
if (config.autoConnect && config.pairedEngine) await engineBridge.connect(config);
else engineBridge.disconnect();
return ok(state);
}
case 'bridge.pair': {
const status = await engineBridge.startPairing();
void appendAuditEvent({ category: 'bridge', action: 'bridge.pair', outcome: 'success' });
return ok(status);
}
case 'bridge.pair.cancel': return ok(engineBridge.cancelPairing());
case 'bridge.pair.status': return ok(engineBridge.getPairingStatus());
case 'bridge.unpair': {
await engineBridge.unpair();
void appendAuditEvent({ category: 'bridge', action: 'bridge.unpair', outcome: 'success' });
return ok(await getState());
}
case 'bridge.connect': {
await engineBridge.connect();
void appendAuditEvent({ category: 'bridge', action: 'bridge.connect', outcome: 'success' });
return ok(engineBridge.getStatus());
}
case 'bridge.disconnect': {
engineBridge.disconnect();
void appendAuditEvent({ category: 'bridge', action: 'bridge.disconnect', outcome: 'success' });
return ok(engineBridge.getStatus());
}
case 'bridge.status': return ok(engineBridge.getStatus());
default: return fail('未知扩展操作');
}
}
export async function runBackground(): Promise<void> {
recordServiceWorkerStart();
browser.runtime.onMessage.addListener((input: unknown, sender: Browser.runtime.MessageSender, sendResponse) => {
if (['bridge.status.changed', 'bridge.pairing.status.changed'].includes((input as { action?: string })?.action || '')) return undefined;
void Promise.resolve().then(() => parseExtensionRequest(input)).then((request) => handleRequest(request, sender)).then(sendResponse).catch((error) => sendResponse(fail(error)));
return true;
});
const storedState = await getState();
const state = applyPolicyToState(storedState, (await getEnterprisePolicy()).policy);
if (JSON.stringify(state.bridge) !== JSON.stringify(storedState.bridge) || JSON.stringify(state.floatingPanel) !== JSON.stringify(storedState.floatingPanel)) {
await updateState(() => state);
}
await applyUserAgentRules(state.userAgentRules).catch(console.error);
if (state.bridge.autoConnect && state.bridge.pairedEngine) await engineBridge.connect(state.bridge).catch(console.error);
}
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

-385
View File
@@ -1,385 +0,0 @@
.proxy-container {
min-width: 200px;
background: white;
}
.proxy-menu {
border: none !important;
box-shadow: none !important;
overflow: hidden;
padding: 0 !important;
margin: 0 !important;
}
.menu-item {
height: 40px !important;
line-height: 40px !important;
margin: 0 !important;
padding: 0 16px !important;
}
.menu-item .anticon {
font-size: 16px;
color: var(--yakit-primary);
margin-right: 8px;
}
.menu-item-label {
font-size: 14px;
color: #333;
}
.menu-item:hover {
background-color: var(--yakit-primary-5) !important;
}
.menu-item:hover .anticon,
.menu-item:hover .menu-item-label {
color: var(--yakit-primary) !important;
}
/* 选中状态 */
.menu-item.ant-menu-item-selected {
background-color: var(--yakit-primary) !important;
}
.menu-item.ant-menu-item-selected .anticon,
.menu-item.ant-menu-item-selected .menu-item-label {
color: white !important;
}
.menu-item.ant-menu-item-selected:hover {
background-color: var(--yakit-primary-hover) !important;
}
/* 分隔线 */
.ant-menu-item-divider {
margin: 4px 0 !important;
border-color: #EAECF3 !important;
}
/* 设置选项 */
.menu-item-setting {
border-top: 1px solid #EAECF3;
margin-top: 4px !important;
}
.menu-item-setting .anticon {
color: #666;
}
.menu-item-setting:hover {
background-color: var(--yakit-primary-5) !important;
}
.menu-item-setting:hover .anticon,
.menu-item-setting:hover .menu-item-label {
color: var(--yakit-primary) !important;
}
/* 调整图标大小和对齐 */
.anticon {
font-size: 16px;
}
/* 添加以下样式来确保下拉菜单显示在正确的位置 */
.ant-dropdown {
position: absolute !important;
top: 100% !important;
left: 0 !important;
width: 100% !important;
min-width: 200px !important;
}
.dropdown-content {
background: white;
border-radius: 4px;
box-shadow: 0 3px 6px -4px rgba(0,0,0,0.12),
0 6px 16px 0 rgba(0,0,0,0.08),
0 9px 28px 8px rgba(0,0,0,0.05);
padding: 4px;
}
/* 确保容器不会限制弹出层 */
.proxy-container {
min-width: 200px;
background: white;
border-radius: 4px;
position: static;
}
/* 添加这个样式来确保下拉菜单显示在正确的位置 */
body {
position: relative;
}
.ant-menu {
border: none !important;
box-shadow: none !important;
padding: 0 !important;
width: 100% !important;
background: white !important;
}
.ant-menu-item {
height: 36px !important;
line-height: 36px !important;
margin: 4px 8px !important;
padding: 0 16px !important;
display: flex !important;
align-items: center !important;
border-radius: 6px !important;
transition: all 0.2s ease-in-out !important;
}
.ant-menu-item:hover {
background-color: #f5f5f5 !important;
color: var(--yakit-primary) !important;
}
.ant-menu-item.ant-menu-item-selected {
background-color: var(--yakit-primary) !important;
color: white !important;
border-radius: 6px !important;
}
.ant-menu-item.ant-menu-item-selected:hover {
background-color: var(--yakit-primary-hover) !important;
}
.ant-menu-item .anticon,
.ant-menu-item img {
font-size: 16px;
margin-right: 8px;
transition: all 0.2s ease-in-out !important;
}
.ant-menu-item.ant-menu-item-selected .anticon,
.ant-menu-item.ant-menu-item-selected img {
color: white;
}
.ant-menu-item-divider {
margin: 4px 0 !important;
height: 1px !important;
background-color: #f0f0f0 !important;
}
/* Checked item style (for the 2080 with checkmark) */
.ant-menu-item.checked::after {
content: "✓";
position: absolute;
right: 16px;
color: var(--yakit-primary);
font-weight: bold;
}
.ant-menu-item.ant-menu-item-selected.checked::after {
color: white;
}
/* Loading state */
.loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 10;
font-size: 14px;
color: var(--yakit-primary);
opacity: 0;
animation: fadeIn 0.2s ease-in-out forwards;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* Remove conflicting styles */
.panel-watermark {
display: none;
}
/* Improve bottom actions */
.ant-menu-item:nth-last-child(1),
.ant-menu-item:nth-last-child(2) {
border-top: 1px solid #f0f0f0;
margin-top: 4px !important;
}
.ant-menu-item:nth-last-child(1) .anticon,
.ant-menu-item:nth-last-child(2) .anticon {
color: #666;
}
.ant-menu-item:nth-last-child(1):hover .anticon,
.ant-menu-item:nth-last-child(2):hover .anticon {
color: var(--yakit-primary);
}
/* 全局样式重置,确保菜单项样式不受默认样式影响 */
.proxy-switch-container {
position: relative;
width: 100%;
overflow: hidden;
border-radius: 0;
box-shadow: none;
}
/* 确保菜单没有边框和阴影 */
.proxy-switch-container .ant-menu {
border: none !important;
border-right: none !important;
box-shadow: none !important;
}
/* 确保所有菜单项正确对齐和布局 */
.proxy-switch-container .ant-menu-item {
margin: 4px 8px !important;
border-radius: 6px !important;
height: 36px !important;
line-height: 36px !important;
position: relative !important;
}
/* 图标对齐 */
.proxy-switch-container .ant-menu-item .anticon,
.proxy-switch-container .ant-menu-item img {
position: absolute !important;
left: 16px !important;
top: 50% !important;
transform: translateY(-50%) !important;
}
/* 选中态和悬停态 */
.proxy-switch-container .ant-menu-item.ant-menu-item-selected {
background-color: var(--yakit-primary) !important;
color: white !important;
}
/* 选中态下的YAK图标变为白色 */
.proxy-switch-container .ant-menu-item.ant-menu-item-selected img {
filter: brightness(0) invert(1) !important;
}
.proxy-switch-container .ant-menu-item:hover {
background-color: #f5f5f5 !important;
}
.proxy-switch-container .ant-menu-item.ant-menu-item-selected:hover {
background-color: var(--yakit-primary-hover) !important;
}
/* 确保分割线样式 */
.proxy-switch-container .ant-menu-item-divider {
margin: 4px 0 !important;
height: 1px !important;
background-color: #f0f0f0 !important;
}
.panel-watermark {
position: absolute;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
opacity: 0.03;
pointer-events: none;
object-fit: contain;
object-position: right bottom;
z-index: 0;
}
/* 确保菜单项在水印上层 */
.ant-menu-item {
position: relative;
z-index: 1;
background: transparent !important;
}
/* 确保分割线在水印上层 */
.ant-menu-item-divider {
position: relative;
z-index: 1;
}
/* 为[直接连接]菜单项添加橙色背景,但仅在被选中时 */
.ant-menu .ant-menu-item.menu-id-direct.ant-menu-item-selected {
background-color: var(--yakit-primary) !important;
color: white !important;
}
.ant-menu .ant-menu-item.menu-id-direct.ant-menu-item-selected .anticon,
.ant-menu .ant-menu-item.menu-id-direct.ant-menu-item-selected span {
color: white !important;
}
/* 覆盖 Ant Design 的宽度计算,确保菜单项占据全宽 */
.proxy-switch-container .ant-menu .ant-menu-item,
.ant-menu-light .ant-menu-item,
.ant-menu-vertical .ant-menu-item,
.ant-menu-inline .ant-menu-item,
.ant-menu .ant-menu-item {
width: 100% !important;
margin-inline: 0 !important;
margin-block: 4px !important;
text-align: center !important;
height: 40px !important;
line-height: 40px !important;
padding-inline: 16px !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
}
/* 让文本的容器也占满全宽 */
.proxy-switch-container .ant-menu .ant-menu-item .ant-menu-title-content,
.ant-menu .ant-menu-item .ant-menu-title-content {
display: block !important;
width: 100% !important;
text-align: center !important;
}
/* 活动项样式 */
.ant-menu .ant-menu-item.active-item::after {
content: "✓";
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
color: var(--yakit-primary);
font-weight: bold;
}
.ant-menu .ant-menu-item.ant-menu-item-selected.active-item::after {
color: white;
}
.active-item {
background-color: var(--yakit-primary) !important;
color: white !important;
font-weight: bold;
transition: background-color 0.2s ease-in-out !important;
}
.active-item img {
filter: brightness(0) invert(1);
}
.active-item .anticon {
color: white !important;
}
.proxy-menu .ant-menu-item {
transition: all 0.2s ease-in-out !important;
}
/* 添加悬停效果 */
.proxy-menu .ant-menu-item:hover {
background-color: rgba(242, 139, 68, 0.1) !important;
}
-364
View File
@@ -1,364 +0,0 @@
import React, {useEffect, useState, useRef} from "react";
import {Menu} from "antd";
import {
DisconnectOutlined,
SettingOutlined,
PlusOutlined,
CheckOutlined,
} from "@ant-design/icons";
import {browser} from "wxt/browser";
import type {MenuProps} from "antd";
import type {ProxyConfig} from "@/types/proxy";
import {ContentActionType, ProxyActionType} from "@/types/action";
import {getAllProxyConfigs, getCurrentProxy} from "@/utils/storage";
import "./index.css";
// YAK 图标 URL
const YAK_ICON_URL = browser.runtime.getURL("/yak.svg");
// 固定的代理模式
const FIXED_MODES = [
{
key: "direct",
name: "[直接连接]",
icon: <DisconnectOutlined/>,
color: "#666",
config: {
id: "direct",
name: "[直接连接]",
proxyType: "direct",
enabled: false,
},
},
{
key: "system",
name: "[系统代理]",
icon: <SettingOutlined/>,
color: "#666",
config: {
id: "system",
name: "[系统代理]",
proxyType: "system",
enabled: false,
},
},
];
interface CustomProxy {
key: string;
name: string;
color: string;
config: ProxyConfig;
enabled?: boolean;
}
export const ProxySwitch: React.FC = () => {
const [initialized, setInitialized] = useState<boolean>(false);
const [currentMode, setCurrentMode] = useState<string>("direct"); // 默认选中直接连接
const [customProxies, setCustomProxies] = useState<CustomProxy[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const loadingTimeoutRef = useRef<number | null>(null);
// 监听存储变化
useEffect(() => {
const handleMessage = (message: any) => {
if (
message.action === ContentActionType.PROXY_CONFIGS_UPDATED &&
message.source !== "proxy_switch"
) {
console.log("proxy_switch 收到代理配置更新消息", message);
loadCustomProxies();
loadProxyStatus();
}
};
browser.runtime.onMessage.addListener(handleMessage);
return () => {
browser.runtime.onMessage.removeListener(handleMessage);
// 清除可能存在的超时计时器
if (loadingTimeoutRef.current) {
clearTimeout(loadingTimeoutRef.current);
}
};
}, []);
// 初始化
useEffect(() => {
let mounted = true;
const init = async () => {
try {
await loadProxyStatus();
if (mounted) {
await loadCustomProxies();
setInitialized(true);
}
} catch (error) {
console.error("初始化失败:", error);
if (mounted) {
setInitialized(true);
}
}
};
init();
return () => {
mounted = false;
};
}, []);
// 获取当前代理状态
const loadProxyStatus = async () => {
try {
// 先尝试从后台脚本获取当前代理状态
const response = await browser.runtime.sendMessage({
action: ProxyActionType.GET_PROXY_STATUS,
});
console.log("proxy_switch 获取当前代理状态", response);
if (response && response.success) {
const activeMode = response.data.mode;
console.log("获取到当前代理模式:", activeMode);
setCurrentMode(activeMode);
return;
}
// 如果后台脚本没有返回,则从存储中获取当前代理
const currentProxy = await getCurrentProxy();
if (currentProxy) {
console.log("从存储获取到当前代理:", currentProxy.id);
setCurrentMode(currentProxy.id);
} else {
console.log("未找到当前代理,使用默认值 direct");
setCurrentMode("direct");
}
} catch (error) {
console.error("Error loading proxy status:", error);
setCurrentMode("direct");
}
};
// 加载自定义代理配置
const loadCustomProxies = async () => {
try {
// 使用存储API获取所有代理配置
const configs = await getAllProxyConfigs();
// 处理代理配置
const proxies = configs
.filter(
(proxy: ProxyConfig) => !["direct", "system"].includes(proxy.id)
)
.map(
(proxy: ProxyConfig): CustomProxy => ({
key: proxy.id,
name: proxy.name,
color: "#1890ff",
config: proxy,
enabled: proxy.enabled,
})
);
setCustomProxies(proxies);
console.log("proxy_switch proxies", proxies);
// 查找并设置已启用的代理
const enabledProxy = configs.find((proxy: ProxyConfig) => proxy.enabled);
if (enabledProxy) {
console.log("proxy_switch enabledProxy", enabledProxy);
setCurrentMode(enabledProxy.id);
}
} catch (error) {
console.error("Error loading custom proxies:", error);
setCustomProxies([]);
}
};
// 处理代理模式变更
const handleModeChange = async (mode: string) => {
if (mode === "setting") {
// 打开设置页面
await browser.runtime.openOptionsPage?.();
return;
}
if (mode === "add") {
// 打开添加代理表单
try {
const [activeTab] = await browser.tabs.query({
active: true,
currentWindow: true,
});
const optionsUrl = browser.runtime.getURL("/options.html");
if (activeTab?.url === optionsUrl) {
browser.tabs.sendMessage(activeTab.id!, {
action: ContentActionType.TRIGGER_ADD_PROXY,
});
} else {
await browser.tabs.create({
url: optionsUrl,
});
}
} catch (error) {
console.error("Failed to get current tab:", error);
}
return;
}
console.log("proxy_switch 处理代理模式变更", mode);
// 如果当前已经是选中的模式,不做任何操作
if (mode === currentMode) return;
// 清除之前可能存在的加载超时
if (loadingTimeoutRef.current) {
clearTimeout(loadingTimeoutRef.current);
}
try {
// 先更新UI,让用户感知到变化
setCurrentMode(mode);
setIsLoading(true);
// 设置超时保护,确保加载状态最终会被清除
loadingTimeoutRef.current = window.setTimeout(() => {
setIsLoading(false);
}, 5000); // 5秒超时保护
// 发送切换代理请求
const response = await browser.runtime.sendMessage({
action: ProxyActionType.SWITCH_PROXY,
mode,
source: "proxy_switch",
});
// 请求完成后,清除超时保护
if (loadingTimeoutRef.current) {
clearTimeout(loadingTimeoutRef.current);
loadingTimeoutRef.current = null;
}
if (!response || !response.success) {
// 如果失败,恢复原状态
console.error("Failed to switch proxy mode");
await loadProxyStatus(); // 重新加载正确的状态
} else {
console.log("代理模式切换成功:", mode);
// 不加 setTimeout firefox UI 会无法渲染选中状态
setTimeout(() => {
setCurrentMode((preMode) => {
return mode === preMode ? preMode : mode;
});
}, 20);
}
} catch (error) {
console.error(`Error switching to proxy mode ${mode}:`, error);
await loadProxyStatus(); // 出错时重新加载正确的状态
} finally {
// 无论如何,最终要关闭加载状态
setIsLoading(false);
}
};
// 构建菜单项
const buildMenuItems = () => {
const items: MenuProps["items"] = [
...FIXED_MODES.map((mode) => ({
key: mode.key,
label: mode.name,
icon: mode.icon,
className: `${currentMode === mode.key ? "active-item" : ""} menu-id-${
mode.key
}`,
})),
{type: "divider"},
];
// 添加自定义代理
if (customProxies.length > 0) {
items.push(
...customProxies.map((proxy) => {
// 构建提示信息:显示代理协议、主机和端口
const tooltipText =
proxy.config.proxyType === "fixed_servers" &&
proxy.config.host &&
proxy.config.port
? `${proxy.config.scheme || "http"}://${proxy.config.host}:${
proxy.config.port
}`
: proxy.config.proxyType === "pac_script"
? "PAC脚本代理"
: proxy.config.proxyType === "auto_detect"
? "自动检测代理"
: "";
// Firefox 兼容性:确保 active-item 类始终应用正确
const isActive = currentMode === proxy.key;
return {
key: proxy.key,
label: proxy.name,
icon: (
<img
src={YAK_ICON_URL}
alt="YAK"
style={{
width: 20,
height: 20,
filter: isActive ? "brightness(0) invert(1)" : "none",
transition: "filter 0.2s ease-in-out",
}}
/>
),
className: `${isActive ? "active-item" : ""} menu-id-${proxy.key}`,
title: tooltipText, // 添加悬停提示
};
})
);
items.push({type: "divider"});
}
// 添加设置选项
items.push({
key: "setting",
label: "代理设置",
icon: <SettingOutlined/>,
className: "menu-id-setting",
});
// 添加新建代理选项
items.push({
key: "add",
label: "添加代理",
icon: <PlusOutlined/>,
className: "menu-id-add",
});
return items;
};
// 只在加载完成后渲染内容
if (!initialized) {
return <div className="proxy-switch-container loading">...</div>;
}
return (
<div className="proxy-switch-container">
<Menu
className="proxy-menu"
selectedKeys={[currentMode]}
defaultSelectedKeys={[currentMode]}
items={buildMenuItems()}
onClick={({key}) => handleModeChange(key)}
/>
{isLoading && (
<div className="loading-overlay">
...
</div>
)}
</div>
);
};
+21
View File
@@ -0,0 +1,21 @@
import { cn } from '@/lib/cn';
export function YakMark({ className, alt = 'Yak' }: { className?: string; alt?: string }) {
return <img className={cn('yak-mark', className)} src="/yak.svg" alt={alt} />;
}
export function YakitMark({ className }: { className?: string }) {
return <img className={cn('yakit-mark', className)} src="/icon/yakitlogo.png" alt="Yakit" />;
}
export function ProductBrand({ compact = false, className }: { compact?: boolean; className?: string }) {
return (
<div className={cn('product-brand', compact && 'product-brand--compact', className)}>
<span className="product-brand__art"><YakMark /></span>
<span className="product-brand__copy">
<strong>Yakit Browser Agent</strong>
{!compact && <small>Authenticated browser security workspace</small>}
</span>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
import type { HTMLAttributes } from 'react';
import { cn } from '@/lib/cn';
export function Badge({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
return <span className={cn('ui-badge', className)} {...props} />;
}
+31
View File
@@ -0,0 +1,31 @@
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import type { ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/cn';
const buttonVariants = cva('ui-button', {
variants: {
variant: {
primary: 'ui-button--primary',
secondary: 'ui-button--secondary',
ghost: 'ui-button--ghost',
danger: 'ui-button--danger',
},
size: {
sm: 'ui-button--sm',
md: 'ui-button--md',
icon: 'ui-button--icon',
},
},
defaultVariants: { variant: 'secondary', size: 'md' },
});
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export function Button({ className, variant, size, asChild, ...props }: ButtonProps) {
const Component = asChild ? Slot : 'button';
return <Component className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}
+16
View File
@@ -0,0 +1,16 @@
import type { LabelHTMLAttributes, ReactNode } from 'react';
import { cn } from '@/lib/cn';
export function Field({ label, hint, children, className, ...props }: {
label: string;
hint?: string;
children: ReactNode;
} & Omit<LabelHTMLAttributes<HTMLLabelElement>, 'children'>) {
return (
<label className={cn('ui-field', className)} {...props}>
<span className="ui-field__label">{label}</span>
{children}
{hint && <small className="ui-field__hint">{hint}</small>}
</label>
);
}
+11
View File
@@ -0,0 +1,11 @@
import * as SwitchPrimitive from '@radix-ui/react-switch';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/cn';
export function Switch({ className, ...props }: ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root className={cn('ui-switch', className)} {...props}>
<SwitchPrimitive.Thumb className="ui-switch__thumb" />
</SwitchPrimitive.Root>
);
}
+17
View File
@@ -0,0 +1,17 @@
import * as TabsPrimitive from '@radix-ui/react-tabs';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/cn';
export const Tabs = TabsPrimitive.Root;
export function TabsList({ className, ...props }: ComponentProps<typeof TabsPrimitive.List>) {
return <TabsPrimitive.List className={cn('ui-tabs-list', className)} {...props} />;
}
export function TabsTrigger({ className, ...props }: ComponentProps<typeof TabsPrimitive.Trigger>) {
return <TabsPrimitive.Trigger className={cn('ui-tabs-trigger', className)} {...props} />;
}
export function TabsContent({ className, ...props }: ComponentProps<typeof TabsPrimitive.Content>) {
return <TabsPrimitive.Content className={cn('ui-tabs-content', className)} {...props} />;
}
+23
View File
@@ -0,0 +1,23 @@
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import type { ComponentProps, ReactNode } from 'react';
import { cn } from '@/lib/cn';
export const TooltipProvider = TooltipPrimitive.Provider;
export function Tooltip({ label, children, side = 'top' }: {
label: string;
children: ReactNode;
side?: ComponentProps<typeof TooltipPrimitive.Content>['side'];
}) {
return (
<TooltipPrimitive.Root>
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content side={side} sideOffset={6} className={cn('ui-tooltip')}>
{label}
<TooltipPrimitive.Arrow className="ui-tooltip__arrow" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
);
}
+245
View File
@@ -0,0 +1,245 @@
import { browser } from 'wxt/browser';
import { installPageWorldBridge } from '@/features/page-context/content-bridge';
import { isStateStorageChange } from '@/protocol/storage';
import type { ActiveTabInfo, BridgeStatus, ExtensionState } from '@/types/models';
const PANEL_IDLE_UNLOAD_MS = 60_000;
const shellCss = `
:host { all: initial; position: fixed !important; inset: 0 !important; z-index: 2147483646 !important; pointer-events: none !important; }
.floating-panel { position: fixed; width: 46px; height: 46px; transform: translateY(-50%); pointer-events: auto; transition: width .16s ease; }
.floating-panel--left { left: 0; }
.floating-panel--right { right: 0; }
.floating-panel.is-expanded { width: min(326px, calc(100vw - 8px)); }
.floating-panel__header { position: absolute; top: 0; z-index: 2; width: 46px; height: 46px; touch-action: none; }
.floating-panel--left .floating-panel__header { left: 0; }
.floating-panel--right .floating-panel__header { right: 0; }
.floating-panel__brand { position: relative; width: 46px; height: 46px; padding: 0; display: grid; place-items: center; border: 1px solid #d7dce1; background: #fff; cursor: pointer; box-shadow: 0 8px 20px rgba(20,24,28,.18); transition: background-color .16s ease, box-shadow .16s ease; }
.floating-panel__brand:hover { background: #f1f3f5; }
:host([data-theme='dark']) .floating-panel__brand { border-color: #343a40; background: #1d232b; }
:host([data-theme='dark']) .floating-panel__brand:hover { background: #262d36; }
.floating-panel--left .floating-panel__brand { border-left: 0; border-radius: 0 23px 23px 0; }
.floating-panel--right .floating-panel__brand { border-right: 0; border-radius: 23px 0 0 23px; }
.floating-panel.is-expanded .floating-panel__brand { box-shadow: none; }
.floating-panel__brand:focus-visible { outline: 2px solid #ee7815; outline-offset: -3px; }
.floating-panel__brand img { width: 42px; height: 42px; display: block; object-fit: contain; pointer-events: none; }
.floating-panel__signal { position: absolute; right: 5px; bottom: 5px; width: 7px; height: 7px; border: 1px solid #fff; border-radius: 50%; background: #90979e; }
:host([data-theme='dark']) .floating-panel__signal { border-color: #1d232b; }
.floating-panel__signal.connected { background: #45b77d; }
.floating-panel__signal.connecting, .floating-panel__signal.negotiating { background: #e3a632; }
.floating-panel__signal.error { background: #dc5e5e; }
iframe { width: 100%; height: 320px; display: block; border: 0; border-radius: 8px; box-shadow: 0 10px 28px rgba(22,28,33,.18); }
`;
async function send<T>(action: string, payload?: unknown): Promise<T> {
const response = await browser.runtime.sendMessage({ action, payload }) as { ok?: boolean; data?: T; error?: string };
if (!response?.ok) throw new Error(response?.error || action);
return response.data as T;
}
export default defineContentScript({
matches: ['http://*/*', 'https://*/*'],
runAt: 'document_start',
async main(ctx) {
if ((import.meta.env.FIREFOX && import.meta.env.MODE !== 'store')
|| (!import.meta.env.FIREFOX && import.meta.env.MODE !== 'production' && import.meta.env.MODE !== 'store')) {
await installPageWorldBridge(ctx).catch((error) => {
console.warn('[Yakit Browser Agent] MAIN-world bridge is unavailable; continuing without page Eval/Invoke.', error);
});
}
const host = document.createElement('yakit-browser-agent');
const shadow = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = shellCss;
const panel = document.createElement('div');
panel.className = 'floating-panel floating-panel--right';
const header = document.createElement('div');
header.className = 'floating-panel__header';
const launcher = document.createElement('button');
launcher.type = 'button';
launcher.className = 'floating-panel__brand';
launcher.setAttribute('aria-label', '展开 Yakit Browser Agent');
const logo = document.createElement('img');
logo.src = browser.runtime.getURL('/yak.svg');
logo.alt = 'Yak';
logo.draggable = false;
const signal = document.createElement('span');
signal.className = 'floating-panel__signal disconnected';
launcher.append(logo, signal);
header.append(launcher);
panel.append(header);
shadow.append(style, panel);
document.documentElement.append(host);
// Launcher theme follows the extension appearance setting (settings.appearance.v1), falling back to the OS scheme.
const themeKey = 'settings.appearance.v1';
const applyTheme = (theme?: string) => {
host.dataset.theme = theme === 'light' || theme === 'dark'
? theme
: (globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
};
void browser.storage.local.get(themeKey).then((stored) => {
applyTheme((stored[themeKey] as { theme?: string } | undefined)?.theme);
});
let state: ExtensionState | undefined;
let currentTab: ActiveTabInfo | undefined;
let frame: HTMLIFrameElement | undefined;
let expanded = false;
let idleTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
let drag: { pointerId: number; startX: number; startY: number; moved: boolean } | undefined;
const setBridgeStatus = (status: BridgeStatus) => {
signal.className = `floating-panel__signal ${status.state}`;
};
const siteAllowed = (next: ExtensionState) => {
const origin = location.origin;
if (next.floatingPanel.siteMode === 'allowlist') return next.floatingPanel.siteOrigins.includes(origin);
if (next.floatingPanel.siteMode === 'denylist') return !next.floatingPanel.siteOrigins.includes(origin);
return true;
};
const adjustForEdgeConflict = () => {
if (host.style.display === 'none') return;
const x = state?.floatingPanel.side === 'left' ? 8 : innerWidth - 8;
const desiredY = (state?.floatingPanel.y || 0.46) * innerHeight;
const previous = host.style.visibility;
host.style.visibility = 'hidden';
const behind = document.elementFromPoint(x, desiredY);
host.style.visibility = previous;
if (!behind) return;
const position = getComputedStyle(behind).position;
const bounds = behind.getBoundingClientRect();
if (!['fixed', 'sticky'].includes(position) || bounds.width < 32 || bounds.height < 32) return;
const offset = desiredY < innerHeight / 2 ? bounds.bottom + 30 : bounds.top - 30;
panel.style.top = `${Math.min(Math.max(offset / innerHeight, 0.08), 0.92) * 100}%`;
};
const applyState = (next: ExtensionState) => {
const previousHandoffId = state?.handoff?.state === 'waiting_for_user' ? state.handoff.id : undefined;
state = next;
const taskTargetsPage = Boolean(
next.activeGrant && next.activeGrant.expiresAt > Date.now()
&& currentTab && next.activeGrant.targets.some((target) => target.tabId === currentTab!.id),
);
const hasPageHandoff = Boolean(
next.handoff?.state === 'waiting_for_user' && next.handoff.target.tabId === currentTab?.id,
);
const visible = next.floatingPanel.enabled && siteAllowed(next)
&& (next.floatingPanel.displayMode === 'always' || taskTargetsPage || hasPageHandoff);
host.style.display = visible ? '' : 'none';
panel.classList.toggle('floating-panel--left', next.floatingPanel.side === 'left');
panel.classList.toggle('floating-panel--right', next.floatingPanel.side === 'right');
panel.style.top = `${next.floatingPanel.y * 100}%`;
if (!visible) collapse();
const nextHandoff = next.handoff?.state === 'waiting_for_user' && next.handoff.target.tabId === currentTab?.id
? next.handoff
: undefined;
if (nextHandoff && nextHandoff.id !== previousHandoffId) expand();
requestAnimationFrame(adjustForEdgeConflict);
};
const ensureFrame = () => {
if (frame) return;
frame = document.createElement('iframe');
frame.title = 'Yakit Browser Agent';
frame.src = `${browser.runtime.getURL('/floating.html')}?tabId=${currentTab?.id || ''}`;
panel.prepend(frame);
};
const unloadFrame = () => {
frame?.remove();
frame = undefined;
};
function collapse() {
expanded = false;
panel.classList.remove('is-expanded');
launcher.setAttribute('aria-label', '展开 Yakit Browser Agent');
if (idleTimer) globalThis.clearTimeout(idleTimer);
idleTimer = globalThis.setTimeout(unloadFrame, PANEL_IDLE_UNLOAD_MS);
}
const expand = () => {
if (idleTimer) globalThis.clearTimeout(idleTimer);
ensureFrame();
expanded = true;
panel.classList.add('is-expanded');
launcher.setAttribute('aria-label', '收起 Yakit Browser Agent');
};
const [initialState, initialTab, initialBridge] = await Promise.all([
send<ExtensionState>('state.get'),
send<ActiveTabInfo>('tab.active').catch(() => undefined),
send<BridgeStatus>('bridge.status'),
]);
currentTab = initialTab;
applyState(initialState);
setBridgeStatus(initialBridge);
launcher.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return;
drag = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, moved: false };
launcher.setPointerCapture(event.pointerId);
});
launcher.addEventListener('pointermove', (event) => {
if (!drag || drag.pointerId !== event.pointerId) return;
if (Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) > 4) drag.moved = true;
if (!drag.moved) return;
const side = event.clientX < innerWidth / 2 ? 'left' : 'right';
const y = Math.min(Math.max(event.clientY / innerHeight, 0.08), 0.92);
panel.classList.toggle('floating-panel--left', side === 'left');
panel.classList.toggle('floating-panel--right', side === 'right');
panel.style.top = `${y * 100}%`;
});
launcher.addEventListener('pointerup', (event) => {
if (!drag || drag.pointerId !== event.pointerId) return;
const moved = drag.moved;
drag = undefined;
if (moved) {
const side = event.clientX < innerWidth / 2 ? 'left' : 'right';
const y = Math.min(Math.max(event.clientY / innerHeight, 0.08), 0.92);
void send<ExtensionState>('panel.update', { side, y }).then(applyState).catch(() => undefined);
} else if (expanded) collapse(); else expand();
});
const onStorageChange = (changes: Record<string, unknown>) => {
if (isStateStorageChange(changes)) void send<ExtensionState>('state.get').then(applyState).catch(() => undefined);
if (themeKey in changes) applyTheme((changes[themeKey] as { newValue?: { theme?: string } } | undefined)?.newValue?.theme);
};
const onRuntimeMessage = (message: unknown) => {
const input = message as { action?: string; payload?: BridgeStatus };
if (input?.action === 'bridge.status.changed' && input.payload) setBridgeStatus(input.payload);
};
const onFrameMessage = (event: MessageEvent) => {
const data = event.data as { channel?: string; type?: string; height?: number };
if (event.source !== frame?.contentWindow || data?.channel !== 'yakit-floating-host') return;
if (data.type === 'collapse') collapse();
if (data.type === 'resize' && typeof data.height === 'number' && frame) {
frame.style.height = `${Math.min(Math.max(Math.ceil(data.height), 160), Math.min(480, innerHeight - 16))}px`;
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (!state?.floatingPanel.shortcutEnabled || !event.altKey || !event.shiftKey || event.code !== 'KeyY') return;
if (host.style.display === 'none') return;
event.preventDefault();
if (expanded) collapse(); else expand();
};
const onFullscreenChange = () => {
if (state?.floatingPanel.autoCollapseFullscreen && document.fullscreenElement) collapse();
};
const onResize = () => requestAnimationFrame(adjustForEdgeConflict);
browser.storage.onChanged.addListener(onStorageChange);
browser.runtime.onMessage.addListener(onRuntimeMessage);
globalThis.addEventListener('message', onFrameMessage);
globalThis.addEventListener('keydown', onKeyDown, true);
document.addEventListener('fullscreenchange', onFullscreenChange);
globalThis.addEventListener('resize', onResize);
ctx.onInvalidated(() => {
if (idleTimer) globalThis.clearTimeout(idleTimer);
browser.storage.onChanged.removeListener(onStorageChange);
browser.runtime.onMessage.removeListener(onRuntimeMessage);
globalThis.removeEventListener('message', onFrameMessage);
globalThis.removeEventListener('keydown', onKeyDown, true);
document.removeEventListener('fullscreenchange', onFullscreenChange);
globalThis.removeEventListener('resize', onResize);
host.remove();
});
},
});
+115
View File
@@ -0,0 +1,115 @@
html, body { width: 100%; height: 100%; margin: 0; pointer-events: none; }
.floating-panel {
position: fixed;
z-index: 2147483646;
width: 46px;
transform: translateY(-50%);
color: var(--foreground);
font-family: var(--font-sans);
font-size: var(--text-md);
letter-spacing: 0;
filter: drop-shadow(0 9px 20px rgba(20, 24, 28, .2));
transition: width .18s ease;
pointer-events: auto;
}
.floating-panel--left { left: 0; }
.floating-panel--right { right: 0; }
.floating-panel.is-expanded { width: min(326px, calc(100vw - 8px)); }
/* Header: follows theme surface, brand tile keeps the dark logo chip */
.floating-panel__header {
height: 46px;
display: flex;
align-items: center;
overflow: hidden;
border: 1px solid var(--border-strong);
background: var(--surface);
color: var(--foreground);
user-select: none;
touch-action: none;
}
.floating-panel--left .floating-panel__header { border-left: 0; border-radius: 0 8px 8px 0; }
.floating-panel--right .floating-panel__header { flex-direction: row-reverse; border-right: 0; border-radius: 8px 0 0 8px; }
.floating-panel.is-expanded .floating-panel__header { border-radius: 8px 8px 0 0; }
.floating-panel__brand { position: relative; width: 44px; height: 44px; flex: 0 0 44px; padding: 0; display: grid; place-items: center; border: 0; background: transparent; }
.floating-panel__brand img { width: 42px; height: 42px; display: block; object-fit: contain; pointer-events: none; }
.floating-panel__signal { position: absolute; right: 5px; bottom: 5px; width: 7px; height: 7px; border: 1px solid var(--surface); border-radius: 50%; background: #90979e; }
.floating-panel__signal.connected { background: #45b77d; }
.floating-panel__signal.connecting { background: #e3a632; }
.floating-panel__signal.negotiating { background: #e3a632; }
.floating-panel__signal.error { background: #dc5e5e; }
.floating-panel__title { min-width: 0; flex: 1; display: grid; gap: 1px; padding: 0 10px; }
.floating-panel__title strong, .floating-panel__title span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.floating-panel__title strong { color: var(--foreground); font-size: var(--text-md); font-weight: 600; line-height: 17px; }
.floating-panel__title span { color: var(--muted); font-size: var(--text-sm); line-height: 15px; }
.floating-panel__grip { color: var(--muted); }
.floating-panel__header > svg:last-child { margin: 0 10px 0 4px; color: var(--muted); }
.floating-panel__body {
overflow: hidden;
border: 1px solid var(--border-strong);
border-top: 0;
border-radius: 0 0 var(--radius-md) var(--radius-md);
background: var(--surface);
box-shadow: var(--shadow-md);
}
.floating-tabs { width: auto; height: 34px; margin: 8px 10px 0; padding: 3px; display: grid; grid-template-columns: repeat(3, 1fr); border: 0; border-radius: 10px; background: var(--surface-subtle); }
.floating-tabs .ui-tabs-trigger { min-width: 0; height: 28px; display: flex; align-items: center; justify-content: center; gap: 5px; border-radius: 8px; font-size: var(--text-sm); }
.floating-tab-content { min-height: 208px; padding: 10px; display: grid; align-content: start; gap: 10px; }
.floating-section-heading { height: 28px; display: flex; align-items: center; justify-content: space-between; color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.floating-option-list { max-height: 224px; overflow-y: auto; display: grid; gap: 4px; scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
.floating-option-list::-webkit-scrollbar { width: 8px; }
.floating-option-list::-webkit-scrollbar-track { background: transparent; }
.floating-option-list::-webkit-scrollbar-thumb { border-radius: 4px; background: var(--border-strong); }
.floating-option-list > button { width: 100%; min-height: 46px; padding: 6px 10px; display: flex; align-items: center; gap: 9px; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--foreground); text-align: left; transition: background-color .13s ease; }
.floating-option-list > button:hover { background: var(--surface-subtle); }
.floating-option-list > button.is-active { background: var(--primary-soft); color: var(--primary-text); }
.floating-radio { width: 15px; height: 15px; flex: 0 0 auto; padding: 2.5px; border: 1.5px solid var(--border-strong); border-radius: 50%; background-clip: content-box; transition: border-color .13s ease; }
.floating-option-list > button.is-active .floating-radio { border-color: var(--primary); background-color: var(--primary); }
.floating-option-list strong, .floating-option-list small { display: block; }
.floating-option-list > button > span { min-width: 0; }
.floating-option-list strong { font-size: var(--text-md); font-weight: 600; line-height: 17px; }
.floating-option-list small { margin-top: 2px; color: var(--muted); font-size: var(--text-sm); line-height: 15px; }
.floating-page-meta { min-width: 0; padding: 9px 12px; display: grid; gap: 3px; border-radius: var(--radius-md); background: var(--surface-subtle); }
.floating-page-meta strong, .floating-page-meta span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.floating-page-meta strong { font-size: var(--text-md); font-weight: 600; }
.floating-page-meta span { color: var(--muted); font-size: var(--text-sm); }
.floating-result { min-height: 34px; padding: 4px 6px 4px 12px; display: flex; align-items: center; justify-content: space-between; gap: 8px; border-radius: var(--radius-md); background: var(--success-soft); color: var(--success); font-size: var(--text-sm); }
.floating-status-row { min-height: 54px; padding: 8px 10px; display: grid; grid-template-columns: 8px 1fr auto; gap: 9px; align-items: center; border-radius: var(--radius-md); background: var(--surface-subtle); }
.floating-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--border-strong); }
.floating-dot.connected { background: var(--success); }
.floating-dot.connecting { background: var(--warning); }
.floating-dot.negotiating { background: var(--warning); }
.floating-dot.error { background: var(--danger); }
.floating-status-row strong, .floating-status-row small { display: block; }
.floating-status-row strong { font-size: var(--text-md); font-weight: 600; }
.floating-status-row small { margin-top: 2px; color: var(--muted); font-size: var(--text-sm); }
.floating-agent-task { min-height: 46px; padding: 8px 8px 8px 12px; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; border-radius: var(--radius-md); background: var(--success-soft); }
.floating-agent-task.paused, .floating-agent-task.waiting_for_human { background: var(--warning-soft); }
.floating-agent-task strong, .floating-agent-task small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.floating-agent-task strong { color: var(--success); font-size: var(--text-md); font-weight: 600; }
.floating-agent-task.paused strong, .floating-agent-task.waiting_for_human strong { color: var(--warning); }
.floating-agent-task small { margin-top: 3px; color: var(--muted); font-size: var(--text-sm); }
.floating-share-row { min-height: 54px; padding: 8px 10px; display: flex; align-items: center; justify-content: space-between; gap: 10px; border-radius: var(--radius-md); background: var(--surface-subtle); }
.floating-share-row strong, .floating-share-row small { display: block; }
.floating-share-row strong { font-size: var(--text-md); font-weight: 600; }
.floating-share-row small { margin-top: 3px; color: var(--muted); font-size: var(--text-sm); }
.floating-handoff { min-height: 112px; padding: 12px; display: grid; align-content: space-between; gap: 12px; border: 1px solid color-mix(in srgb, var(--warning) 30%, var(--surface)); border-radius: var(--radius-md); background: var(--warning-soft); }
.floating-handoff__copy { min-width: 0; display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: 8px; align-items: start; }
.floating-handoff__copy > svg { margin-top: 1px; color: var(--warning); }
.floating-handoff__copy strong, .floating-handoff__copy small { display: block; }
.floating-handoff__copy strong { color: var(--warning); font-size: var(--text-md); font-weight: 600; line-height: 17px; }
.floating-handoff__copy small { margin-top: 4px; color: var(--muted); font-size: var(--text-sm); line-height: 16px; overflow-wrap: anywhere; }
.floating-handoff__actions { display: grid; grid-template-columns: 1fr auto; gap: 6px; }
.floating-notice { margin: 0 10px 10px; padding: 8px 12px; border-radius: var(--radius-md); background: var(--danger-soft); color: var(--danger); font-size: var(--text-sm); line-height: 1.45; }
.spin { animation: floating-spin .8s linear infinite; }
@keyframes floating-spin { to { transform: rotate(360deg); } }
+2 -69
View File
@@ -1,73 +1,6 @@
import { browser, type Browser } from 'wxt/browser';
import {ContentActionType, ProxyActionType} from '@/types/action';
import { getCurrentProxyMode, switchProxyMode } from '@/utils/proxy';
import { getProxyConfig, saveProxyConfig } from '@/utils/storage';
import type { ProxyConfig } from '@/types/proxy';
// 固定的代理模式配置
const FIXED_MODES = [
{
id: 'direct',
name: '[直接连接]',
proxyType: 'direct',
enabled: false
},
{
id: 'system',
name: '[系统代理]',
proxyType: 'system',
enabled: false
}
];
import { runBackground } from '@/app/background';
export default defineBackground({
type: 'module',
async main() {
// 初始化固定模式的代理配置
await initializeFixedModes();
// 初始化代理状态监听
browser.runtime.onMessage.addListener((message: any, sender: Browser.runtime.MessageSender, sendResponse: (response?: any) => void) => {
if (message.action === ProxyActionType.GET_PROXY_STATUS) {
// 获取当前代理状态
getCurrentProxyMode().then(mode => {
sendResponse({ success: true, data: { mode } });
});
return true;
} else if (message.action === ProxyActionType.SWITCH_PROXY) {
// 切换代理
switchProxyMode(message.mode).then(success => {
sendResponse({ success });
// 如果切换成功,广播代理状态更改消息
if (success) {
browser.runtime.sendMessage({
action: ContentActionType.PROXY_CONFIGS_UPDATED,
source: 'background'
});
}
});
return true;
}
});
console.log('代理管理后台服务已启动');
},
main: runBackground,
});
// 初始化固定模式的代理配置
async function initializeFixedModes() {
try {
// 确保固定模式的配置已保存到数据库
for (const modeConfig of FIXED_MODES) {
const existingConfig = await getProxyConfig(modeConfig.id);
if (!existingConfig) {
console.log(`初始化固定模式配置: ${modeConfig.id}`);
await saveProxyConfig(modeConfig as ProxyConfig);
}
}
} catch (error) {
console.error('初始化固定模式配置失败:', error);
}
}
-6
View File
@@ -1,6 +0,0 @@
export default defineContentScript({
matches: ['*://*.google.com/*'],
main() {
console.log('Hello content.');
},
});
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Yakit Browser Agent</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+46
View File
@@ -0,0 +1,46 @@
import { useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { browser } from 'wxt/browser';
import { TooltipProvider } from '@/components/ui/tooltip';
import { FloatingPanel } from '@/features/floating-panel/FloatingPanel';
import type { ActiveTabInfo, BridgeStatus, ExtensionState } from '@/types/models';
import { request } from '@/platform/messaging/runtime';
import { watchTheme } from '@/platform/storage/appearance';
import '@/styles/global.css';
import '../agent.content/style.css';
import './style.css';
watchTheme();
function FloatingApp() {
const [initial, setInitial] = useState<{ state: ExtensionState; tab?: ActiveTabInfo; bridge: BridgeStatus }>();
const [error, setError] = useState('');
useEffect(() => {
const tabId = Number(new URLSearchParams(location.search).get('tabId'));
void Promise.all([
request('state.get'),
Number.isSafeInteger(tabId) && tabId > 0
? request('tab.get', { tabId }).catch(() => undefined)
: Promise.resolve(undefined),
request('bridge.status'),
]).then(([state, tab, bridge]) => setInitial({ state, tab, bridge }))
.catch((reason) => setError(reason instanceof Error ? reason.message : String(reason)));
}, []);
if (error) return <div className="floating-frame-error">{error}</div>;
if (!initial) return <div className="floating-frame-loading"></div>;
return (
<FloatingPanel
initialState={initial.state}
initialTab={initial.tab}
initialBridge={initial.bridge}
yakIconUrl={browser.runtime.getURL('/yak.svg')}
embedded
/>
);
}
createRoot(document.getElementById('app')!).render(
<TooltipProvider delayDuration={350}><FloatingApp /></TooltipProvider>,
);
+8
View File
@@ -0,0 +1,8 @@
html, body, #app { width: 100%; height: 100%; min-width: 0; min-height: 0; margin: 0; overflow: hidden; }
body { background: transparent; }
.floating-panel.floating-panel--embedded { position: relative; inset: auto; width: 100%; height: 100%; transform: none; filter: none; }
.floating-panel--embedded .floating-panel__header { border-radius: 8px 8px 0 0; }
.floating-panel--embedded .floating-panel__body { max-height: calc(100% - 46px); overflow: auto; box-shadow: none; }
.floating-panel--embedded .floating-panel__brand { visibility: hidden; }
.floating-frame-loading, .floating-frame-error { height: 100%; display: grid; place-items: center; padding: 16px; box-sizing: border-box; color: var(--muted); background: var(--surface); font: 13px var(--font-sans); }
.floating-frame-error { color: var(--danger); }
+614 -71
View File
@@ -1,85 +1,628 @@
.options-layout {
min-height: 100vh;
}
/* Options 工作台 —— 基于 src/styles/tokens.css 令牌,暗色由 html[data-theme='dark'] 自动切换 */
.options-header {
background-color: #F28B44;
display: flex;
code, pre { font-family: var(--font-mono); }
pre { margin: 0; }
input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0; accent-color: var(--primary); }
/* ---------- 原生按钮(组件库之外的 <button>) ---------- */
.primary-button, .danger-button, .icon-button,
.page-heading > button:not(.ui-button),
.editor-actions > button:not(.ui-button),
.panel-title > button:not(.ui-button) {
min-height: 36px;
padding: 0 14px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 24px;
height: 64px;
gap: 7px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--foreground);
font-size: var(--text-md);
font-weight: 600;
line-height: 1;
white-space: nowrap;
cursor: pointer;
transition: background-color .15s ease, border-color .15s ease, color .15s ease, box-shadow .15s ease;
}
.page-heading > button:not(.ui-button):hover,
.editor-actions > button:not(.ui-button):hover,
.panel-title > button:not(.ui-button):hover { border-color: var(--muted); background: var(--surface-subtle); }
.primary-button { border-color: var(--primary-strong); background: var(--primary-strong); color: var(--primary-on-strong); }
.primary-button:hover { border-color: var(--primary-strong-hover); background: var(--primary-strong-hover); }
.danger-button { border-color: color-mix(in srgb, var(--danger) 35%, var(--surface)); color: var(--danger); }
.danger-button:hover { background: var(--danger-soft); }
.icon-button { width: 34px; height: 34px; min-height: 34px; padding: 0; }
.icon-button:hover { background: var(--surface-subtle); }
.icon-button.danger { color: var(--danger); }
.icon-button.danger:hover { background: var(--danger-soft); }
.primary-button:disabled, .danger-button:disabled, .icon-button:disabled,
.page-heading > button:not(.ui-button):disabled,
.editor-actions > button:not(.ui-button):disabled { border-color: var(--border); background: var(--surface-subtle); color: var(--muted); cursor: not-allowed; }
.primary-button:focus-visible, .danger-button:focus-visible, .icon-button:focus-visible,
.page-heading > button:not(.ui-button):focus-visible,
.editor-actions > button:not(.ui-button):focus-visible,
.panel-title > button:not(.ui-button):focus-visible,
.data-row:focus-visible, .network-row:focus-visible, .observation-row:focus-visible,
.task-workflow-list button:focus-visible, .context-node-list button:focus-visible,
.sidebar nav button:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--focus);
}
.options-content {
padding: 32px;
min-width: 600px;
margin: 0 auto;
background-color: #f5f5f5;
}
/* ---------- 布局骨架 ---------- */
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 238px minmax(0, 1fr); }
/* 所有单列纵向 grid 容器必须显式 minmax(0,1fr),否则子元素 max-content 会撑破窄屏 */
.content-area, .section-view, .settings-form, .list-pane, .editor-pane, .rule-editor,
.pairing-workspace, .panel-policy-settings, .grant-editor, .protocol-panel,
.observation-section, .network-inspector, .context-primary, .context-inspector,
.context-inspector > section, .context-diff, .context-inventory, .context-node-browser,
.context-mode, .context-json, .context-utility-panel, .tab-picker, .tab-picker-group, .data-list,
.task-workflow-list, .cookie-transfer, .network-artifact { grid-template-columns: minmax(0, 1fr); }
.workspace { min-width: 0; position: relative; }
.content-area { max-width: 1440px; margin: 0 auto; padding: 22px 28px 36px; display: grid; gap: 16px; }
.workspace-loading { min-height: 100vh; display: flex; align-items: center; justify-content: center; gap: 10px; color: var(--muted); font-size: var(--text-md); }
.spin { animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 50% { opacity: .35; } }
.proxy-list-card {
margin-bottom: 32px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
/* ---------- 侧栏(与全局表面一致,暗色主题随令牌切换) ---------- */
.sidebar { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--surface); color: var(--foreground); }
.sidebar-brand { height: 64px; padding: 0 14px; display: flex; align-items: center; border-bottom: 1px solid var(--border); }
.sidebar-brand .product-brand { width: 100%; color: var(--foreground); }
.sidebar nav { padding: 14px 10px; display: grid; gap: 2px; }
.sidebar nav button { width: 100%; height: 40px; padding: 0 10px; display: grid; grid-template-columns: 20px 1fr 14px; align-items: center; gap: 8px; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--muted-strong); font-size: var(--text-md); font-weight: 500; text-align: left; cursor: pointer; transition: background-color .14s ease, color .14s ease; }
.sidebar nav button:hover { background: var(--surface-subtle); color: var(--foreground); }
.sidebar nav button.active { background: var(--surface-subtle); color: var(--foreground); box-shadow: inset 3px 0 0 var(--primary); }
.sidebar nav button.active svg:first-child { color: var(--primary); }
.sidebar nav button > svg:last-child { opacity: 0; }
.sidebar nav button.active > svg:last-child { opacity: 1; }
.sidebar-theme { margin-top: auto; padding: 12px 14px; display: grid; gap: 7px; border-top: 1px solid var(--border); }
.sidebar-theme > span { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .05em; text-transform: uppercase; }
.sidebar-theme select { height: 34px; }
.sidebar-status { min-height: 64px; padding: 12px 14px; display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 10px; align-items: center; border-top: 1px solid var(--border); }
.sidebar-yakit-mark { position: relative; width: 28px; height: 28px; }
.sidebar-yakit-mark .yakit-mark { width: 28px; height: 28px; border-radius: 6px; }
.sidebar-status strong, .sidebar-status span { display: block; }
.sidebar-status strong { font-size: var(--text-sm); line-height: 17px; }
.sidebar-status div > span { margin-top: 2px; overflow: hidden; color: var(--muted); font-size: var(--text-xs); line-height: 14px; white-space: nowrap; text-overflow: ellipsis; }
.connection-dot { position: absolute; right: -2px; bottom: -2px; width: 10px; height: 10px; border: 2px solid var(--surface); border-radius: 50%; background: var(--muted); }
.connection-dot.connected { background: #45b981; }
.connection-dot.connecting, .connection-dot.negotiating { background: #e3a632; animation: pulse 1.3s infinite; }
.connection-dot.error { background: #e06e6e; }
.proxy-list-card .ant-card-head {
padding: 0 16px;
min-height: 48px;
}
/* ---------- 顶栏 ---------- */
.topbar { position: sticky; top: 0; z-index: 5; height: 60px; padding: 0 max(28px, (100% - 1440px) / 2 + 28px); display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--border); background: var(--background); }
.topbar-tab { min-width: 0; flex: 0 1 420px; height: 38px; padding: 0 6px 0 11px; display: flex; align-items: center; gap: 8px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); box-shadow: var(--shadow-sm); }
.topbar-tab__favicon { width: 16px; height: 16px; flex: 0 0 auto; display: grid; place-items: center; color: var(--muted); }
.topbar-tab__favicon img { width: 16px; height: 16px; object-fit: contain; }
.target-tab-select { min-width: 0; flex: 1; height: 36px; padding: 0 4px; border: 0; background: transparent; font-size: var(--text-md); }
.target-tab-select:focus-visible { box-shadow: none; }
.topbar-actions { display: flex; align-items: center; gap: 8px; }
.proxy-list-card .ant-card-head-title {
padding: 14px 0;
font-size: 16px;
}
.proxy-list-card .ant-card-head-wrapper {
display: flex;
/* ---------- 状态徽章 ---------- */
.permission-state, .large-status, .agent-runtime-state, .capture-state {
min-height: 30px;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 3px 12px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: var(--muted-strong);
font-size: var(--text-sm);
font-weight: 600;
white-space: nowrap;
}
.permission-state.enabled, .large-status.connected, .agent-runtime-state.running {
border-color: color-mix(in srgb, var(--success) 38%, var(--surface));
background: var(--success-soft);
color: var(--success);
}
.large-status.connecting, .large-status.negotiating, .agent-runtime-state.paused, .agent-runtime-state.waiting_for_human {
border-color: color-mix(in srgb, var(--warning) 42%, var(--surface));
background: var(--warning-soft);
color: var(--warning);
}
.large-status.error, .agent-runtime-state.revoked, .agent-runtime-state.expired {
border-color: color-mix(in srgb, var(--danger) 38%, var(--surface));
background: var(--danger-soft);
color: var(--danger);
}
.capture-state i { width: 7px; height: 7px; border-radius: 50%; background: var(--border-strong); }
.capture-state.active { border-color: color-mix(in srgb, var(--success) 38%, var(--surface)); background: var(--success-soft); color: var(--success); }
.capture-state.active i { background: var(--success); animation: pulse 1.4s infinite; }
/* ---------- 页面通用 ---------- */
.section-view { display: grid; gap: 16px; align-content: start; }
.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; }
.page-heading h1 { margin: 0; font-size: var(--text-2xl); font-weight: 700; line-height: 28px; }
.page-heading p { margin: 5px 0 0; color: var(--muted); font-size: var(--text-sm); line-height: 17px; }
.section-view h2 { margin: 0; font-size: var(--text-lg); font-weight: 650; }
.empty-state { min-height: 130px; padding: 20px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; border-radius: var(--radius-md); color: var(--muted); font-size: var(--text-md); text-align: center; }
.status-good { color: var(--success); font-weight: 600; }
.status-error { color: var(--danger); font-weight: 600; }
.status-muted { color: var(--muted); }
.active-label { padding: 2px 7px; border-radius: 999px; background: var(--primary-soft); color: var(--primary-text); font-size: var(--text-xs); font-weight: 600; white-space: nowrap; }
/* 代码/报文块 —— 浅色主题用浅灰嵌底,暗色主题用深面板 */
.network-packet, .invoke-result, .network-artifact pre, .context-json pre,
.proxy-tools pre, .observation-values pre, .observation-stack pre {
margin: 0;
padding: 12px 13px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-subtle);
color: var(--foreground);
font-size: var(--text-sm);
line-height: 1.55;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
[data-theme='dark'] .network-packet, [data-theme='dark'] .invoke-result, [data-theme='dark'] .network-artifact pre,
[data-theme='dark'] .context-json pre, [data-theme='dark'] .proxy-tools pre,
[data-theme='dark'] .observation-values pre, [data-theme='dark'] .observation-stack pre {
border-color: #262c33;
background: #12161b;
color: #d6dde4;
}
.proxy-list-card .ant-card-extra {
padding: 8px 0;
/* Toast */
.toast { position: fixed; right: 22px; bottom: 22px; z-index: 30; max-width: 420px; display: flex; align-items: center; gap: 8px; padding: 11px 15px; border-radius: var(--radius-md); box-shadow: var(--shadow-md); font-size: var(--text-md); font-weight: 500; }
.toast.ok { border: 1px solid color-mix(in srgb, var(--success) 38%, var(--surface)); background: var(--success-soft); color: var(--success); }
.toast.error { border: 1px solid color-mix(in srgb, var(--danger) 38%, var(--surface)); background: var(--danger-soft); color: var(--danger); }
/* 人工接管横幅 */
.handoff-banner { padding: 14px 18px; display: flex; align-items: center; gap: 13px; border-left: 3px solid var(--warning); border-radius: var(--radius-lg); background: var(--warning-soft); box-shadow: var(--shadow-sm); }
.handoff-banner > svg { flex: 0 0 auto; color: var(--warning); }
.handoff-banner__copy { min-width: 0; flex: 1; }
.handoff-banner__copy span, .handoff-banner__copy strong, .handoff-banner__copy small { display: block; }
.handoff-banner__copy span { color: var(--warning); font-size: var(--text-sm); font-weight: 650; }
.handoff-banner__copy strong { margin-top: 2px; font-size: var(--text-md); line-height: 18px; overflow-wrap: anywhere; }
.handoff-banner__copy small { margin-top: 3px; overflow: hidden; color: var(--muted); font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; }
.handoff-banner__actions { display: flex; gap: 8px; }
/* ---------- 运行概览 ---------- */
.task-command-bar { padding: 15px 18px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.task-site-identity { min-width: 0; display: flex; align-items: center; gap: 11px; }
.task-site-identity > svg { flex: 0 0 auto; color: var(--primary); }
.task-site-identity strong, .task-site-identity small { display: block; }
.task-site-identity strong { font-size: var(--text-md); font-weight: 650; line-height: 18px; }
.task-site-identity small { margin-top: 2px; color: var(--muted); font-size: var(--text-sm); }
.task-quick-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.task-status-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.task-status-grid section { min-width: 0; padding: 15px 16px 12px; display: grid; gap: 3px; align-content: start; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.task-status-grid section.needs-attention { box-shadow: inset 3px 0 0 var(--warning), var(--shadow-sm); }
.task-status-grid span { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.task-status-grid strong { margin-top: 4px; overflow: hidden; font-size: var(--text-lg); font-weight: 650; line-height: 19px; white-space: nowrap; text-overflow: ellipsis; }
.task-status-grid small { min-height: 32px; color: var(--muted); font-size: var(--text-sm); line-height: 16px; }
.task-status-grid button { margin: 8px -6px 0; padding: 4px 6px; display: flex; align-items: center; justify-content: space-between; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--primary-text); font-size: var(--text-sm); font-weight: 600; cursor: pointer; }
.task-status-grid button:hover { background: var(--primary-soft); }
.task-workflow-list { display: grid; gap: 10px; }
.task-workflow-list button { min-height: 62px; padding: 10px 16px; display: grid; grid-template-columns: 22px minmax(0, 1fr) 16px; gap: 13px; align-items: center; border: 0; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); color: var(--foreground); text-align: left; cursor: pointer; transition: background-color .14s ease; }
.task-workflow-list button:hover { background: var(--surface-subtle); }
.task-workflow-list button > svg:first-child { color: var(--muted-strong); }
.task-workflow-list button:hover > svg:first-child { color: var(--primary); }
.task-workflow-list button > svg:last-child { color: var(--muted); }
.task-workflow-list strong, .task-workflow-list small { display: block; }
.task-workflow-list strong { font-size: var(--text-md); font-weight: 650; line-height: 18px; }
.task-workflow-list small { margin-top: 2px; color: var(--muted); font-size: var(--text-sm); line-height: 16px; }
/* ---------- 操作记录 ---------- */
.activity-view .activity-heading-actions, .network-heading-actions { display: flex; align-items: center; gap: 8px; }
.agent-runtime-band { padding: 15px 18px; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.agent-runtime-summary { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; gap: 18px; align-items: center; }
.agent-runtime-summary span { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.agent-runtime-summary strong, .agent-runtime-summary small { display: block; }
.agent-runtime-summary strong { margin-top: 4px; overflow: hidden; font-size: var(--text-lg); font-weight: 650; white-space: nowrap; text-overflow: ellipsis; }
.agent-runtime-summary small { margin-top: 2px; color: var(--muted); font-size: var(--text-sm); }
.agent-runtime-controls { display: flex; gap: 8px; }
.agent-action-list { margin-top: 14px; display: grid; border-top: 1px solid var(--border); }
.agent-action-row { padding: 8px 2px; display: grid; grid-template-columns: 12px 84px minmax(160px, 1.4fr) minmax(80px, .6fr) 110px 76px; gap: 10px; align-items: center; border-bottom: 1px solid var(--border); font-size: var(--text-sm); }
.agent-action-row code { overflow: hidden; font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; }
.agent-action-row strong { font-size: var(--text-sm); }
.agent-action-row strong.success { color: var(--success); }
.agent-action-row strong.error { color: var(--danger); }
.agent-actions-empty { margin-top: 14px; padding: 14px 4px 2px; color: var(--muted); font-size: var(--text-sm); }
.action-state { width: 8px; height: 8px; border-radius: 50%; background: var(--border-strong); }
.action-state.success { background: var(--success); }
.action-state.error { background: var(--danger); }
.action-state.running { background: var(--primary); animation: pulse 1.2s infinite; }
.activity-subheading { margin-top: 6px; display: flex; align-items: flex-end; justify-content: space-between; gap: 14px; }
.activity-subheading p { margin: 4px 0 0; color: var(--muted); font-size: var(--text-sm); }
.activity-loading { min-height: 120px; display: flex; align-items: center; justify-content: center; gap: 8px; color: var(--muted); font-size: var(--text-md); }
.activity-loading.error { color: var(--danger); }
.activity-table { border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); overflow: hidden; }
.activity-table__head, .activity-table__row { padding: 0 16px; display: grid; grid-template-columns: 150px 86px minmax(150px, 1.1fr) minmax(150px, 1.2fr) 88px 72px; gap: 12px; align-items: center; }
.activity-table__head { min-height: 38px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.activity-table__row { min-height: 42px; border-bottom: 1px solid var(--border); font-size: var(--text-sm); }
.activity-table__row:last-child { border-bottom: 0; }
.activity-table__row > * { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.activity-table__row code { font-size: var(--text-sm); }
.audit-outcome.success { color: var(--success); font-weight: 600; }
.audit-outcome.error { color: var(--danger); font-weight: 600; }
/* ---------- 分栏编辑页(代理配置 / 代理规则 / UA / Cookie) ---------- */
.split-view { grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); gap: 16px; align-items: start; }
.split-view, .rule-layout { display: grid; }
.rule-layout { grid-template-columns: minmax(0, 1fr) minmax(320px, 380px); gap: 16px; align-items: start; }
.list-pane, .editor-pane { min-width: 0; display: grid; gap: 14px; align-content: start; }
.editor-pane { padding: 18px; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.editor-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.editor-heading p { margin: 4px 0 0; color: var(--muted); font-size: var(--text-sm); word-break: break-all; }
.data-list { display: grid; gap: 8px; }
.data-row { min-height: 58px; padding: 8px 12px; display: grid; grid-template-columns: 30px minmax(0, 1fr) auto auto 15px; gap: 10px; align-items: center; border: 0; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); color: var(--foreground); text-align: left; cursor: pointer; }
.data-row:hover { background: var(--surface-subtle); }
.data-row.selected { box-shadow: inset 3px 0 0 var(--primary), var(--shadow-sm); }
.data-row > svg:last-child { color: var(--muted); }
.data-row strong, .data-row small { display: block; }
.data-row strong { font-size: var(--text-md); font-weight: 600; }
.data-row small { margin-top: 2px; overflow: hidden; color: var(--muted); font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; }
.row-icon { width: 30px; height: 30px; display: grid; place-items: center; border-radius: var(--radius-md); background: var(--surface-subtle); color: var(--muted-strong); }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.form-grid .ui-field:has(textarea), .form-grid .check-row { grid-column: 1 / -1; }
.check-row { display: flex; align-items: center; gap: 8px; font-size: var(--text-md); }
.editor-actions { display: flex; gap: 8px; }
.rule-editor { min-width: 0; padding: 18px; display: grid; gap: 13px; align-content: start; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.rule-editor > h2 { margin-bottom: 2px; }
.rule-editor > p { margin: -4px 0 0; color: var(--muted); font-size: var(--text-sm); line-height: 1.5; }
/* 代理规则 */
.proxy-routing-bar { padding: 15px 18px; display: flex; flex-wrap: wrap; gap: 14px; align-items: flex-end; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.proxy-routing-bar .ui-field { width: 200px; }
.proxy-preview-input { min-width: 0; flex: 1; display: grid; gap: 6px; }
.proxy-preview-input > label { color: var(--muted-strong); font-size: var(--text-sm); font-weight: 600; }
.proxy-preview-input > div { display: flex; gap: 6px; align-items: center; }
.proxy-preview-result { min-width: 180px; padding: 9px 13px; display: grid; gap: 2px; border-radius: var(--radius-md); background: var(--surface-subtle); }
.proxy-preview-result.conflict { background: var(--warning-soft); }
.proxy-preview-result small { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.proxy-preview-result strong { font-size: var(--text-md); font-weight: 650; }
.proxy-preview-result span { color: var(--muted); font-size: var(--text-sm); }
.proxy-preview-result i { color: var(--warning); font-size: var(--text-sm); font-style: normal; font-weight: 600; }
.rule-table, .proxy-rule-table { border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); overflow: hidden; }
.table-head, .table-row { padding: 0 16px; display: grid; gap: 12px; align-items: center; }
.table-head { min-height: 38px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.table-row { min-height: 46px; border-bottom: 1px solid var(--border); font-size: var(--text-md); }
.table-row:last-child { border-bottom: 0; }
.table-row > * { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.table-row code { font-size: var(--text-sm); }
.rule-table .table-head, .rule-table .table-row { grid-template-columns: minmax(110px, 1fr) minmax(180px, 2fr) minmax(110px, 1fr) 64px 34px; }
.proxy-rule-table .table-head, .proxy-rule-table .table-row { grid-template-columns: 20px minmax(150px, 1.3fr) minmax(130px, 1fr) 100px 54px 62px 34px; }
.proxy-rule-table .table-row { cursor: grab; }
.proxy-rule-table .table-row > svg { color: var(--muted); }
.proxy-rule-name { padding: 0; display: block; overflow: hidden; border: 0; background: transparent; color: var(--foreground); text-align: left; cursor: pointer; }
.proxy-rule-name:hover strong { color: var(--primary-text); }
.proxy-rule-name strong, .proxy-rule-name small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.proxy-rule-name strong { font-size: var(--text-md); font-weight: 600; }
.proxy-rule-name small { margin-top: 2px; color: var(--muted); font-size: var(--text-sm); }
.proxy-tools { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.proxy-tools > section { min-width: 0; padding: 15px 16px; display: grid; gap: 11px; align-content: start; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.proxy-tools > section > div:first-child { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.proxy-tools pre { max-height: 220px; }
.proxy-tools textarea { min-height: 160px; font-family: var(--font-mono); font-size: var(--text-sm); }
.proxy-stats p { margin: 0; display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: var(--text-md); }
.proxy-stats > span { color: var(--muted); font-size: var(--text-sm); }
/* Cookie Editor */
.url-bar { display: flex; align-items: center; gap: 12px; }
.url-bar input { flex: 1; }
.url-bar > span { flex: 0 0 auto; color: var(--muted); font-size: var(--text-sm); }
.cookie-toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.cookie-toolbar select { width: auto; min-width: 108px; }
.cookie-toolbar .ui-button { margin-left: auto; }
.network-search { position: relative; min-width: 200px; flex: 1; }
.network-search > svg { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: var(--muted); pointer-events: none; }
.network-search input { padding-left: 31px; }
.cookie-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 380px); gap: 16px; align-items: start; }
.cookie-table { border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); overflow: hidden; }
.cookie-columns { padding: 0 14px; display: grid; grid-template-columns: 24px minmax(120px, 1fr) minmax(150px, 1.2fr) minmax(120px, .9fr) minmax(110px, .8fr) 34px; gap: 10px; align-items: center; }
.cookie-group__heading { padding: 8px 14px 5px; display: flex; align-items: baseline; gap: 8px; color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .03em; text-transform: uppercase; }
.cookie-group__heading span { font-weight: 500; text-transform: none; }
.cookie-name-button { padding: 0; overflow: hidden; border: 0; background: transparent; color: var(--foreground); text-align: left; cursor: pointer; }
.cookie-name-button strong { display: block; overflow: hidden; font-size: var(--text-md); font-weight: 600; white-space: nowrap; text-overflow: ellipsis; }
.cookie-name-button:hover strong { color: var(--primary-text); }
.cookie-value-button { min-width: 0; padding: 3px 6px; display: flex; align-items: center; gap: 6px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--muted-strong); cursor: pointer; }
.cookie-value-button:hover { background: var(--surface-subtle); }
.cookie-value-button code { overflow: hidden; font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; }
.cookie-value-button svg { flex: 0 0 auto; color: var(--muted); }
.cookie-columns > span > small { display: block; overflow: hidden; color: var(--muted); font-size: var(--text-sm); line-height: 15px; white-space: nowrap; text-overflow: ellipsis; }
.tag-list { display: flex; flex-wrap: wrap; gap: 4px; }
.tag-list i { padding: 1px 6px; border-radius: 999px; background: var(--surface-subtle); color: var(--muted-strong); font-size: var(--text-xs); font-style: normal; font-weight: 600; }
.cookie-editor-pane { position: sticky; top: 76px; }
.secret-field { position: relative; }
.secret-field .ui-button--icon { position: absolute; right: 6px; top: 6px; width: 28px; height: 28px; }
.secret-field.masked textarea { -webkit-text-security: disc; }
.cookie-transfer { display: grid; gap: 10px; }
.cookie-transfer .segmented { justify-self: start; }
.transfer-status { color: var(--muted); font-size: var(--text-sm); }
/* 分段选择器 */
.segmented { display: inline-flex; gap: 2px; padding: 3px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface-subtle); }
.segmented button { min-width: 72px; height: 30px; padding: 0 12px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); font-size: var(--text-sm); font-weight: 600; cursor: pointer; }
.segmented button.active { background: var(--surface); color: var(--foreground); box-shadow: var(--shadow-sm); }
.segmented button:disabled { opacity: .45; cursor: not-allowed; }
/* ---------- 网络活动 ---------- */
.network-control-bar { padding: 10px 18px; display: flex; flex-wrap: wrap; gap: 12px; align-items: center; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.network-control-bar > label { display: flex; align-items: center; gap: 10px; cursor: pointer; }
.network-control-bar > label > span { display: block; }
.network-control-bar strong { font-size: var(--text-md); font-weight: 600; line-height: 17px; }
.network-control-bar small { display: block; margin-top: 1px; color: var(--muted); font-size: var(--text-sm); }
.network-control-bar .network-search { flex: 1; min-width: 180px; }
.network-error { padding: 12px 16px; display: flex; align-items: center; gap: 9px; border-radius: var(--radius-lg); background: var(--danger-soft); color: var(--danger); font-size: var(--text-md); }
.network-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(360px, 440px); gap: 16px; align-items: start; }
.network-timeline { border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); overflow: hidden; }
.network-table-head { padding: 0 16px; min-height: 38px; display: grid; grid-template-columns: 62px 58px minmax(0, 1fr) 88px 72px; gap: 10px; align-items: center; border-bottom: 1px solid var(--border); color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.network-row { width: 100%; padding: 9px 16px; display: grid; grid-template-columns: 62px 58px minmax(0, 1fr) 88px 72px; gap: 10px; align-items: center; border: 0; border-bottom: 1px solid var(--border); background: transparent; color: var(--foreground); font-size: var(--text-sm); text-align: left; cursor: pointer; }
.network-row:last-child { border-bottom: 0; }
.network-row:hover { background: var(--surface-subtle); }
.network-row.selected { background: var(--primary-soft); box-shadow: inset 3px 0 0 var(--primary); }
.network-row > span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.method { font-size: var(--text-sm); font-weight: 700; }
.method-get { color: var(--success); }
.method-post { color: var(--primary-text); }
.method-put, .method-patch { color: var(--warning); }
.method-delete { color: var(--danger); }
.network-target strong, .network-target small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.network-target strong { font-size: var(--text-md); font-weight: 600; }
.network-target small { margin-top: 1px; color: var(--muted); font-size: var(--text-sm); }
.network-inspector { min-width: 0; padding: 16px; display: grid; gap: 14px; align-content: start; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); position: sticky; top: 76px; }
.network-inspector__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.network-inspector__heading > div { min-width: 0; }
.network-inspector__heading > div > span { color: var(--muted); font-size: var(--text-xs); font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
.network-inspector__heading strong, .network-inspector__heading small { display: block; overflow: hidden; text-overflow: ellipsis; }
.network-inspector__heading strong { margin-top: 3px; font-size: var(--text-lg); font-weight: 650; word-break: break-all; }
.network-inspector__heading small { margin-top: 3px; color: var(--muted); font-size: var(--text-sm); white-space: nowrap; }
.network-meta { margin: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; }
.network-meta > div { min-width: 0; }
.network-meta dt { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.network-meta dd { margin: 3px 0 0; overflow: hidden; font-size: var(--text-md); white-space: nowrap; text-overflow: ellipsis; }
.network-packet-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.network-packet-heading > strong { font-size: var(--text-md); font-weight: 650; }
.network-packet-heading > div { display: flex; gap: 6px; align-items: center; }
.network-packet { max-height: 320px; white-space: pre; }
.network-limitations { padding: 9px 12px; display: flex; gap: 8px; align-items: flex-start; border-radius: var(--radius-md); background: var(--warning-soft); color: var(--warning); font-size: var(--text-sm); line-height: 1.5; }
.network-preview-empty { padding: 18px 14px; display: flex; align-items: flex-start; gap: 9px; border-radius: var(--radius-md); background: var(--surface-subtle); color: var(--muted); font-size: var(--text-sm); line-height: 1.55; }
.network-preview-empty svg { flex: 0 0 auto; margin-top: 1px; }
.network-artifact { display: grid; gap: 8px; }
.network-artifact > div:first-child { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.network-artifact strong { font-size: var(--text-md); font-weight: 650; }
.network-artifact pre { max-height: 260px; }
/* 页面行为观测 */
.observation-section { padding: 16px 18px; display: grid; gap: 13px; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.observation-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 14px; }
.observation-heading span { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.observation-heading h2 { margin-top: 3px; }
.observation-controls { padding: 0; box-shadow: none; }
.observation-kinds { margin-left: auto; color: var(--muted); font-size: var(--text-sm); }
.observation-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 400px); gap: 16px; align-items: start; }
.observation-timeline { border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; }
.observation-table-head { padding: 0 13px; min-height: 34px; display: grid; grid-template-columns: 92px 96px minmax(0, 1fr) 64px 88px; gap: 10px; align-items: center; border-bottom: 1px solid var(--border); color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.observation-row { width: 100%; padding: 8px 13px; display: grid; grid-template-columns: 92px 96px minmax(0, 1fr) 64px 88px; gap: 10px; align-items: center; border: 0; border-bottom: 1px solid var(--border); background: transparent; color: var(--foreground); font-size: var(--text-sm); text-align: left; cursor: pointer; }
.observation-row:last-child { border-bottom: 0; }
.observation-row:hover { background: var(--surface-subtle); }
.observation-row.selected { background: var(--primary-soft); box-shadow: inset 3px 0 0 var(--primary); }
.observation-row > strong { overflow: hidden; font-size: var(--text-sm); font-weight: 650; white-space: nowrap; text-overflow: ellipsis; }
.observation-row > span, .observation-row > time { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.observation-target strong, .observation-target small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.observation-target strong { font-weight: 600; }
.observation-target small { margin-top: 1px; color: var(--muted); }
.observation-inspector { position: static; padding: 0; box-shadow: none; }
.observation-values, .observation-stack { display: grid; gap: 7px; }
.observation-values > strong, .observation-stack > strong { font-size: var(--text-sm); font-weight: 650; }
.observation-values pre, .observation-stack pre { max-height: 180px; }
/* ---------- 登录态工作区 ---------- */
.context-options { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
.context-options select { width: auto; min-width: 240px; }
.context-options > span { overflow: hidden; color: var(--muted); font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; }
.context-mode { display: grid; gap: 16px; }
.context-mode-tabs { justify-self: start; }
.context-empty { min-height: 300px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); color: var(--muted); }
.context-empty svg { color: var(--border-strong); }
.context-empty strong { color: var(--muted-strong); font-size: var(--text-lg); }
.context-empty span { font-size: var(--text-sm); }
.context-session-strip { padding: 6px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.context-session-strip > div { min-width: 0; padding: 10px 12px; display: grid; gap: 2px; align-content: start; border-radius: var(--radius-md); background: var(--surface-subtle); }
.context-session-strip small { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.context-session-strip strong { overflow: hidden; font-size: var(--text-lg); font-weight: 650; white-space: nowrap; text-overflow: ellipsis; }
.context-session-strip span { overflow: hidden; color: var(--muted); font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; }
.context-session-strip .auth-state { grid-template-columns: 20px minmax(0, 1fr) auto; align-items: center; gap: 8px; }
.context-session-strip .auth-state > span { min-width: 0; display: grid; gap: 2px; }
.context-session-strip .auth-state > svg { color: var(--muted); }
.context-session-strip .auth-state.authenticated > svg, .context-session-strip .auth-state.authenticated strong { color: var(--success); }
.context-session-strip .auth-state.unauthenticated strong { color: var(--danger); }
.context-session-strip .auth-state > i { color: var(--muted); font-size: var(--text-sm); font-style: normal; }
.context-workspace { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 380px); gap: 16px; align-items: start; }
.context-primary { min-width: 0; display: grid; gap: 16px; }
.context-diff, .context-inventory, .context-node-browser { padding: 16px 18px; display: grid; gap: 13px; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.context-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.context-section-heading span { color: var(--muted); font-size: var(--text-sm); }
.diff-state { padding: 3px 9px; border-radius: 999px; background: var(--surface-subtle); color: var(--muted-strong); font-size: var(--text-xs); font-weight: 650; }
.diff-state.changed, .diff-state.document_changed { background: var(--warning-soft); color: var(--warning); }
.diff-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
.diff-summary > span { padding: 10px 12px; display: grid; gap: 2px; border-radius: var(--radius-md); background: var(--surface-subtle); color: var(--muted); font-size: var(--text-sm); }
.diff-summary strong { color: var(--foreground); font-size: var(--text-xl); font-weight: 700; }
.diff-events { display: grid; gap: 5px; }
.diff-events span { display: flex; gap: 7px; align-items: baseline; font-size: var(--text-sm); }
.diff-events i { color: var(--success); font-style: normal; font-weight: 700; }
.diff-events .removed i { color: var(--danger); }
.diff-events .removed { color: var(--muted); text-decoration: line-through; }
.context-inventory-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.context-inventory-grid > div { min-width: 0; padding: 12px 13px; display: grid; gap: 8px; align-content: start; border-radius: var(--radius-md); background: var(--surface-subtle); }
.context-inventory-grid > div > strong { font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
.context-inventory-grid > div > span { font-size: var(--text-xl); font-weight: 700; }
.context-inventory-grid ul { margin: 0; padding: 0; display: grid; gap: 6px; list-style: none; }
.context-inventory-grid li { display: flex; align-items: center; gap: 7px; font-size: var(--text-sm); }
.context-inventory-grid li b { font-weight: 600; }
.context-inventory-grid li span, .context-inventory-grid li small { overflow: hidden; color: var(--muted); white-space: nowrap; text-overflow: ellipsis; }
.context-inventory-grid li i { width: 7px; height: 7px; flex: 0 0 auto; border-radius: 50%; background: var(--border-strong); }
.context-inventory-grid li i.ready, .context-inventory-grid li i.document { background: var(--success); }
.context-inventory-grid li i.history { background: var(--primary); }
.context-inventory-grid p { margin: 0; color: var(--muted); font-size: var(--text-sm); line-height: 1.5; }
.context-node-search { position: relative; width: 240px; }
.context-node-search > svg { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--muted); pointer-events: none; }
.context-node-search input { height: 32px; padding-left: 29px; font-size: var(--text-sm); }
.context-node-head { padding: 0 12px 6px; display: grid; grid-template-columns: minmax(0, 1.6fr) 92px 62px 64px; gap: 10px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.context-node-list { max-height: 320px; overflow-y: auto; display: grid; }
.context-node-list > button { width: 100%; padding: 8px 12px; display: grid; grid-template-columns: minmax(0, 1.6fr) 92px 62px 64px; gap: 10px; align-items: center; border: 0; border-bottom: 1px solid var(--border); background: transparent; color: var(--foreground); font-size: var(--text-sm); text-align: left; cursor: pointer; }
.context-node-list > button:last-child { border-bottom: 0; }
.context-node-list > button:hover { background: var(--surface-subtle); }
.context-node-list > button.active { background: var(--primary-soft); box-shadow: inset 3px 0 0 var(--primary); }
.context-node-list strong, .context-node-list small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.context-node-list strong { font-size: var(--text-md); font-weight: 600; }
.context-node-list small { margin-top: 1px; color: var(--muted); }
.context-node-list code { overflow: hidden; font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; }
.context-node-list i { padding: 1px 6px; border-radius: 999px; background: var(--surface-subtle); color: var(--muted); font-size: var(--text-xs); font-style: normal; font-weight: 600; text-align: center; }
.context-node-list i.ready { background: var(--success-soft); color: var(--success); }
.context-inspector { min-width: 0; display: grid; gap: 16px; position: sticky; top: 76px; }
.context-inspector > section { padding: 16px; display: grid; gap: 13px; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.context-inspector-empty { padding: 14px 12px; border-radius: var(--radius-md); background: var(--surface-subtle); color: var(--muted); font-size: var(--text-sm); line-height: 1.5; }
.context-node-error { padding: 9px 12px; display: flex; gap: 8px; align-items: flex-start; border-radius: var(--radius-md); background: var(--danger-soft); color: var(--danger); font-size: var(--text-sm); }
.node-identity { display: grid; gap: 3px; }
.node-identity code { color: var(--muted); font-size: var(--text-sm); }
.node-identity strong { font-size: var(--text-lg); font-weight: 650; overflow-wrap: anywhere; }
.node-identity span { color: var(--muted); font-size: var(--text-sm); overflow-wrap: anywhere; }
.node-properties { margin: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 10px 12px; }
.node-properties dt { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.node-properties dd { margin: 3px 0 0; overflow: hidden; font-size: var(--text-md); white-space: nowrap; text-overflow: ellipsis; }
.node-actions { display: flex; gap: 8px; }
.node-value-editor { display: flex; gap: 8px; align-items: flex-end; }
.node-value-editor .ui-field { flex: 1; }
.auth-evidence ul { margin: 0; padding-left: 18px; display: grid; gap: 6px; font-size: var(--text-sm); line-height: 1.5; }
.signal-names { display: grid; gap: 4px; font-size: var(--text-sm); }
.signal-names strong { font-weight: 650; }
.signal-names span { color: var(--muted); overflow-wrap: anywhere; }
.context-utility-panel { max-width: 760px; display: grid; gap: 13px; align-content: start; }
.context-utility-panel > p { margin: 0; color: var(--muted); font-size: var(--text-sm); }
.eval-mode { justify-self: start; }
.eval-warning { padding: 10px 13px; display: flex; gap: 9px; align-items: flex-start; border-radius: var(--radius-md); background: var(--primary-soft); color: var(--primary-text); font-size: var(--text-sm); line-height: 1.5; }
.eval-warning svg { flex: 0 0 auto; margin-top: 1px; }
.code-editor { font-family: var(--font-mono); font-size: var(--text-sm); }
.eval-result-meta { display: flex; flex-wrap: wrap; gap: 7px; }
.eval-result-meta span { padding: 3px 9px; border-radius: 999px; background: var(--surface-subtle); color: var(--muted-strong); font-size: var(--text-xs); font-weight: 600; }
.invoke-result { max-height: 320px; }
.context-json { display: grid; gap: 10px; }
.context-json pre { max-height: 560px; }
.panel-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.panel-title > span { font-size: var(--text-lg); font-weight: 650; }
/* ---------- 引擎连接 ---------- */
.managed-policy-banner { padding: 12px 16px; display: flex; gap: 10px; align-items: flex-start; border-radius: var(--radius-lg); background: var(--warning-soft); box-shadow: var(--shadow-sm); }
.managed-policy-banner > svg { flex: 0 0 auto; margin-top: 2px; color: var(--warning); }
.managed-policy-banner strong, .managed-policy-banner small { display: block; }
.managed-policy-banner strong { font-size: var(--text-md); font-weight: 650; }
.managed-policy-banner small { margin-top: 2px; color: var(--muted-strong); font-size: var(--text-sm); }
.managed-policy-banner i { display: block; margin-top: 3px; color: var(--warning); font-size: var(--text-sm); font-style: normal; }
.bridge-identity-strip { padding: 13px 18px; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 14px; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.bridge-identity-strip > div { min-width: 0; }
.bridge-identity-strip span { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.bridge-identity-strip code, .bridge-identity-strip strong { display: block; margin-top: 4px; overflow: hidden; font-size: var(--text-md); white-space: nowrap; text-overflow: ellipsis; }
.engine-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 400px); gap: 16px; align-items: start; }
.settings-form { min-width: 0; display: grid; gap: 16px; }
.pairing-workspace { padding: 18px; display: grid; gap: 15px; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.pairing-workspace.pending { box-shadow: inset 3px 0 0 var(--warning), var(--shadow-sm); }
.pairing-workspace.paired { box-shadow: inset 3px 0 0 var(--success), var(--shadow-sm); }
.pairing-workspace.error { box-shadow: inset 3px 0 0 var(--danger), var(--shadow-sm); }
.pairing-workspace__heading { display: flex; gap: 13px; align-items: flex-start; }
.pairing-icon { width: 40px; height: 40px; flex: 0 0 auto; display: grid; place-items: center; border-radius: var(--radius-md); background: var(--primary-soft); color: var(--primary-text); }
.pairing-workspace__heading p { margin: 4px 0 0; color: var(--muted); font-size: var(--text-sm); line-height: 1.5; }
/* 未配对 idle 态:居中 hero,配对是该页此时的主任务 */
.pairing-workspace.idle { padding: 30px 22px 22px; justify-items: center; text-align: center; }
.pairing-workspace.idle .pairing-workspace__heading { flex-direction: column; align-items: center; gap: 12px; }
.pairing-workspace.idle .pairing-icon { width: 52px; height: 52px; border-radius: var(--radius-lg); }
.pairing-workspace.idle .pairing-icon svg { width: 24px; height: 24px; }
.pairing-workspace.idle .editor-actions { justify-content: center; }
.pairing-code { padding: 16px; display: grid; gap: 4px; justify-items: center; border-radius: var(--radius-md); background: var(--surface-subtle); text-align: center; }
.pairing-code span { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .05em; text-transform: uppercase; }
.pairing-code strong { font-family: var(--font-mono); font-size: 30px; font-weight: 700; letter-spacing: .12em; }
.pairing-code small { color: var(--muted); font-size: var(--text-sm); }
.paired-engine-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.paired-engine-meta > div { min-width: 0; }
.paired-engine-meta span { color: var(--muted); font-size: var(--text-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.paired-engine-meta code { display: block; margin-top: 3px; overflow: hidden; font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; }
.advanced-connection { border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.advanced-connection > summary { padding: 15px 18px; font-size: var(--text-md); font-weight: 650; cursor: pointer; list-style-position: inside; }
.advanced-connection__body { padding: 2px 18px 16px; display: grid; gap: 13px; }
.toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 14px; cursor: pointer; }
.toggle-row > span { min-width: 0; }
.toggle-row strong { font-size: var(--text-md); font-weight: 600; }
.toggle-row small { display: block; margin-top: 2px; color: var(--muted); font-size: var(--text-sm); }
.panel-policy-settings { padding: 18px; display: grid; gap: 14px; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.panel-policy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.grant-editor { padding: 18px; display: grid; gap: 14px; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); }
.grant-editor > p { margin: -6px 0 0; color: var(--muted); font-size: var(--text-sm); line-height: 1.5; }
.tab-picker { display: grid; gap: 10px; }
.tab-picker-group { padding: 6px; display: grid; gap: 2px; border-radius: var(--radius-md); background: var(--surface-subtle); }
.tab-picker-group label { padding: 7px 9px; display: flex; align-items: flex-start; gap: 10px; border-radius: var(--radius-sm); cursor: pointer; }
.tab-picker-group label:hover { background: var(--surface); }
.tab-picker-group label > input { margin-top: 2px; }
.tab-picker-group label > span { min-width: 0; }
.tab-picker-group label strong, .tab-picker-group label small { display: block; }
.tab-picker-group label strong { font-size: var(--text-md); font-weight: 600; }
.tab-picker-group label small { margin-top: 1px; overflow: hidden; color: var(--muted); font-size: var(--text-sm); white-space: nowrap; text-overflow: ellipsis; }
.tab-picker-group .frame-target { margin-left: 25px; }
.grant-options { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.grant-risk-toggle { padding: 10px 13px; border-radius: var(--radius-md); background: var(--warning-soft); }
.grant-scope-list { display: flex; flex-wrap: wrap; gap: 6px; }
.grant-scope-list span { padding: 3px 9px; border-radius: 999px; background: var(--surface-subtle); color: var(--muted-strong); font-size: var(--text-xs); font-weight: 600; }
.grant-status { padding: 11px 14px; display: grid; gap: 2px; border-radius: var(--radius-md); background: var(--success-soft); }
.grant-status strong { color: var(--success); font-size: var(--text-md); font-weight: 650; }
.grant-status span { color: var(--muted-strong); font-size: var(--text-sm); }
.protocol-panel { padding: 18px; display: grid; gap: 4px; align-content: start; border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-sm); position: sticky; top: 76px; }
.protocol-panel h2 { margin-bottom: 10px; }
.protocol-panel > div { padding: 9px 0; display: grid; gap: 3px; border-bottom: 1px solid var(--border); }
.protocol-panel > div:last-child { border-bottom: 0; }
.protocol-panel code { color: var(--primary-text); font-size: var(--text-sm); font-weight: 600; }
.protocol-panel span { color: var(--muted); font-size: var(--text-sm); line-height: 1.5; }
/* ---------- 窄屏适配 ---------- */
@media (max-width: 1080px) {
.task-status-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.network-layout, .observation-layout, .context-workspace, .engine-layout, .rule-layout, .cookie-layout, .split-view { grid-template-columns: minmax(0, 1fr); }
.network-inspector, .context-inspector, .cookie-editor-pane, .protocol-panel { position: static; }
.proxy-tools { grid-template-columns: minmax(0, 1fr); }
.bridge-identity-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
.proxy-list-card .ant-card-body {
padding: 24px;
}
.proxy-list-card .ant-list-item {
padding: 16px 24px;
transition: all 0.3s;
}
.proxy-list-card .ant-list-item:hover {
background-color: rgba(242, 139, 68, 0.05);
}
.add-proxy-card {
margin-bottom: 32px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.add-proxy-card .ant-modal-footer {
display: flex;
justify-content: flex-end;
padding: 10px 24px;
border-top: 1px solid #f0f0f0;
}
.add-proxy-card .ant-modal-footer button {
margin-left: 8px;
}
.required-label::before {
content: '* ';
color: #ff4d4f;
}
.ant-form-item-label > label.ant-form-item-required:not(.ant-form-item-required-mark-optional)::before {
display: none !important;
}
.ant-space {
width: 100%;
@media (max-width: 720px) {
.app-shell { grid-template-columns: minmax(0, 1fr); }
.sidebar { position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--border); }
.sidebar-brand { height: 56px; }
.sidebar nav { grid-auto-flow: column; grid-auto-columns: max-content; overflow-x: auto; padding: 10px; }
.sidebar nav button { width: auto; grid-template-columns: 18px 1fr; }
.sidebar nav button > svg:last-child { display: none; }
.sidebar-theme { margin-top: 0; grid-auto-flow: column; align-items: center; justify-content: space-between; }
.sidebar-theme select { width: 150px; }
.sidebar-status { min-height: 54px; }
.topbar { padding: 0 16px; }
.content-area { padding: 16px; }
.page-heading { flex-direction: column; align-items: flex-start; }
.task-command-bar, .agent-runtime-summary { flex-direction: column; display: flex; align-items: stretch; }
.task-status-grid, .context-session-strip, .diff-summary, .context-inventory-grid, .grant-options, .panel-policy-grid, .form-grid, .paired-engine-meta { grid-template-columns: minmax(0, 1fr); }
.agent-action-row { grid-template-columns: 12px 76px minmax(0, 1fr) 76px; }
.agent-action-row span:nth-child(4), .agent-action-row span:last-child { display: none; }
.activity-table__head, .activity-table__row { grid-template-columns: 120px minmax(0, 1fr) 80px; }
.activity-table__head span:nth-child(2), .activity-table__head span:nth-child(4), .activity-table__head span:last-child,
.activity-table__row > span:nth-child(2), .activity-table__row > span:nth-child(4), .activity-table__row > span:last-child { display: none; }
.network-table-head, .network-row { grid-template-columns: 56px 50px minmax(0, 1fr) 66px; }
.network-table-head span:nth-child(4), .network-row > span:nth-child(4) { display: none; }
.observation-table-head, .observation-row { grid-template-columns: 76px minmax(0, 1fr) 80px; }
.observation-table-head span:nth-child(2), .observation-table-head span:nth-child(4),
.observation-row > span:nth-child(2), .observation-row > span:nth-child(4) { display: none; }
.cookie-columns { grid-template-columns: 24px minmax(0, 1fr) minmax(0, 1fr) 34px; }
.cookie-columns > span:nth-child(4), .cookie-columns > span:nth-child(5) { display: none; }
.cookie-toolbar select { min-width: 0; flex: 1; }
.context-node-head { display: none; }
.context-node-list > button { grid-template-columns: minmax(0, 1fr) 64px; }
.context-node-list code, .context-node-list i { display: none; }
.rule-table .table-head, .rule-table .table-row { grid-template-columns: minmax(0, 1fr) 34px; }
.rule-table .table-row > span, .rule-table .table-head > span { display: none; }
.proxy-rule-table .table-head, .proxy-rule-table .table-row { grid-template-columns: minmax(0, 1fr) 34px; }
.proxy-rule-table .table-row > span, .proxy-rule-table .table-row > svg, .proxy-rule-table .table-head > span { display: none; }
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Yaklang 代理管理设置</title>
<title>Yakit Browser Agent</title>
<meta name="manifest.open_in_tab" content="true" />
</head>
<body>
+3
View File
@@ -1,8 +1,11 @@
import React from 'react';
import {createRoot} from 'react-dom/client';
import App from './App';
import { watchTheme } from '@/platform/storage/appearance';
import '@/styles/global.css'
import './style.css';
watchTheme();
const root = createRoot(document.getElementById('app')!);
root.render(<App/>);
+1 -9
View File
@@ -1,9 +1 @@
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
}
* {
box-sizing: border-box;
}
html, body, #app { min-width: 320px; min-height: 100%; margin: 0; }
+141
View File
@@ -0,0 +1,141 @@
import {
PAGE_REQUEST_EVENT,
PAGE_RESPONSE_EVENT,
type PageBridgeRequest,
type PageBridgeResponse,
} from '@/features/page-context/protocol';
export default defineUnlistedScript(() => {
const script = document.currentScript;
if (!script || script.getAttribute('data-yakit-page-bridge-ready') === 'true') return;
script.setAttribute('data-yakit-page-bridge-ready', 'true');
const MAX_DEPTH = 6;
const MAX_ITEMS = 100;
const MAX_STRING = 100_000;
function serialize(value: unknown): { value: unknown; type: string; preview: string; truncated: boolean } {
const seen = new WeakSet<object>();
let truncated = false;
const visit = (input: unknown, depth: number): unknown => {
if (input === null) return null;
if (typeof input === 'string') {
if (input.length > MAX_STRING) truncated = true;
return input.slice(0, MAX_STRING);
}
if (typeof input === 'number' || typeof input === 'boolean') return input;
if (typeof input === 'undefined') return { $type: 'undefined' };
if (typeof input === 'bigint') return { $type: 'bigint', value: input.toString() };
if (typeof input === 'symbol') return { $type: 'symbol', value: String(input) };
if (typeof input === 'function') {
const source = Function.prototype.toString.call(input);
if (source.length > 2_000) truncated = true;
return { $type: 'function', name: input.name || '', source: source.slice(0, 2_000) };
}
if (depth >= MAX_DEPTH) {
truncated = true;
return { $type: 'max-depth', constructor: (input as object).constructor?.name || 'Object' };
}
if (seen.has(input as object)) return { $type: 'circular' };
seen.add(input as object);
if (input instanceof Error) {
return { $type: 'error', name: input.name, message: input.message, stack: input.stack?.slice(0, 10_000) };
}
if (input instanceof Date) return { $type: 'date', value: input.toISOString() };
if (input instanceof RegExp) return { $type: 'regexp', value: String(input) };
if (input instanceof Node) {
const element = input instanceof Element ? input : input.parentElement;
const html = element?.outerHTML || input.textContent || '';
if (html.length > 10_000) truncated = true;
return {
$type: 'node',
name: input.nodeName,
html: html.slice(0, 10_000),
};
}
if (Array.isArray(input)) {
if (input.length > MAX_ITEMS) truncated = true;
return input.slice(0, MAX_ITEMS).map((item) => visit(item, depth + 1));
}
const output: Record<string, unknown> = {};
const keys = Reflect.ownKeys(input as object).slice(0, MAX_ITEMS);
if (Reflect.ownKeys(input as object).length > MAX_ITEMS) truncated = true;
for (const key of keys) {
const name = typeof key === 'symbol' ? `[${String(key)}]` : key;
try {
output[name] = visit(Reflect.get(input as object, key), depth + 1);
} catch (error) {
output[name] = { $type: 'unreadable', message: error instanceof Error ? error.message : String(error) };
}
}
return output;
};
const normalized = visit(value, 0);
let preview: string;
try {
preview = typeof value === 'string' ? value : JSON.stringify(normalized);
} catch {
preview = String(value);
}
return {
value: normalized,
type: value === null ? 'null' : typeof value,
preview: preview.slice(0, 2_000),
truncated: truncated || preview.length > 2_000,
};
}
script.addEventListener(PAGE_REQUEST_EVENT, (rawEvent) => {
if (!(rawEvent instanceof CustomEvent) || typeof rawEvent.detail !== 'string') return;
void (async () => {
let request: PageBridgeRequest;
try {
request = JSON.parse(rawEvent.detail) as PageBridgeRequest;
} catch {
return;
}
const startedAt = performance.now();
let response: PageBridgeResponse;
try {
let rawResult: unknown;
if (request.operation === 'eval') {
const source = request.mode === 'expression'
? `(${request.code}\n)`
: `(async () => {\n${request.code}\n})()`;
rawResult = (0, eval)(source);
} else {
const segments = request.path.split('.').filter(Boolean);
let owner: unknown = window;
let target: unknown = window;
for (const segment of segments) {
owner = target;
target = Reflect.get(target as object, segment);
}
if (typeof target !== 'function') throw new TypeError(`${request.path} is not a function`);
rawResult = Reflect.apply(target, owner, request.args);
}
const result = serialize(await rawResult);
response = {
id: request.id,
ok: true,
result: { ...result, durationMs: Math.round((performance.now() - startedAt) * 100) / 100 },
};
} catch (error) {
response = {
id: request.id,
ok: false,
error: {
name: error instanceof Error ? error.name : 'Error',
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack?.slice(0, 10_000) : undefined,
},
};
}
script.dispatchEvent(new CustomEvent(PAGE_RESPONSE_EVENT, { detail: JSON.stringify(response) }));
})();
});
});
+453
View File
@@ -0,0 +1,453 @@
type ObservationKind = 'fetch' | 'xhr' | 'form' | 'websocket' | 'webcrypto' | 'cryptojs';
interface ObserverOptions {
captureValues: boolean;
maxEntries: number;
maxValueBytes: number;
expiresAt?: number;
}
interface ObserverRecord {
id: string;
sequence: number;
timestamp: number;
kind: ObservationKind;
operation: string;
url?: string;
method?: string;
algorithm?: string;
direction?: 'send' | 'receive';
socketId?: string;
byteLength?: number;
resultByteLength?: number;
dataType?: string;
stack?: string;
scriptUrl?: string;
sensitiveCaptured: boolean;
inputPreview?: string;
outputPreview?: string;
error?: string;
}
interface ObserverSnapshot {
version: 2;
active: boolean;
startedAt?: number;
count: number;
droppedCount: number;
options?: ObserverOptions;
records: ObserverRecord[];
}
interface ObserverController {
version: 2;
command(command: 'start' | 'status' | 'list' | 'clear' | 'stop', input?: Partial<ObserverOptions> & { limit?: number }): ObserverSnapshot;
}
interface LegacyObserverController {
version?: unknown;
command?: (command: 'stop', input?: Record<string, never>) => unknown;
}
type ObserverRecordInput = Omit<ObserverRecord, 'id' | 'sequence' | 'timestamp' | 'sensitiveCaptured'>;
export default defineUnlistedScript(() => {
const REGISTRY_KEY = '__YAKIT_PAGE_OBSERVER_V2__';
const LEGACY_REGISTRY_KEY = '__YAKIT_PAGE_OBSERVER_V1__';
const registry = window as unknown as Record<string, unknown>;
const existing = registry[REGISTRY_KEY] as ObserverController | undefined;
if (existing?.version === 2) return;
const legacy = registry[LEGACY_REGISTRY_KEY] as LegacyObserverController | undefined;
try {
if (legacy?.version === 1 && typeof legacy.command === 'function') legacy.command('stop');
} catch {
// A stale observer must not block the current controller from installing.
}
const encoder = new TextEncoder();
const restorers: Array<() => void> = [];
let cryptoJsTimer: number | undefined;
let expiryTimer: number | undefined;
let active = false;
let startedAt: number | undefined;
let observationSession = 0;
let sequence = 0;
let socketSequence = 0;
let droppedCount = 0;
let records: ObserverRecord[] = [];
let options: ObserverOptions = { captureValues: false, maxEntries: 100, maxValueBytes: 2_048 };
function dataType(value: unknown): string {
if (value === null) return 'null';
if (value === undefined) return 'undefined';
if (typeof value !== 'object') return typeof value;
return Object.prototype.toString.call(value).slice(8, -1);
}
function byteLength(value: unknown): number | undefined {
try {
if (typeof value === 'string') return encoder.encode(value).byteLength;
if (value instanceof Blob) return value.size;
if (value instanceof ArrayBuffer) return value.byteLength;
if (ArrayBuffer.isView(value)) return value.byteLength;
if (value instanceof URLSearchParams) return encoder.encode(value.toString()).byteLength;
if (typeof FormData !== 'undefined' && value instanceof FormData) {
let total = 0;
for (const [key, item] of value.entries()) total += encoder.encode(key).byteLength + (typeof item === 'string' ? encoder.encode(item).byteLength : item.size);
return total;
}
if (value && typeof value === 'object' && typeof (value as { sigBytes?: unknown }).sigBytes === 'number') {
return Math.max(0, (value as { sigBytes: number }).sigBytes);
}
if (value !== undefined) return encoder.encode(JSON.stringify(value)).byteLength;
} catch {
return undefined;
}
return undefined;
}
function preview(value: unknown): string | undefined {
if (!options.captureValues || value === undefined) return undefined;
try {
let output: string;
if (typeof value === 'string') output = value;
else if (value instanceof URLSearchParams) output = value.toString();
else if (value instanceof ArrayBuffer || ArrayBuffer.isView(value) || value instanceof Blob) output = `[binary ${byteLength(value) || 0} bytes]`;
else if (typeof FormData !== 'undefined' && value instanceof FormData) {
output = JSON.stringify([...value.entries()].map(([key, item]) => [key, typeof item === 'string' ? item : `[file ${item.size} bytes]`]));
} else if (value && typeof value === 'object' && typeof (value as { toString?: unknown }).toString === 'function') {
const cryptoText = (value as { toString(): string }).toString();
output = cryptoText === '[object Object]' ? JSON.stringify(value) : cryptoText;
} else output = String(value);
const bytes = encoder.encode(output);
if (bytes.byteLength <= options.maxValueBytes) return output;
return new TextDecoder().decode(bytes.slice(0, options.maxValueBytes));
} catch {
return `[${dataType(value)}]`;
}
}
function stackInfo(): { stack?: string; scriptUrl?: string } {
try {
const stack = new Error().stack?.split('\n').slice(2, 10).join('\n').slice(0, 4_096);
const scriptUrl = stack?.match(/https?:\/\/[^\s)]+/)?.[0]?.slice(0, 2_048);
return { stack, scriptUrl };
} catch {
return {};
}
}
function record(input: ObserverRecordInput): ObserverRecord | undefined {
if (!active) return undefined;
const nextSequence = sequence + 1;
const item: ObserverRecord = {
id: `observation-${startedAt || Date.now()}-${observationSession}-${nextSequence}`,
sequence: nextSequence,
timestamp: Date.now(),
sensitiveCaptured: options.captureValues,
...input,
};
sequence = nextSequence;
records.push(item);
while (records.length > options.maxEntries) {
records.shift();
droppedCount += 1;
}
return item;
}
function observe(factory: () => ObserverRecordInput): ObserverRecord | undefined {
if (!active) return undefined;
try {
return record(factory());
} catch {
droppedCount += 1;
return undefined;
}
}
function bestEffort(operation: () => void): void {
try {
operation();
} catch {
// Observation is diagnostic and must never change the target page's behavior.
}
}
function errorMessage(error: unknown): string {
try {
return (error instanceof Error ? error.message : String(error)).slice(0, 512);
} catch {
return 'Unknown error';
}
}
function algorithmSummary(value: unknown): string | undefined {
if (typeof value === 'string') return value.slice(0, 160);
if (!value || typeof value !== 'object') return undefined;
const algorithm = value as Record<string, unknown>;
const name = typeof algorithm.name === 'string' ? algorithm.name : 'unknown';
const parts = [name];
if (typeof algorithm.namedCurve === 'string') parts.push(`curve=${algorithm.namedCurve}`);
if (typeof algorithm.length === 'number') parts.push(`length=${algorithm.length}`);
if (typeof algorithm.tagLength === 'number') parts.push(`tag=${algorithm.tagLength}`);
const hash = algorithm.hash;
if (typeof hash === 'string') parts.push(`hash=${hash}`);
else if (hash && typeof hash === 'object' && typeof (hash as { name?: unknown }).name === 'string') parts.push(`hash=${(hash as { name: string }).name}`);
if (algorithm.iv !== undefined) parts.push(`ivBytes=${byteLength(algorithm.iv) || 0}`);
if (algorithm.salt !== undefined) parts.push(`saltBytes=${byteLength(algorithm.salt) || 0}`);
return parts.join(' ').slice(0, 240);
}
function patchFetch(): void {
const original = window.fetch;
if (typeof original !== 'function') return;
const wrapped: typeof window.fetch = function observedFetch(this: Window, input, init) {
observe(() => {
const request = typeof Request !== 'undefined' && input instanceof Request ? input : undefined;
const url = request?.url || String(input);
const method = init?.method || request?.method || 'GET';
const body = init?.body;
return { kind: 'fetch', operation: 'fetch', url: url.slice(0, 8_192), method: method.toUpperCase().slice(0, 32), byteLength: byteLength(body), dataType: dataType(body), inputPreview: preview(body), ...stackInfo() };
});
return Reflect.apply(original, this, [input, init]);
};
window.fetch = wrapped;
restorers.push(() => { if (window.fetch === wrapped) window.fetch = original; });
}
function patchXhr(): void {
if (typeof XMLHttpRequest === 'undefined') return;
const states = new WeakMap<XMLHttpRequest, { method: string; url: string; headerCount: number }>();
const prototype = XMLHttpRequest.prototype;
const originalOpen = prototype.open;
const originalSend = prototype.send;
const originalSetHeader = prototype.setRequestHeader;
const wrappedOpen = function observedOpen(this: XMLHttpRequest, method: string, url: string | URL, ...rest: unknown[]) {
bestEffort(() => {
states.set(this, { method: String(method).toUpperCase().slice(0, 32), url: String(url).slice(0, 8_192), headerCount: 0 });
});
return Reflect.apply(originalOpen, this, [method, url, ...rest] as Parameters<XMLHttpRequest['open']>);
} as typeof prototype.open;
const wrappedSetHeader = function observedSetRequestHeader(this: XMLHttpRequest, name: string, value: string) {
bestEffort(() => {
const state = states.get(this);
if (state) state.headerCount += 1;
});
return Reflect.apply(originalSetHeader, this, [name, value]);
};
const wrappedSend = function observedSend(this: XMLHttpRequest, body?: Document | XMLHttpRequestBodyInit | null) {
observe(() => {
const state = states.get(this);
return { kind: 'xhr', operation: 'send', url: state?.url, method: state?.method, byteLength: byteLength(body), dataType: dataType(body), inputPreview: preview(body), ...stackInfo() };
});
return Reflect.apply(originalSend, this, [body]);
};
const restore = () => {
if (prototype.open === wrappedOpen) prototype.open = originalOpen;
if (prototype.setRequestHeader === wrappedSetHeader) prototype.setRequestHeader = originalSetHeader;
if (prototype.send === wrappedSend) prototype.send = originalSend;
};
try {
prototype.open = wrappedOpen;
prototype.setRequestHeader = wrappedSetHeader;
prototype.send = wrappedSend;
} catch (error) {
bestEffort(restore);
throw error;
}
restorers.push(restore);
}
function patchForms(): void {
const onSubmit = (event: Event) => {
const form = event.target instanceof HTMLFormElement ? event.target : undefined;
if (!form) return;
observe(() => {
let body: FormData | undefined;
try { body = new FormData(form); } catch { /* Some custom forms cannot be serialized. */ }
return {
kind: 'form', operation: 'submit', url: form.action.slice(0, 8_192), method: form.method.toUpperCase().slice(0, 32),
byteLength: byteLength(body), dataType: 'FormData', inputPreview: preview(body), ...stackInfo(),
};
});
};
document.addEventListener('submit', onSubmit, true);
restorers.push(() => document.removeEventListener('submit', onSubmit, true));
}
function patchWebSocket(): void {
const Original = window.WebSocket;
if (typeof Original !== 'function') return;
const Wrapped = new Proxy(Original, {
construct(target, args) {
const socket = Reflect.construct(target, args) as WebSocket;
bestEffort(() => {
const socketId = `socket-${startedAt || Date.now()}-${observationSession}-${++socketSequence}`;
const socketUrl = String(args[0] || '').slice(0, 8_192);
observe(() => ({ kind: 'websocket', operation: 'construct', url: socketUrl, socketId, ...stackInfo() }));
const originalSend = socket.send;
const wrappedSend = function observedSend(this: WebSocket, data: string | ArrayBufferLike | Blob | ArrayBufferView) {
observe(() => ({ kind: 'websocket', operation: 'frame', direction: 'send', url: socketUrl, socketId, byteLength: byteLength(data), dataType: dataType(data), inputPreview: preview(data), ...stackInfo() }));
return Reflect.apply(originalSend, this, [data]);
};
const onOpen = () => observe(() => ({ kind: 'websocket', operation: 'open', url: socketUrl, socketId }));
const onMessage = (event: MessageEvent) => observe(() => ({ kind: 'websocket', operation: 'frame', direction: 'receive', url: socketUrl, socketId, byteLength: byteLength(event.data), dataType: dataType(event.data), outputPreview: preview(event.data) }));
const onClose = (event: CloseEvent) => observe(() => ({ kind: 'websocket', operation: 'close', url: socketUrl, socketId, error: event.wasClean ? undefined : `code=${event.code}` }));
const onError = () => observe(() => ({ kind: 'websocket', operation: 'error', url: socketUrl, socketId, error: 'WebSocket error' }));
restorers.push(() => {
if (socket.send === wrappedSend) socket.send = originalSend;
socket.removeEventListener('open', onOpen);
socket.removeEventListener('message', onMessage);
socket.removeEventListener('close', onClose);
socket.removeEventListener('error', onError);
});
socket.send = wrappedSend;
socket.addEventListener('open', onOpen);
socket.addEventListener('message', onMessage);
socket.addEventListener('close', onClose);
socket.addEventListener('error', onError);
});
return socket;
},
});
window.WebSocket = Wrapped;
restorers.push(() => { if (window.WebSocket === Wrapped) window.WebSocket = Original; });
}
function patchWebCrypto(): void {
const subtle = globalThis.crypto?.subtle;
if (!subtle) return;
const prototype = Object.getPrototypeOf(subtle) as Record<string, unknown>;
const operations = ['encrypt', 'decrypt', 'sign', 'verify', 'digest', 'deriveBits', 'deriveKey', 'generateKey', 'importKey', 'exportKey', 'wrapKey', 'unwrapKey'] as const;
for (const operation of operations) {
const original = prototype[operation];
if (typeof original !== 'function') continue;
const wrapped = function observedWebCrypto(this: SubtleCrypto, ...args: unknown[]) {
const item = observe(() => {
const input = args.find((value, index) => index > 0 && (typeof value === 'string' || value instanceof ArrayBuffer || ArrayBuffer.isView(value)));
return { kind: 'webcrypto', operation, algorithm: algorithmSummary(args[0]), byteLength: byteLength(input), dataType: dataType(input), inputPreview: preview(input), ...stackInfo() };
});
try {
const result = Reflect.apply(original, this, args) as Promise<unknown>;
void result.then((output) => {
if (item) {
item.resultByteLength = byteLength(output);
item.outputPreview = preview(output);
}
}, (error) => { if (item) item.error = errorMessage(error); });
return result;
} catch (error) {
if (item) item.error = errorMessage(error);
throw error;
}
};
prototype[operation] = wrapped;
restorers.push(() => { if (prototype[operation] === wrapped) prototype[operation] = original; });
}
}
const cryptoJsRestorers: Array<() => void> = [];
const cryptoJsWrappers = new WeakSet<Function>();
function patchCryptoJs(): void {
const cryptoJs = (window as unknown as { CryptoJS?: Record<string, unknown> }).CryptoJS;
if (!cryptoJs) return;
const paths = [
'AES.encrypt', 'AES.decrypt', 'DES.encrypt', 'DES.decrypt', 'TripleDES.encrypt', 'TripleDES.decrypt',
'RC4.encrypt', 'RC4.decrypt', 'Rabbit.encrypt', 'Rabbit.decrypt', 'MD5', 'SHA1', 'SHA224', 'SHA256',
'SHA384', 'SHA512', 'SHA3', 'RIPEMD160', 'HmacMD5', 'HmacSHA1', 'HmacSHA224', 'HmacSHA256',
'HmacSHA384', 'HmacSHA512', 'PBKDF2', 'EvpKDF',
];
for (const path of paths) {
const segments = path.split('.');
let owner: Record<string, unknown> = cryptoJs;
for (const segment of segments.slice(0, -1)) {
const next = owner[segment];
if (!next || typeof next !== 'object') { owner = {}; break; }
owner = next as Record<string, unknown>;
}
const key = segments.at(-1)!;
const original = owner[key];
if (typeof original !== 'function' || cryptoJsWrappers.has(original)) continue;
const wrapped = function observedCryptoJs(this: unknown, ...args: unknown[]) {
const item = observe(() => ({ kind: 'cryptojs', operation: path, algorithm: path.split('.')[0], byteLength: byteLength(args[0]), dataType: dataType(args[0]), inputPreview: preview(args[0]), ...stackInfo() }));
try {
const output = Reflect.apply(original, this, args);
if (item) {
item.resultByteLength = byteLength(output);
item.outputPreview = preview(output);
}
return output;
} catch (error) {
if (item) item.error = errorMessage(error);
throw error;
}
};
owner[key] = wrapped;
cryptoJsWrappers.add(wrapped);
const restore = () => { if (owner[key] === wrapped) owner[key] = original; };
cryptoJsRestorers.push(restore);
}
}
function stop(): void {
active = false;
if (expiryTimer !== undefined) window.clearTimeout(expiryTimer);
if (cryptoJsTimer !== undefined) window.clearInterval(cryptoJsTimer);
expiryTimer = undefined;
cryptoJsTimer = undefined;
while (cryptoJsRestorers.length) {
const restore = cryptoJsRestorers.pop();
if (restore) bestEffort(restore);
}
while (restorers.length) {
const restore = restorers.pop();
if (restore) bestEffort(restore);
}
}
function snapshot(limit = options.maxEntries): ObserverSnapshot {
return {
version: 2,
active,
startedAt,
count: records.length,
droppedCount,
options: startedAt ? { ...options } : undefined,
records: records.slice(-Math.max(0, Math.min(limit, options.maxEntries))),
};
}
const controller: ObserverController = {
version: 2,
command(command, input = {}) {
if (command === 'start') {
stop();
options = {
captureValues: input.captureValues === true,
maxEntries: Math.max(10, Math.min(Number(input.maxEntries) || 100, 200)),
maxValueBytes: Math.max(256, Math.min(Number(input.maxValueBytes) || 2_048, 8_192)),
expiresAt: typeof input.expiresAt === 'number' ? input.expiresAt : undefined,
};
records = [];
droppedCount = 0;
sequence = 0;
socketSequence = 0;
observationSession += 1;
startedAt = Date.now();
active = true;
for (const patch of [patchFetch, patchXhr, patchForms, patchWebSocket, patchWebCrypto, patchCryptoJs]) {
bestEffort(patch);
}
cryptoJsTimer = window.setInterval(() => bestEffort(patchCryptoJs), 1_000);
if (options.expiresAt) expiryTimer = window.setTimeout(stop, Math.max(0, options.expiresAt - Date.now()));
} else if (command === 'clear') {
records = [];
droppedCount = 0;
} else if (command === 'stop') stop();
return snapshot(typeof input.limit === 'number' ? input.limit : options.maxEntries);
},
};
Object.defineProperty(registry, REGISTRY_KEY, { value: controller, configurable: true, enumerable: false, writable: false });
});
+72 -83
View File
@@ -1,90 +1,79 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 0;
text-align: center;
}
.popup-shell { width: 390px; display: flex; flex-direction: column; background: var(--surface); }
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #54bc4ae0);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
/* Header */
.popup-header { padding: 12px 16px 10px; border-bottom: 1px solid var(--border); color: var(--foreground); }
.popup-brand-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.popup-brand-actions { display: flex; align-items: center; gap: 4px; }
.popup-brand-actions .ui-button { color: var(--muted-strong); }
.popup-brand-actions .ui-button:hover { background: var(--surface-subtle); color: var(--foreground); }
.popup-engine-pill { height: 26px; padding: 0 10px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface-subtle); color: var(--muted-strong); font-size: var(--text-sm); font-weight: 600; white-space: nowrap; cursor: pointer; transition: background-color .15s ease, border-color .15s ease; }
.popup-engine-pill:hover { background: var(--border); }
.popup-engine-pill:disabled { opacity: .55; cursor: not-allowed; }
.popup-engine-pill i { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
.popup-engine-pill.connected { border-color: color-mix(in srgb, var(--success) 40%, var(--surface)); background: var(--success-soft); color: var(--success); }
.popup-engine-pill.connected i { background: var(--success); }
.popup-engine-pill.connecting i, .popup-engine-pill.negotiating i { background: var(--warning); animation: pulse 1.3s infinite; }
.popup-engine-pill.error i { background: var(--danger); }
.popup-tab-line { min-width: 0; margin: 8px -6px 0; padding: 3px 6px; display: flex; align-items: center; gap: 7px; border-radius: 4px; color: var(--muted); font-size: var(--text-sm); line-height: 16px; }
.popup-tab-line:hover { background: var(--surface-subtle); }
.popup-tab-line > span:last-child { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.popup-favicon { width: 16px; height: 16px; flex: 0 0 auto; display: grid; place-items: center; }
.popup-favicon img { width: 16px; height: 16px; object-fit: contain; }
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 人工接管 —— 内嵌警告卡 */
.popup-handoff { margin: 10px 12px; display: grid; grid-template-columns: 20px minmax(0, 1fr) auto; gap: 10px; align-items: start; padding: 12px 14px 12px 13px; border-left: 3px solid var(--warning); border-radius: var(--radius-md); background: var(--warning-soft); }
.popup-handoff > svg { margin-top: 1px; color: var(--warning); }
.popup-handoff__copy { min-width: 0; }
.popup-handoff__copy strong, .popup-handoff__copy span, .popup-handoff__copy small { display: block; }
.popup-handoff__copy strong { font-size: var(--text-md); font-weight: 600; line-height: 18px; }
.popup-handoff__copy span { margin-top: 3px; font-size: var(--text-sm); line-height: 16px; overflow-wrap: anywhere; }
.popup-handoff__copy small { margin-top: 4px; overflow: hidden; color: var(--muted); font-size: var(--text-sm); line-height: 16px; white-space: nowrap; text-overflow: ellipsis; }
.popup-handoff__actions { display: flex; gap: 4px; align-items: center; }
.popup-handoff__actions .ui-button { white-space: nowrap; }
.popup-handoff__actions .ui-button--icon { width: 30px; height: 30px; }
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
/* 共享会话 */
.popup-share { padding: 12px 16px; display: flex; align-items: center; justify-content: space-between; gap: 14px; border-bottom: 1px solid var(--border); transition: background-color .16s ease; }
.popup-share.is-active { background: var(--success-soft); }
.popup-share-copy { min-width: 0; display: flex; align-items: flex-start; gap: 10px; }
.popup-share-copy > svg { width: 18px; height: 18px; margin-top: 1px; flex: 0 0 auto; color: var(--muted-strong); }
.popup-share.is-active .popup-share-copy > svg { color: var(--success); }
.popup-share-copy strong, .popup-share-copy span { display: block; }
.popup-share-copy strong { font-size: var(--text-md); font-weight: 600; line-height: 18px; }
.popup-share-copy span { margin-top: 2px; color: var(--muted); font-size: var(--text-sm); line-height: 16px; }
.popup-share.is-active .popup-share-copy span { color: var(--success); }
.card {
padding: 2em;
}
/* 代理快切 */
.popup-proxy { padding: 10px 12px 12px; border-bottom: 1px solid var(--border); }
.popup-section-label { min-height: 22px; margin-bottom: 7px; padding: 0 4px; display: flex; align-items: center; gap: 7px; color: var(--muted-strong); font-size: var(--text-sm); font-weight: 600; line-height: 16px; }
.popup-section-label .ui-badge { margin-left: auto; }
.popup-proxy-list { max-height: 172px; overflow-y: auto; display: grid; gap: 3px; scrollbar-width: thin; }
.popup-proxy-list > button { width: 100%; min-height: 40px; padding: 4px 10px; display: flex; align-items: center; gap: 10px; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--foreground); text-align: left; cursor: pointer; transition: background-color .13s ease; }
.popup-proxy-list > button:hover { background: var(--surface-subtle); }
.popup-proxy-list > button:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--focus); }
.popup-proxy-list > button.is-active { background: var(--primary-soft); }
.popup-proxy-list > button.is-active strong { color: var(--primary-text); }
.popup-radio { width: 15px; height: 15px; flex: 0 0 auto; padding: 2.5px; border: 1.5px solid var(--border-strong); border-radius: 50%; background-clip: content-box; transition: border-color .13s ease; }
.popup-proxy-list > button.is-active .popup-radio { border-color: var(--primary); background-color: var(--primary); }
.popup-proxy-list > button > span { min-width: 0; }
.popup-proxy-list strong, .popup-proxy-list small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.popup-proxy-list strong { font-size: var(--text-md); font-weight: 600; line-height: 17px; }
.popup-proxy-list small { margin-top: 1px; color: var(--muted); font-size: var(--text-sm); line-height: 15px; }
.read-the-docs {
color: #888;
}
/* 工具网格 */
.popup-tools { padding: 10px 12px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; border-bottom: 1px solid var(--border); }
.popup-tools button { padding: 9px 6px 8px; display: grid; justify-items: center; gap: 5px; border: 0; border-radius: var(--radius-md); background: var(--surface-subtle); color: var(--muted-strong); font-size: var(--text-sm); font-weight: 600; cursor: pointer; transition: color .13s ease, background-color .13s ease; }
.popup-tools button:hover { background: var(--border); color: var(--foreground); }
.popup-tools button:hover > svg { color: var(--primary); }
.popup-tools button:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--focus); }
.popup-tools button > svg { color: var(--muted-strong); }
.popup-container {
min-width: 190px;
display: flex;
flex-direction: column;
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
/* Footer CTA */
.popup-footer { margin-top: auto; padding: 10px 16px 12px; }
.popup-capture { width: 100%; height: 38px; font-size: var(--text-lg); }
.popup-notice { display: block; margin-top: 6px; overflow: hidden; color: var(--muted); font-size: var(--text-sm); line-height: 16px; text-align: center; white-space: nowrap; text-overflow: ellipsis; }
.popup-content {
flex: 1;
padding: 4px 8px;
background-color: #fff;
overflow: auto;
}
/* Ensure the proxy menu takes full width */
.popup-content .proxy-switch-container {
width: 100%;
}
.popup-content .proxy-switch-container .ant-menu {
width: 100%;
border-radius: 0;
}
/* Customize scrollbar */
.popup-content::-webkit-scrollbar {
width: 4px;
}
.popup-content::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 2px;
}
.popup-content::-webkit-scrollbar-thumb {
background: var(--yakit-primary);
border-radius: 2px;
}
.popup-content::-webkit-scrollbar-thumb:hover {
background: var(--yakit-primary-hover);
}
.popup-loading { width: 390px; height: 230px; display: flex; align-items: center; justify-content: center; gap: 9px; color: var(--muted); background: var(--background); font-size: var(--text-md); }
.spin { animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 50% { opacity: .35; } }
+198 -11
View File
@@ -1,14 +1,201 @@
import React from 'react';
import {ProxySwitch} from '@/components/ProxySwitch';
import '@/styles/global.css'
import { useCallback, useEffect, useState } from 'react';
import {
AlertTriangle, Braces, Check, Cookie, ExternalLink, Network, Radio, RefreshCw,
ShieldCheck, UserRoundCog, X,
} from 'lucide-react';
import { browser } from 'wxt/browser';
import { ProductBrand } from '@/components/brand/Brand';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipProvider } from '@/components/ui/tooltip';
import { HANDOFF_REASON_LABELS, waitingHandoff } from '@/features/handoff/presentation';
import { READ_CAPABILITY_SCOPES } from '@/protocol/capabilities';
import { isStateStorageChange } from '@/protocol/storage';
import type { ActiveTabInfo, BridgeStatus, ExtensionState, ProxyProfile } from '@/types/models';
import { errorMessage, request } from '@/platform/messaging/runtime';
import './App.css';
export default function App() {
return (
<div className="popup-container">
<main className="popup-content">
<ProxySwitch/>
</main>
</div>
);
const PROXY_KIND_LABELS: Record<ProxyProfile['kind'], string> = {
fixed_servers: '固定代理',
pac_script: 'PAC Script',
direct: '直连',
system: '系统代理',
};
function proxyDetail(profile: ProxyProfile): string {
return profile.kind === 'fixed_servers'
? `${profile.scheme}://${profile.host}:${profile.port}`
: PROXY_KIND_LABELS[profile.kind];
}
function App() {
const [state, setState] = useState<ExtensionState>();
const [tab, setTab] = useState<ActiveTabInfo>();
const [bridge, setBridge] = useState<BridgeStatus>({ state: 'disconnected', message: '未连接引擎' });
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState('');
const load = useCallback(async () => {
const [nextState, nextTab, nextBridge] = await Promise.all([
request('state.get'),
request('tab.active').catch(() => undefined),
request('bridge.status'),
]);
setState(nextState);
setTab(nextTab);
setBridge(nextBridge);
}, []);
useEffect(() => {
void load();
const listener = (message: { action?: string; payload?: BridgeStatus }) => {
if (message.action === 'bridge.status.changed' && message.payload) setBridge(message.payload);
};
browser.runtime.onMessage.addListener(listener);
const onStorageChange = (changes: Record<string, unknown>) => {
if (isStateStorageChange(changes)) void request('state.get').then(setState).catch(() => undefined);
};
browser.storage.onChanged.addListener(onStorageChange);
return () => {
browser.runtime.onMessage.removeListener(listener);
browser.storage.onChanged.removeListener(onStorageChange);
};
}, [load]);
const grantActive = Boolean(state?.activeGrant && state.activeGrant.expiresAt > Date.now() && tab && state.activeGrant.targets.some((target) => target.tabId === tab.id));
const handoff = waitingHandoff(state?.handoff);
const run = async (task: () => Promise<void>) => {
setBusy(true);
setNotice('');
try {
await task();
} catch (error) {
setNotice(errorMessage(error));
} finally {
setBusy(false);
}
};
const openTool = (tool: string) => {
const target = tab ? `?tabId=${tab.id}` : '';
return browser.tabs.create({ url: browser.runtime.getURL(`/options.html${target}#${tool}`) });
};
const toggleEngine = () => run(async () => {
if (!state!.bridge.pairedEngine) {
await request('bridge.pair');
await openTool('engine');
return;
}
if (bridge.state === 'connected') await request('bridge.disconnect'); else await request('bridge.connect');
setBridge(await request('bridge.status'));
});
const capture = () => run(async () => {
const context = await request('context.capture', {
includeDom: true,
includeStorage: true,
includeCookies: true,
tabId: tab?.id,
});
await navigator.clipboard.writeText(JSON.stringify(context, null, 2));
setNotice('页面上下文已复制');
});
if (!state) {
return <div className="popup-loading"><RefreshCw size={18} className="spin" /></div>;
}
const engineBusy = bridge.state === 'connecting' || bridge.state === 'negotiating';
return (
<TooltipProvider delayDuration={350}>
<main className="popup-shell">
<header className="popup-header">
<div className="popup-brand-row">
<ProductBrand compact />
<div className="popup-brand-actions">
<Tooltip label={bridge.state === 'connected' ? '断开引擎连接' : state.bridge.pairedEngine ? '连接引擎' : '配对本机 Yakit'}>
<button className={`popup-engine-pill ${bridge.state}`} disabled={busy} onClick={() => void toggleEngine()}>
<i />{bridge.state === 'connected' ? '引擎在线' : engineBusy ? '连接中' : state.bridge.pairedEngine ? '引擎离线' : '配对'}
</button>
</Tooltip>
<Tooltip label="打开完整工作台">
<Button size="icon" variant="ghost" aria-label="打开完整工作台" onClick={() => void openTool('overview')}>
<ExternalLink size={16} />
</Button>
</Tooltip>
</div>
</div>
<div className="popup-tab-line">
<span className="popup-favicon">{tab?.favIconUrl ? <img src={tab.favIconUrl} alt="" /> : <Radio size={12} />}</span>
<span title={tab?.url}>{tab?.title || '当前页面不可访问'}</span>
</div>
</header>
{handoff && <section className="popup-handoff" aria-live="assertive">
<AlertTriangle size={18} />
<div className="popup-handoff__copy">
<strong>{HANDOFF_REASON_LABELS[handoff.reason]}</strong>
<span>{handoff.message}</span>
<small title={handoff.target.title}>{handoff.target.title}</small>
</div>
<div className="popup-handoff__actions">
<Button size="sm" variant="primary" disabled={busy} onClick={() => void run(async () => setState(await request('handoff.resolve', { id: handoff.id, outcome: 'completed' })))}><Check size={14} /></Button>
<Button size="icon" variant="ghost" disabled={busy} aria-label="取消人工接管" title="取消人工接管" onClick={() => void run(async () => setState(await request('handoff.resolve', { id: handoff.id, outcome: 'cancelled' })))}><X size={15} /></Button>
</div>
</section>}
<section className={`popup-share ${grantActive ? 'is-active' : ''}`}>
<div className="popup-share-copy">
<ShieldCheck size={18} />
<div>
<strong></strong>
<span>{grantActive ? `只读会话 ${new Date(state.activeGrant!.expiresAt).toLocaleTimeString()} 到期` : '创建 30 分钟只读会话'}</span>
</div>
</div>
<Switch checked={grantActive} disabled={!tab || busy} aria-label="共享当前浏览器上下文" onCheckedChange={(checked) => void run(async () => {
const updated = checked
? await request('grant.create', { targets: [{ tabId: tab!.id, frameId: 0 }], scopes: READ_CAPABILITY_SCOPES, durationMinutes: 30 })
: await request('grant.revoke');
setState(updated);
})} />
</section>
<section className="popup-proxy">
<div className="popup-section-label"><Network size={14} /><span></span>{state.activeProxyId === 'rules' && <Badge></Badge>}</div>
<div className="popup-proxy-list" role="radiogroup" aria-label="代理出口">
{state.proxyProfiles.map((profile) => {
const active = state.activeProxyId === profile.id;
return <button key={profile.id} role="radio" aria-checked={active} className={active ? 'is-active' : ''} disabled={busy} onClick={() => void run(async () => setState(await request('proxy.switch', { id: profile.id })))}>
<i className="popup-radio" />
<span><strong>{profile.name}</strong><small>{proxyDetail(profile)}</small></span>
</button>;
})}
{state.proxyRules.length > 0 && <button role="radio" aria-checked={state.activeProxyId === 'rules'} className={state.activeProxyId === 'rules' ? 'is-active' : ''} disabled={busy} onClick={() => void run(async () => setState(await request('proxy.rules.apply')))}>
<i className="popup-radio" />
<span><strong></strong><small>{state.proxyRules.filter((rule) => rule.enabled).length} </small></span>
</button>}
</div>
</section>
{!handoff && <nav className="popup-tools" aria-label="安全测试工具">
<button onClick={() => void openTool('cookies')}><Cookie size={17} /><span>Cookie</span></button>
<button onClick={() => void openTool('user-agent')}><UserRoundCog size={17} /><span>User-Agent</span></button>
<button onClick={() => void openTool('context')}><Braces size={17} /><span></span></button>
</nav>}
<footer className="popup-footer">
<Button className="popup-capture" variant="primary" disabled={busy || !tab?.url?.startsWith('http')} onClick={() => void capture()}>
{busy ? <RefreshCw className="spin" size={15} /> : <Radio size={15} />}
</Button>
{notice && <span className="popup-notice">{notice}</span>}
</footer>
</main>
</TooltipProvider>
);
}
export default App;
+2 -2
View File
@@ -1,9 +1,9 @@
<!doctype html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Default Popup Title</title>
<title>Yakit Browser Agent</title>
<meta name="manifest.type" content="browser_action" />
</head>
<body>
+4
View File
@@ -1,8 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import { watchTheme } from '@/platform/storage/appearance';
import '@/styles/global.css';
import './style.css';
watchTheme();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
+2 -67
View File
@@ -1,67 +1,2 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
html, body, #root { margin: 0; min-width: 390px; }
body { overflow: hidden; }
-303
View File
@@ -1,303 +0,0 @@
/* Base styles for the proxy panel */
.yak-proxy-root * {
all: initial;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: normal;
margin: 0;
padding: 0;
border: none;
outline: none;
}
.floating-panel {
position: fixed;
top: 30%;
right: 0;
transform: translateY(-30%);
background: white;
z-index: 2147483647;
width: 50px;
height: 40px;
overflow: hidden;
transition: width 0.2s cubic-bezier(0.4, 0, 0.2, 1),
height 0.2s cubic-bezier(0.4, 0, 0.2, 1),
background-color 0.2s ease;
box-sizing: border-box;
}
/* Non-expanded state */
.floating-panel:not(.expanded):not(.dragging) {
border-radius: 50px 0 0 50px;
box-shadow: -4px 0 20px rgba(0,0,0,0.15);
border: 1px solid #eee;
border-right: none;
}
/* Dragging state */
.floating-panel.dragging {
cursor: grabbing;
user-select: none;
opacity: 0.95;
transition: none;
}
/* Hover state */
.floating-panel:not(.expanded):hover {
width: 120px;
background: #fff7e6;
border-color: #ffd591;
}
/* Expanded state */
.floating-panel.expanded {
width: 180px;
height: auto;
max-height: 400px;
border-radius: 8px 0 0 8px;
box-shadow: -2px 0 10px rgba(0,0,0,0.1);
border: 1px solid #eee;
border-right: none;
}
/* Panel header */
.panel-header {
height: 40px;
min-height: 40px;
display: flex;
align-items: center;
padding: 0 8px;
cursor: pointer;
user-select: none;
}
/* Header in expanded state */
.floating-panel.expanded .panel-header {
background: #f8f9fa;
border-bottom: 1px solid #eee;
}
.header-content {
display: flex;
align-items: center;
flex: 1;
overflow: hidden;
}
/* Yak icon */
.yak-icon {
width: 36px;
height: 36px;
min-width: 36px;
object-fit: contain;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1));
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.floating-panel.expanded .yak-icon {
width: 24px;
height: 24px;
min-width: 24px;
}
/* Active proxy info */
.active-proxy-info {
display: flex;
align-items: center;
margin-left: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #ff6b00;
font-size: 13px;
font-weight: 500;
}
.active-proxy-info span:first-child {
margin-right: 6px;
}
.active-proxy-info span:nth-child(2) {
color: #333;
}
/* Panel content */
.panel-content {
display: none;
background: white;
overflow-y: auto;
max-height: 360px;
opacity: 0;
transition: opacity 0.2s ease;
}
.floating-panel.expanded .panel-content {
display: block;
opacity: 1;
}
/* Scrollbar styles */
.panel-content::-webkit-scrollbar {
width: 4px;
}
.panel-content::-webkit-scrollbar-track {
background: #f5f5f5;
}
.panel-content::-webkit-scrollbar-thumb {
background: #ddd;
border-radius: 4px;
}
.panel-content::-webkit-scrollbar-thumb:hover {
background: #ccc;
}
/* Proxy item */
.proxy-item {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
position: relative;
}
.proxy-item:hover {
background: #fff7e6;
}
.proxy-item.active {
background: #fff7e6;
color: #ff6b00;
}
.proxy-item.active span {
color: #ff6b00;
}
.proxy-item span:first-child {
margin-right: 8px;
font-size: 16px;
}
.proxy-item span {
color: #333;
}
.proxy-status {
position: absolute;
right: 12px;
width: 6px;
height: 6px;
border-radius: 50%;
background: #52c41a;
box-shadow: 0 0 4px rgba(82,196,26,0.3);
}
.proxy-item.active .proxy-status {
background: #ff6b00;
box-shadow: 0 0 4px rgba(255,107,0,0.3);
}
/* Divider */
.divider {
height: 1px;
background: #f0f0f0;
margin: 4px 0;
}
/* Action buttons */
.action-button {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
color: #666;
}
.action-button:hover {
background: #fff7e6;
color: #ff6b00;
}
.action-button:hover span {
color: #ff6b00;
}
.action-button span:first-child {
margin-right: 8px;
}
.action-button span {
color: #666;
}
/* Tab container */
.tabs-container {
display: flex;
height: 100%;
min-height: 200px;
}
.tab-list {
width: 40px;
background: #f8f9fa;
border-right: 1px solid #eee;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 8px;
position: sticky;
top: 0;
align-self: flex-start;
height: 100%;
flex-shrink: 0;
}
.tab-button {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 4px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
background: transparent;
border: none;
padding: 0;
}
.tab-button:hover {
background: #fff7e6;
}
.tab-button.active {
background: #fff7e6;
color: #ff6b00;
}
.tab-content {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
height: 100%;
}
.tab-panel {
display: none;
height: 100%;
overflow: hidden;
flex-direction: column;
}
.tab-panel.active {
display: flex;
}
-356
View File
@@ -1,356 +0,0 @@
import React, {useState, useEffect, useRef} from 'react';
import {browser} from 'wxt/browser';
import type {ProxyConfig} from '@/types/proxy.ts';
// Constants - using string literal instead of getURL since it will be replaced at build time
const YAK_ICON_URL = browser.runtime.getURL("/yak.svg");
// Action types from the application
const ProxyActionType = {
SET_PROXY_CONFIG: "SET_PROXY_CONFIG",
CLEAR_PROXY_CONFIG: "CLEAR_PROXY_CONFIG",
GET_PROXY_STATUS: "GET_PROXY_STATUS",
GET_PROXY_CONFIGS: "GET_PROXY_CONFIGS",
UPDATE_PROXY_CONFIG: "UPDATE_PROXY_CONFIG",
};
// Export anonymous component directly as default export
const App: React.FC = () => {
// State
const [expanded, setExpanded] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [activeTab, setActiveTab] = useState('proxy');
const [proxyStatus, setProxyStatus] = useState({
enable: false,
proxy: '',
currentMode: 'direct'
});
const [proxyConfigs, setProxyConfigs] = useState<ProxyConfig[]>([]);
// Refs
const panelRef = useRef<HTMLDivElement>(null);
const dragStartRef = useRef({y: 0, top: 0});
const timeoutRef = useRef<number | null>(null);
// Setup message listener for updates
useEffect(() => {
const messageListener = async (message: any) => {
if (message.action === "PROXY_STATUS_CHANGED" || message.action === "PROXY_CONFIGS_UPDATED") {
await fetchProxyStatus();
await fetchProxyConfigs();
}
};
browser.runtime.onMessage.addListener(messageListener);
// Initial data fetch
fetchProxyStatus();
fetchProxyConfigs();
// Position from localStorage if available
const savedPosition = localStorage.getItem("yakitProxyPanelPosition");
if (savedPosition && panelRef.current) {
const top = (parseFloat(savedPosition) / 100) * window.innerHeight;
panelRef.current.style.top = `${top}px`;
panelRef.current.style.transform = 'translateY(0)';
}
return () => {
browser.runtime.onMessage.removeListener(messageListener);
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
}
};
}, []);
// Fetch current proxy status
const fetchProxyStatus = async () => {
try {
const response = await sendMessageWithRetry({
action: ProxyActionType.GET_PROXY_STATUS,
});
if (response && response.success) {
const status = response.data;
setProxyStatus({
enable: status.enabled,
proxy: status.mode === "system" ? "system" : "",
currentMode: status.mode || "direct",
});
}
} catch (error) {
console.error("Error fetching proxy status:", error);
}
};
// Fetch proxy configurations
const fetchProxyConfigs = async () => {
try {
const response = await sendMessageWithRetry({
action: ProxyActionType.GET_PROXY_CONFIGS,
});
if (response && response.success) {
setProxyConfigs(response.data || []);
}
} catch (error) {
console.error("Error fetching proxy configs:", error);
}
};
// Send message with retry logic
const sendMessageWithRetry = async (message: any, maxRetries = 3) => {
for (let i = 0; i < maxRetries; i++) {
try {
return await browser.runtime.sendMessage(message);
} catch (error) {
console.warn(`Attempt ${i + 1} failed:`, error);
if (i === maxRetries - 1) {
throw error;
}
await new Promise(resolve => setTimeout(resolve, 500));
}
}
};
// Handle switching to a different proxy
const handleProxySwitch = async (config: ProxyConfig) => {
try {
await sendMessageWithRetry({
action: ProxyActionType.SET_PROXY_CONFIG,
config,
});
// Update the UI
await fetchProxyStatus();
} catch (error) {
console.error("Error switching proxy:", error);
}
};
// Open options page
const openOptionsPage = async (triggerAdd = false) => {
try {
await sendMessageWithRetry({
action: "OPEN_OPTIONS_PAGE",
triggerAdd,
});
} catch (error) {
console.error("Error opening options page:", error);
}
};
// Handle dragging functionality
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (expanded) {
setExpanded(false);
return;
}
if (e.button !== 0) return; // Only left mouse button
setIsDragging(true);
const rect = panelRef.current?.getBoundingClientRect();
if (rect) {
dragStartRef.current = {
y: e.clientY,
top: rect.top,
};
}
e.preventDefault();
};
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!isDragging) return;
const deltaY = e.clientY - dragStartRef.current.y;
const newTop = dragStartRef.current.top + deltaY;
// Limit drag range to viewport
const maxTop = window.innerHeight - (panelRef.current?.offsetHeight || 0);
const boundedTop = Math.max(0, Math.min(newTop, maxTop));
if (panelRef.current) {
panelRef.current.style.top = `${boundedTop}px`;
panelRef.current.style.transform = 'translateY(0)';
}
};
const handleMouseUp = () => {
if (!isDragging) return;
setIsDragging(false);
// Save position
if (panelRef.current) {
const top = panelRef.current.getBoundingClientRect().top;
const percentage = (top / window.innerHeight) * 100;
localStorage.setItem("yakitProxyPanelPosition", percentage.toString());
}
};
// Handle mouse enter to clear any auto-collapse timeouts
const handleMouseEnter = () => {
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
// Handle mouse leave to auto-collapse the panel
const handleMouseLeave = () => {
if (expanded) {
timeoutRef.current = window.setTimeout(() => {
setExpanded(false);
timeoutRef.current = null;
}, 300);
}
};
// Get active proxy name and icon
let proxyIcon = "🟢";
let proxyName = "直接连接";
if (proxyStatus.currentMode === "system") {
proxyIcon = "⚙️";
proxyName = "系统代理";
} else if (proxyStatus.currentMode === "fixed_servers") {
const activeConfig = proxyConfigs.find(c => c.enabled);
if (activeConfig) {
proxyIcon = activeConfig.proxyType === "pac_script" ? "📜" : "🌐";
proxyName = activeConfig.name || "未命名代理";
}
}
return (
<div
ref={panelRef}
className={`floating-panel ${expanded ? 'expanded' : ''} ${isDragging ? 'dragging' : ''}`}
data-active-tab={activeTab}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
onMouseEnter={handleMouseEnter}
>
<div
className="panel-header"
onMouseDown={handleMouseDown}
onClick={() => !isDragging && setExpanded(!expanded)}
>
<div className="header-content">
<img src={YAK_ICON_URL} className="yak-icon" alt="Yak"/>
<div className="active-proxy-info">
<span>{proxyIcon}</span>
<span>{proxyName}</span>
</div>
</div>
</div>
{expanded && (
<div className="panel-content">
<div className="tabs-container">
<div className="tab-list">
<button
className={`tab-button ${activeTab === 'proxy' ? 'active' : ''}`}
onClick={() => setActiveTab('proxy')}
title="代理设置"
>
🌐
</button>
<button
className={`tab-button ${activeTab === 'links' ? 'active' : ''}`}
onClick={() => setActiveTab('links')}
title="页面链接"
>
🔗
</button>
</div>
<div className="tab-content">
<div className={`tab-panel ${activeTab === 'proxy' ? 'active' : ''}`} data-panel="proxy">
<div
className={`proxy-item ${proxyStatus.currentMode === 'direct' ? 'active' : ''}`}
onClick={() => handleProxySwitch({
id: 'direct',
name: '[直接连接]',
proxyType: 'direct',
enabled: false
})}
title="直接连接"
>
<span>🟢</span>
<span></span>
{proxyStatus.currentMode === 'direct' && <div className="proxy-status"></div>}
</div>
<div
className={`proxy-item ${proxyStatus.currentMode === 'system' ? 'active' : ''}`}
onClick={() => handleProxySwitch({
id: 'system',
name: '[系统代理]',
proxyType: 'system',
enabled: true
})}
title="系统代理"
>
<span></span>
<span></span>
{proxyStatus.currentMode === 'system' && <div className="proxy-status"></div>}
</div>
<div className="divider"></div>
{proxyConfigs.map(config => {
if (config.proxyType !== 'direct' && config.proxyType !== 'system') {
const isActive = proxyStatus.currentMode === 'fixed_servers' && config.enabled;
const proxyIcon = config.proxyType === 'pac_script' ? '📜' : '🌐';
const tooltipText = config.proxyType === 'pac_script'
? 'PAC Script'
: `${config.scheme ? `${config.scheme.toUpperCase()} ` : ''}${config.host}:${config.port}`;
return (
<div
key={config.id}
className={`proxy-item ${isActive ? 'active' : ''}`}
onClick={() => handleProxySwitch({...config, enabled: true})}
title={tooltipText}
>
<span>{proxyIcon}</span>
<span>{config.name || '未命名代理'}</span>
{isActive && <div className="proxy-status"></div>}
</div>
);
}
return null;
})}
<div className="divider"></div>
<div className="action-button" onClick={() => openOptionsPage(true)}>
<span></span>
<span></span>
</div>
<div className="action-button" onClick={() => openOptionsPage(false)}>
<span></span>
<span></span>
</div>
</div>
<div className={`tab-panel ${activeTab === 'links' ? 'active' : ''}`} data-panel="links">
{/* Links panel content will be added in the future */}
<div className="links-placeholder" style={{padding: '16px', textAlign: 'center'}}>
<p></p>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default App;
-39
View File
@@ -1,39 +0,0 @@
import './App.css';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
export default defineContentScript({
matches: ['<all_urls>'],
cssInjectionMode: 'ui',
async main(ctx) {
console.log("Proxy content script starting...");
// Define your UI with shadow root for isolation
const ui = await createShadowRootUi(ctx, {
name: 'yakit-proxy-panel',
position: 'inline',
anchor: 'body',
onMount: (container) => {
// Create a wrapper div for the React app
const app = document.createElement('div');
app.id = 'yakit-proxy-root';
app.className = 'yak-proxy-root';
container.append(app);
// Create a root on the UI container and render a component
const root = ReactDOM.createRoot(app);
root.render(<App />);
return root;
},
onRemove: (root) => {
// Unmount the root when the UI is removed
root?.unmount();
},
});
// Mount the UI
ui.mount();
},
});
+120
View File
@@ -0,0 +1,120 @@
import { browser } from 'wxt/browser';
import { AGENT_RUNTIME_STORAGE_KEY } from '@/protocol/storage';
import type {
AgentActionRecord, AgentActionState, AgentRuntime, AgentRuntimeState, BridgeGrant,
} from '@/types/models';
import { ExtensionError } from '@/shared/errors';
interface StorageArea {
get(keys: string | string[]): Promise<Record<string, unknown>>;
set(items: Record<string, unknown>): Promise<void>;
}
const MAX_ACTIONS = 200;
const sessionStorage = (browser.storage as unknown as { session?: StorageArea }).session;
let queue: Promise<void> = Promise.resolve();
let fallbackRuntime: AgentRuntime | undefined;
function emptyRuntime(): AgentRuntime {
return { state: 'idle', updatedAt: Date.now(), actions: [] };
}
function normalizeRuntime(input: unknown): AgentRuntime {
if (!input || typeof input !== 'object') return emptyRuntime();
const value = input as Partial<AgentRuntime>;
return {
state: value.state || 'idle',
taskId: value.taskId,
grantId: value.grantId,
startedAt: value.startedAt,
pausedAt: value.pausedAt,
updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : Date.now(),
actions: Array.isArray(value.actions) ? value.actions.slice(-MAX_ACTIONS) : [],
};
}
export async function getAgentRuntime(): Promise<AgentRuntime> {
if (!sessionStorage) return fallbackRuntime || emptyRuntime();
return normalizeRuntime((await sessionStorage.get(AGENT_RUNTIME_STORAGE_KEY))[AGENT_RUNTIME_STORAGE_KEY]);
}
async function mutate(updater: (current: AgentRuntime) => AgentRuntime | Promise<AgentRuntime>): Promise<AgentRuntime> {
let resolveResult!: (runtime: AgentRuntime) => void;
let rejectResult!: (error: unknown) => void;
const result = new Promise<AgentRuntime>((resolve, reject) => {
resolveResult = resolve;
rejectResult = reject;
});
queue = queue.then(async () => {
try {
const next = normalizeRuntime(await updater(await getAgentRuntime()));
fallbackRuntime = next;
await sessionStorage?.set({ [AGENT_RUNTIME_STORAGE_KEY]: next });
resolveResult(next);
} catch (error) {
rejectResult(error);
}
});
return result;
}
export function startAgentRuntime(grant: BridgeGrant): Promise<AgentRuntime> {
const now = Date.now();
return mutate((current) => ({
state: 'running', taskId: grant.taskId, grantId: grant.id, startedAt: now,
updatedAt: now, actions: current.grantId === grant.id ? current.actions : [],
}));
}
export function setAgentRuntimeState(state: AgentRuntimeState, grant?: BridgeGrant): Promise<AgentRuntime> {
return mutate((current) => ({
...current,
state,
taskId: grant?.taskId || current.taskId,
grantId: grant?.id || current.grantId,
pausedAt: state === 'paused' ? Date.now() : undefined,
updatedAt: Date.now(),
actions: ['revoked', 'expired'].includes(state)
? current.actions.map((action) => action.state === 'running'
? { ...action, state: 'cancelled', completedAt: Date.now(), durationMs: Date.now() - action.startedAt, errorCode: state }
: action)
: current.actions,
}));
}
export function clearAgentActions(): Promise<AgentRuntime> {
return mutate((current) => ({ ...current, actions: [], updatedAt: Date.now() }));
}
export async function beginAgentAction(
grant: BridgeGrant,
input: { requestId: string; method: string; targetTabId?: number },
): Promise<AgentActionRecord> {
let created!: AgentActionRecord;
await mutate((current) => {
const runtime = current.grantId === grant.id
? current
: { state: 'running' as const, taskId: grant.taskId, grantId: grant.id, startedAt: Date.now(), updatedAt: Date.now(), actions: [] };
if (runtime.state === 'paused' || runtime.state === 'waiting_for_human') {
throw new ExtensionError('agent_paused', runtime.state === 'waiting_for_human' ? 'Agent 正在等待用户完成接管步骤' : 'Agent 操作已被用户暂停');
}
if (runtime.state !== 'running') throw new ExtensionError('grant_expired', 'Agent 会话已经结束');
created = {
id: crypto.randomUUID(), requestId: input.requestId, taskId: grant.taskId, grantId: grant.id,
method: input.method, targetTabId: input.targetTabId, state: 'running', startedAt: Date.now(),
};
return { ...runtime, updatedAt: Date.now(), actions: [...runtime.actions, created].slice(-MAX_ACTIONS) };
});
return created;
}
export function finishAgentAction(id: string, state: Exclude<AgentActionState, 'running'>, errorCode?: string): Promise<AgentRuntime> {
const now = Date.now();
return mutate((current) => ({
...current,
updatedAt: now,
actions: current.actions.map((action) => action.id === id && action.state === 'running'
? { ...action, state, completedAt: now, durationMs: now - action.startedAt, errorCode }
: action),
}));
}
+57
View File
@@ -0,0 +1,57 @@
import { browser } from 'wxt/browser';
import type { BrowserCookie, CookieInput, CookieRemoveInput } from '@/types/models';
function toCookie(cookie: Browser.cookies.Cookie): BrowserCookie {
const extended = cookie as Browser.cookies.Cookie & {
firstPartyDomain?: string;
priority?: 'low' | 'medium' | 'high';
sameParty?: boolean;
};
return {
name: cookie.name,
value: cookie.value,
domain: cookie.domain,
path: cookie.path,
secure: cookie.secure,
httpOnly: cookie.httpOnly,
sameSite: cookie.sameSite,
session: cookie.session,
expirationDate: cookie.expirationDate,
hostOnly: cookie.hostOnly,
storeId: cookie.storeId,
firstPartyDomain: extended.firstPartyDomain || undefined,
partitionKey: cookie.partitionKey,
priority: extended.priority,
sameParty: extended.sameParty,
};
}
export async function listCookies(url: string): Promise<BrowserCookie[]> {
const cookies = await browser.cookies.getAll({ url, partitionKey: {} }).catch(() => browser.cookies.getAll({ url }));
return cookies.map(toCookie).sort((left, right) => left.name.localeCompare(right.name));
}
export async function setCookie(input: CookieInput): Promise<BrowserCookie> {
const details = {
url: input.url,
name: input.name,
value: input.value,
domain: input.domain || undefined,
path: input.path || '/',
secure: input.secure,
httpOnly: input.httpOnly,
sameSite: input.sameSite || 'unspecified',
expirationDate: input.expirationDate,
storeId: input.storeId,
...(input.firstPartyDomain ? { firstPartyDomain: input.firstPartyDomain } : {}),
partitionKey: input.partitionKey,
} as Parameters<typeof browser.cookies.set>[0];
const cookie = await browser.cookies.set(details);
if (!cookie) throw new Error('Cookie 写入失败');
return toCookie(cookie);
}
export async function removeCookie(input: CookieRemoveInput): Promise<void> {
const result = await browser.cookies.remove(input);
if (!result) throw new Error('Cookie 不存在或删除失败');
}
+24
View File
@@ -0,0 +1,24 @@
import { vi, describe, expect, it } from 'vitest';
vi.mock('wxt/browser', () => ({ browser: {} }));
import type { BrowserCookie } from '@/types/models';
import { buildCookieUrl, exportCookies } from './transfer';
const cookie = {
name: 'session', value: 'secret-value', domain: '.example.test', path: '/', secure: true,
httpOnly: true, hostOnly: false, session: false, sameSite: 'lax', storeId: '0',
} as BrowserCookie;
describe('Cookie transfer', () => {
it('constructs a domain/path aware URL', () => {
expect(buildCookieUrl('http://app.example.test/start', { domain: '.example.test', path: 'api', secure: true }))
.toBe('https://example.test/api');
});
it('redacts exports unless values are explicitly requested', () => {
expect(exportCookies([cookie], 'json', false)).toContain('[REDACTED]');
expect(exportCookies([cookie], 'netscape', false)).not.toContain('secret-value');
expect(exportCookies([cookie], 'set-cookie', true)).toContain('session=secret-value');
});
});
+165
View File
@@ -0,0 +1,165 @@
import type {
BrowserCookie, CookieImportResult, CookieInput, CookieTransferFormat,
} from '@/types/models';
import { setCookie } from '@/features/cookies/service';
const MAX_COOKIES = 1_000;
const MAX_TRANSFER_BYTES = 2 * 1024 * 1024;
function assertTransferSize(text: string): void {
if (new TextEncoder().encode(text).byteLength > MAX_TRANSFER_BYTES) throw new Error('Cookie 导入内容超过 2 MiB');
}
export function buildCookieUrl(baseUrl: string, input: Pick<CookieInput, 'domain' | 'path' | 'secure'>): string {
const base = new URL(baseUrl);
const host = input.domain?.replace(/^\./, '') || base.hostname;
const protocol = input.secure ? 'https:' : base.protocol === 'https:' ? 'https:' : 'http:';
const path = input.path?.startsWith('/') ? input.path : `/${input.path || ''}`;
return `${protocol}//${host}${path}`;
}
function sameSite(value: unknown): CookieInput['sameSite'] {
const normalized = String(value || '').toLowerCase().replace('none', 'no_restriction');
return ['lax', 'strict', 'no_restriction', 'unspecified'].includes(normalized)
? normalized as CookieInput['sameSite']
: 'unspecified';
}
function fromRecord(value: unknown, baseUrl: string, warnings: string[]): CookieInput | undefined {
if (!value || typeof value !== 'object') return undefined;
const input = value as Record<string, unknown>;
if (typeof input.name !== 'string' || typeof input.value !== 'string' || input.name.length > 4_096 || input.value.length > 64 * 1_024) return undefined;
const output: CookieInput = {
url: baseUrl,
name: input.name,
value: input.value,
domain: typeof input.domain === 'string' ? input.domain.slice(0, 253) : undefined,
path: typeof input.path === 'string' ? input.path.slice(0, 4_096) : '/',
secure: input.secure === true,
httpOnly: input.httpOnly === true,
sameSite: sameSite(input.sameSite),
expirationDate: typeof input.expirationDate === 'number' && Number.isFinite(input.expirationDate) ? input.expirationDate : undefined,
storeId: typeof input.storeId === 'string' ? input.storeId.slice(0, 240) : undefined,
};
if (input.partitionKey && typeof input.partitionKey === 'object') {
const partition = input.partitionKey as Record<string, unknown>;
output.partitionKey = {
topLevelSite: typeof partition.topLevelSite === 'string' ? partition.topLevelSite.slice(0, 8_192) : undefined,
hasCrossSiteAncestor: partition.hasCrossSiteAncestor === true,
};
}
if (input.priority || input.sameParty) warnings.push(`${input.name}: Priority/SameParty 无法通过浏览器 Cookies API 写回`);
output.url = buildCookieUrl(baseUrl, output);
return output;
}
function parseJSON(text: string, baseUrl: string, warnings: string[]): CookieInput[] {
const parsed = JSON.parse(text) as unknown;
if (!Array.isArray(parsed)) throw new Error('JSON Cookie 必须是数组');
if (parsed.length > MAX_COOKIES) throw new Error(`单次最多导入 ${MAX_COOKIES} 个 Cookie`);
return parsed.map((item) => fromRecord(item, baseUrl, warnings)).filter((item): item is CookieInput => Boolean(item));
}
function parseNetscape(text: string, baseUrl: string): CookieInput[] {
const output: CookieInput[] = [];
for (const rawLine of text.split(/\r?\n/)) {
const httpOnly = rawLine.startsWith('#HttpOnly_');
if ((!httpOnly && rawLine.trim().startsWith('#')) || !rawLine.trim()) continue;
const line = httpOnly ? rawLine.slice('#HttpOnly_'.length) : rawLine;
const fields = line.split('\t');
if (fields.length < 7) continue;
const [domain, , path, secure, expiration, name, ...value] = fields;
const item: CookieInput = {
url: baseUrl, name, value: value.join('\t'), domain, path: path || '/', secure: secure.toUpperCase() === 'TRUE', httpOnly,
expirationDate: Number(expiration) > 0 ? Number(expiration) : undefined,
sameSite: 'unspecified',
};
item.url = buildCookieUrl(baseUrl, item);
output.push(item);
if (output.length > MAX_COOKIES) throw new Error(`单次最多导入 ${MAX_COOKIES} 个 Cookie`);
}
return output;
}
function parseSetCookie(text: string, baseUrl: string, warnings: string[]): CookieInput[] {
const output: CookieInput[] = [];
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.replace(/^set-cookie:\s*/i, '').trim();
if (!line) continue;
const [pair, ...attributes] = line.split(';').map((part) => part.trim());
const separator = pair.indexOf('=');
if (separator < 0) continue;
const item: CookieInput = { url: baseUrl, name: pair.slice(0, separator), value: pair.slice(separator + 1), path: '/', sameSite: 'unspecified' };
for (const attribute of attributes) {
const [rawName, ...rawValue] = attribute.split('=');
const name = rawName.toLowerCase();
const value = rawValue.join('=');
if (name === 'domain') item.domain = value;
else if (name === 'path') item.path = value || '/';
else if (name === 'secure') item.secure = true;
else if (name === 'httponly') item.httpOnly = true;
else if (name === 'samesite') item.sameSite = sameSite(value);
else if (name === 'expires') {
const timestamp = Date.parse(value);
if (Number.isFinite(timestamp)) item.expirationDate = timestamp / 1_000;
} else if (name === 'max-age' && Number.isFinite(Number(value))) item.expirationDate = Date.now() / 1_000 + Number(value);
else if (name === 'partitioned') item.partitionKey = { topLevelSite: new URL(baseUrl).origin };
else if (name === 'priority' || name === 'sameparty') warnings.push(`${item.name}: ${rawName} 无法通过浏览器 Cookies API 写回`);
}
item.url = buildCookieUrl(baseUrl, item);
output.push(item);
if (output.length > MAX_COOKIES) throw new Error(`单次最多导入 ${MAX_COOKIES} 个 Cookie`);
}
return output;
}
export async function importCookies(baseUrl: string, format: CookieTransferFormat, text: string): Promise<CookieImportResult> {
assertTransferSize(text);
const warnings: string[] = [];
const cookies = format === 'json' ? parseJSON(text, baseUrl, warnings)
: format === 'netscape' ? parseNetscape(text, baseUrl)
: parseSetCookie(text, baseUrl, warnings);
let imported = 0;
let failed = 0;
for (const cookie of cookies) {
try {
await setCookie(cookie);
imported += 1;
} catch (error) {
failed += 1;
if (warnings.length < 50) warnings.push(`${cookie.name}: ${error instanceof Error ? error.message : String(error)}`);
}
}
if (cookies.length === 0) warnings.push('没有解析到可导入的 Cookie');
return { imported, failed, warnings: warnings.slice(0, 50) };
}
function displayValue(cookie: BrowserCookie, includeValues: boolean): string {
return includeValues ? cookie.value : '[REDACTED]';
}
export function exportCookies(cookies: BrowserCookie[], format: CookieTransferFormat, includeValues: boolean): string {
if (format === 'json') {
return JSON.stringify(cookies.map((cookie) => ({ ...cookie, value: displayValue(cookie, includeValues) })), null, 2);
}
if (format === 'netscape') {
const lines = ['# Netscape HTTP Cookie File', '# Exported by Yakit Browser Agent'];
for (const cookie of cookies) {
const domain = `${cookie.httpOnly ? '#HttpOnly_' : ''}${cookie.domain}`;
lines.push([domain, cookie.hostOnly ? 'FALSE' : 'TRUE', cookie.path, cookie.secure ? 'TRUE' : 'FALSE', Math.floor(cookie.expirationDate || 0), cookie.name, displayValue(cookie, includeValues)].join('\t'));
}
return `${lines.join('\n')}\n`;
}
return cookies.map((cookie) => {
const attributes = [`Path=${cookie.path}`];
if (!cookie.hostOnly) attributes.push(`Domain=${cookie.domain}`);
if (cookie.expirationDate) attributes.push(`Expires=${new Date(cookie.expirationDate * 1_000).toUTCString()}`);
if (cookie.secure) attributes.push('Secure');
if (cookie.httpOnly) attributes.push('HttpOnly');
if (cookie.sameSite && cookie.sameSite !== 'unspecified') attributes.push(`SameSite=${cookie.sameSite === 'no_restriction' ? 'None' : cookie.sameSite}`);
if (cookie.partitionKey) attributes.push('Partitioned');
if (cookie.priority) attributes.push(`Priority=${cookie.priority}`);
if (cookie.sameParty) attributes.push('SameParty');
return `Set-Cookie: ${cookie.name}=${displayValue(cookie, includeValues)}; ${attributes.join('; ')}`;
}).join('\n');
}
+31
View File
@@ -0,0 +1,31 @@
import { browser } from 'wxt/browser';
import type { AuditEvent } from '@/types/models';
import { AUDIT_STORAGE_KEY } from '@/protocol/storage';
const MAX_AUDIT_EVENTS = 500;
let auditQueue: Promise<void> = Promise.resolve();
export type NewAuditEvent = Omit<AuditEvent, 'id' | 'timestamp'>;
export function appendAuditEvent(input: NewAuditEvent): Promise<void> {
const operation = auditQueue.then(async () => {
const stored = await browser.storage.local.get(AUDIT_STORAGE_KEY);
const current = Array.isArray(stored[AUDIT_STORAGE_KEY]) ? stored[AUDIT_STORAGE_KEY] as AuditEvent[] : [];
const event: AuditEvent = { id: crypto.randomUUID(), timestamp: Date.now(), ...input };
await browser.storage.local.set({ [AUDIT_STORAGE_KEY]: [...current, event].slice(-MAX_AUDIT_EVENTS) });
});
auditQueue = operation.catch(() => undefined);
return operation;
}
export async function listAuditEvents(limit = 100): Promise<AuditEvent[]> {
const stored = await browser.storage.local.get(AUDIT_STORAGE_KEY);
const current = Array.isArray(stored[AUDIT_STORAGE_KEY]) ? stored[AUDIT_STORAGE_KEY] as AuditEvent[] : [];
return current.slice(-Math.min(Math.max(limit, 1), MAX_AUDIT_EVENTS)).reverse();
}
export async function clearAuditEvents(): Promise<void> {
const operation = auditQueue.then(() => browser.storage.local.remove(AUDIT_STORAGE_KEY));
auditQueue = operation.catch(() => undefined);
return operation;
}
+46
View File
@@ -0,0 +1,46 @@
import { browser } from 'wxt/browser';
import { STATE_STORAGE_KEYS } from '@/protocol/storage';
import type { BridgeStatus, DiagnosticsBundle } from '@/types/models';
import { listAuditEvents } from '@/features/diagnostics/audit';
import { getState } from '@/platform/storage/state';
import { getEnterprisePolicy } from '@/platform/policy/managed';
import { getRuntimeMetrics } from './metrics';
export async function createDiagnosticsBundle(bridge: BridgeStatus): Promise<DiagnosticsBundle> {
const manifest = browser.runtime.getManifest();
const sessionArea = (browser.storage as unknown as { session?: { get(keys: string[]): Promise<Record<string, unknown>> } }).session;
const [state, platform, policy, metrics, audit, local, session] = await Promise.all([
getState(), browser.runtime.getPlatformInfo(), getEnterprisePolicy(), getRuntimeMetrics(), listAuditEvents(100),
browser.storage.local.get([...STATE_STORAGE_KEYS]),
sessionArea?.get([...STATE_STORAGE_KEYS]) || Promise.resolve({}),
]);
const { taskId: _taskId, grantId: _grantId, ...safeBridge } = bridge;
return {
schemaVersion: 1,
generatedAt: Date.now(),
extension: {
version: manifest.version,
manifestVersion: manifest.manifest_version,
buildChannel: import.meta.env.MODE,
permissions: [...(manifest.permissions || [])].sort(),
},
platform: { os: platform.os, arch: platform.arch },
bridge: safeBridge,
policy,
state: {
proxyProfiles: state.proxyProfiles.length,
proxyRules: state.proxyRules.length,
userAgentRules: state.userAgentRules.length,
floatingPanelEnabled: state.floatingPanel.enabled,
activeGrant: Boolean(state.activeGrant),
activeGrantTargets: state.activeGrant?.targets.length || 0,
activeGrantScopes: state.activeGrant?.scopes || [],
handoffState: state.handoff?.state,
},
storageDomains: Object.fromEntries(STATE_STORAGE_KEYS.map((key) => [key, key in local || key in session])),
metrics,
recentAudit: audit.map(({ timestamp, category, action, outcome, durationMs, errorCode }) => ({
timestamp, category, action, outcome, durationMs, errorCode,
})),
};
}
+77
View File
@@ -0,0 +1,77 @@
import { browser } from 'wxt/browser';
import { RUNTIME_METRICS_STORAGE_KEY } from '@/protocol/storage';
import type { RuntimeMetrics } from '@/types/models';
let queue: Promise<void> = Promise.resolve();
function defaults(): RuntimeMetrics {
const now = Date.now();
return {
version: 1, firstSeenAt: now, updatedAt: now, serviceWorkerStarts: 0,
bridgeConnectAttempts: 0, bridgeConnections: 0, bridgeDisconnects: 0, bridgeErrors: 0,
heartbeatSamples: 0, heartbeatLatencyTotalMs: 0, heartbeatLatencyMaxMs: 0, capabilities: {},
};
}
export async function getRuntimeMetrics(): Promise<RuntimeMetrics> {
const stored = (await browser.storage.local.get(RUNTIME_METRICS_STORAGE_KEY))[RUNTIME_METRICS_STORAGE_KEY];
if (!stored || typeof stored !== 'object') return defaults();
return { ...defaults(), ...(stored as Partial<RuntimeMetrics>), capabilities: (stored as RuntimeMetrics).capabilities || {} };
}
function mutate(updater: (current: RuntimeMetrics) => RuntimeMetrics): void {
queue = queue.then(async () => {
const next = updater(await getRuntimeMetrics());
next.updatedAt = Date.now();
await browser.storage.local.set({ [RUNTIME_METRICS_STORAGE_KEY]: next });
}).catch(() => undefined);
}
export function recordServiceWorkerStart(): void {
mutate((current) => ({ ...current, serviceWorkerStarts: current.serviceWorkerStarts + 1 }));
}
export function recordBridgeState(state: 'connecting' | 'connected' | 'disconnected' | 'error'): void {
mutate((current) => ({
...current,
bridgeConnectAttempts: current.bridgeConnectAttempts + (state === 'connecting' ? 1 : 0),
bridgeConnections: current.bridgeConnections + (state === 'connected' ? 1 : 0),
bridgeDisconnects: current.bridgeDisconnects + (state === 'disconnected' ? 1 : 0),
bridgeErrors: current.bridgeErrors + (state === 'error' ? 1 : 0),
}));
}
export function recordHeartbeat(latencyMs: number): void {
const bounded = Math.min(Math.max(Math.round(latencyMs), 0), 60_000);
mutate((current) => ({
...current,
heartbeatSamples: current.heartbeatSamples + 1,
heartbeatLatencyTotalMs: current.heartbeatLatencyTotalMs + bounded,
heartbeatLatencyMaxMs: Math.max(current.heartbeatLatencyMaxMs, bounded),
}));
}
export function recordCapabilityMetric(method: string, durationMs: number, error: boolean): void {
mutate((current) => {
const previous = current.capabilities[method] || { count: 0, errorCount: 0, totalDurationMs: 0, maxDurationMs: 0 };
const duration = Math.min(Math.max(Math.round(durationMs), 0), 60_000);
return {
...current,
capabilities: {
...current.capabilities,
[method]: {
count: previous.count + 1,
errorCount: previous.errorCount + (error ? 1 : 0),
totalDurationMs: previous.totalDurationMs + duration,
maxDurationMs: Math.max(previous.maxDurationMs, duration),
},
},
};
});
}
export async function resetRuntimeMetrics(): Promise<RuntimeMetrics> {
const next = defaults();
await browser.storage.local.set({ [RUNTIME_METRICS_STORAGE_KEY]: next });
return next;
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import type { BridgeEnvelope } from '@/types/messages';
import {
clientAuthPayload, engineChallengePayload, pairingVerificationCode, signBridgePayload, verifyBridgePayload,
} from './identity';
describe('Bridge v3 identity transcript', () => {
it('keeps the Go-compatible canonical field order', () => {
expect(engineChallengePayload({
engineIdentityId: 'identity-1', engineInstanceId: 'instance-1', challenge: 'nonce-1', timestamp: 123,
})).toBe('yak-browser-bridge-v3\nengine-challenge\nidentity-1\ninstance-1\nnonce-1\n123');
const envelope: BridgeEnvelope = {
type: 'auth', installationId: 'install-1', client: 'client-1', version: '1.0.0',
capabilities: ['z.capability', 'a.capability'], taskId: 'task-1', grantId: 'grant-1', resumeSessionId: 'session-1',
};
expect(clientAuthPayload({
origin: 'chrome-extension://abc', engineIdentityId: 'identity-1', engineInstanceId: 'instance-1',
challenge: 'nonce-1', envelope,
})).toBe('yak-browser-bridge-v3\nclient-auth\nchrome-extension://abc\nidentity-1\ninstance-1\nnonce-1\ninstall-1\nclient-1\n1.0.0\na.capability,z.capability\ntask-1\ngrant-1\nsession-1');
});
it('matches the shared pairing verification vector', async () => {
await expect(pairingVerificationCode({
engineIdentityId: 'engine-id', requestId: 'request-1', origin: 'chrome-extension://abc', installationId: 'install-1',
clientNonce: 'client-nonce-value', serverNonce: 'server-nonce-value',
publicKey: { kty: 'EC', crv: 'P-256', x: 'x-coordinate', y: 'y-coordinate' },
})).resolves.toBe('113961');
});
it('signs and verifies ECDSA P-256 payloads', async () => {
const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
const publicJWK = await crypto.subtle.exportKey('jwk', pair.publicKey);
const publicKey = { kty: 'EC' as const, crv: 'P-256' as const, x: publicJWK.x!, y: publicJWK.y! };
const signature = await signBridgePayload(pair.privateKey, 'payload');
await expect(verifyBridgePayload(publicKey, 'payload', signature)).resolves.toBe(true);
await expect(verifyBridgePayload(publicKey, 'tampered', signature)).resolves.toBe(false);
});
});
+166
View File
@@ -0,0 +1,166 @@
import type { BridgeEnvelope } from '@/types/messages';
import type { BridgePublicKey } from '@/types/models';
const DATABASE_NAME = 'yakit-browser-bridge-identity-v1';
const STORE_NAME = 'identities';
interface StoredBrowserIdentity {
installationId: string;
privateKey: CryptoKey;
publicKey: BridgePublicKey;
createdAt: number;
}
function openIdentityDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DATABASE_NAME, 1);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(STORE_NAME)) request.result.createObjectStore(STORE_NAME, { keyPath: 'installationId' });
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('无法打开浏览器配对身份数据库'));
});
}
function requestResult<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('浏览器配对身份数据库操作失败'));
});
}
async function readIdentity(installationId: string): Promise<StoredBrowserIdentity | undefined> {
const database = await openIdentityDatabase();
try {
const transaction = database.transaction(STORE_NAME, 'readonly');
return await requestResult(transaction.objectStore(STORE_NAME).get(installationId)) as StoredBrowserIdentity | undefined;
} finally {
database.close();
}
}
async function writeIdentity(identity: StoredBrowserIdentity): Promise<void> {
const database = await openIdentityDatabase();
try {
const transaction = database.transaction(STORE_NAME, 'readwrite');
await requestResult(transaction.objectStore(STORE_NAME).put(identity));
} finally {
database.close();
}
}
export async function clearBrowserBridgeIdentity(installationId: string): Promise<void> {
const database = await openIdentityDatabase();
try {
const transaction = database.transaction(STORE_NAME, 'readwrite');
await requestResult(transaction.objectStore(STORE_NAME).delete(installationId));
} finally {
database.close();
}
}
function normalizePublicJWK(value: JsonWebKey): BridgePublicKey {
if (value.kty !== 'EC' || value.crv !== 'P-256' || !value.x || !value.y) throw new Error('浏览器配对公钥不是 ECDSA P-256');
return { kty: 'EC', crv: 'P-256', x: value.x, y: value.y };
}
export async function getOrCreateBrowserBridgeIdentity(installationId: string): Promise<StoredBrowserIdentity> {
const existing = await readIdentity(installationId);
if (existing?.privateKey && existing.publicKey) return existing;
const generated = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
const [publicJWK, privatePKCS8] = await Promise.all([
crypto.subtle.exportKey('jwk', generated.publicKey),
crypto.subtle.exportKey('pkcs8', generated.privateKey),
]);
const privateKey = await crypto.subtle.importKey('pkcs8', privatePKCS8, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']);
const identity: StoredBrowserIdentity = {
installationId,
privateKey,
publicKey: normalizePublicJWK(publicJWK),
createdAt: Date.now(),
};
await writeIdentity(identity);
return identity;
}
function bytesToBase64URL(bytes: Uint8Array): string {
let binary = '';
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, Math.min(offset + 0x8000, bytes.length)));
}
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
}
function base64URLToBytes(value: string): Uint8Array {
const padded = value.replaceAll('-', '+').replaceAll('_', '/') + '='.repeat((4 - (value.length % 4)) % 4);
const binary = atob(padded);
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
export function randomBridgeNonce(): string {
return bytesToBase64URL(crypto.getRandomValues(new Uint8Array(32)));
}
export async function signBridgePayload(privateKey: CryptoKey, payload: string): Promise<string> {
const signature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, privateKey, new TextEncoder().encode(payload));
return bytesToBase64URL(new Uint8Array(signature));
}
export async function verifyBridgePayload(publicKey: BridgePublicKey, payload: string, signature: string): Promise<boolean> {
const key = await crypto.subtle.importKey('jwk', publicKey, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
return crypto.subtle.verify(
{ name: 'ECDSA', hash: 'SHA-256' }, key,
base64URLToBytes(signature).buffer as ArrayBuffer,
new TextEncoder().encode(payload),
);
}
export function engineChallengePayload(input: {
engineIdentityId: string;
engineInstanceId: string;
challenge: string;
timestamp: number;
}): string {
return [
'yak-browser-bridge-v3', 'engine-challenge', input.engineIdentityId, input.engineInstanceId,
input.challenge, String(input.timestamp),
].join('\n');
}
export function clientAuthPayload(input: {
origin: string;
engineIdentityId: string;
engineInstanceId: string;
challenge: string;
envelope: BridgeEnvelope;
}): string {
return [
'yak-browser-bridge-v3', 'client-auth', input.origin, input.engineIdentityId, input.engineInstanceId,
input.challenge, input.envelope.installationId || '', input.envelope.client || '', input.envelope.version || '',
[...(input.envelope.capabilities || [])].sort().join(','), input.envelope.taskId || '', input.envelope.grantId || '',
input.envelope.resumeSessionId || '',
].join('\n');
}
export async function pairingVerificationCode(input: {
engineIdentityId: string;
requestId: string;
origin: string;
installationId: string;
clientNonce: string;
serverNonce: string;
publicKey: BridgePublicKey;
}): Promise<string> {
const payload = [
'yak-browser-pairing-v1', input.engineIdentityId, input.requestId, input.origin, input.installationId,
input.clientNonce, input.serverNonce, input.publicKey.kty, input.publicKey.crv, input.publicKey.x, input.publicKey.y,
].join('\n');
const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload)));
let value = 0n;
for (const byte of hash.subarray(0, 8)) value = (value << 8n) | BigInt(byte);
return String(value % 1_000_000n).padStart(6, '0');
}
export function publicKeysEqual(left: BridgePublicKey, right: BridgePublicKey): boolean {
return left.kty === right.kty && left.crv === right.crv && left.x === right.x && left.y === right.y;
}
+755
View File
@@ -0,0 +1,755 @@
import { browser } from 'wxt/browser';
import type { BridgeEnvelope } from '@/types/messages';
import type { BridgeConfig, BridgePairingStatus, BridgePublicKey, BridgeStatus } from '@/types/models';
import { BRIDGE_CAPABILITIES } from '@/protocol/capabilities';
import {
BRIDGE_CHUNK_BYTES, BRIDGE_CHUNK_THRESHOLD_BYTES, BRIDGE_CHUNK_TIMEOUT_MS,
BRIDGE_MAX_CHUNK_TRANSFERS, BRIDGE_MAX_MESSAGE_BYTES, BRIDGE_PROTOCOL_VERSION, parseBridgeEnvelope,
parseBridgePairingEnvelope, type BridgePairingEnvelope,
} from '@/protocol/bridge';
import { getBridgeRuntimeSession, getState, setBridgeRuntimeSession, updateState } from '@/platform/storage/state';
import { routeCapability } from '@/features/grants/service';
import { errorCode, ExtensionError, isDeniedErrorCode } from '@/shared/errors';
import { appendAuditEvent } from '@/features/diagnostics/audit';
import { beginAgentAction, finishAgentAction } from '@/features/agent-runtime/service';
import { recordBridgeState, recordCapabilityMetric, recordHeartbeat } from '@/features/diagnostics/metrics';
import {
clearBrowserBridgeIdentity, clientAuthPayload, engineChallengePayload, getOrCreateBrowserBridgeIdentity,
pairingVerificationCode, publicKeysEqual, randomBridgeNonce, signBridgePayload, verifyBridgePayload,
} from './identity';
const STATUS_EVENT = 'bridge.status.changed';
const PAIRING_STATUS_EVENT = 'bridge.pairing.status.changed';
const RECONNECT_DELAY = 3_000;
const HEARTBEAT_INTERVAL = 20_000;
const HANDSHAKE_TIMEOUT = 5_000;
const MAX_CONCURRENT_REQUESTS = 8;
const ENGINE_REQUEST_TIMEOUT = 10_000;
const MAX_OUTGOING_REQUESTS = 4;
interface OutgoingRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof globalThis.setTimeout>;
}
interface ChunkAssembly {
createdAt: number;
total: number;
originalBytes: number;
parts: Array<Uint8Array | undefined>;
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, Math.min(offset + 0x8000, bytes.length)));
}
return btoa(binary);
}
function base64ToBytes(value: string): Uint8Array {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return bytes;
}
function isLoopbackEndpoint(endpoint: string): boolean {
try {
const url = new URL(endpoint);
return (url.protocol === 'ws:' || url.protocol === 'wss:')
&& ['127.0.0.1', 'localhost', '[::1]', '::1'].includes(url.hostname);
} catch {
return false;
}
}
export class EngineBridge {
private socket?: WebSocket;
private nativePort?: Browser.runtime.Port;
private reconnectTimer?: ReturnType<typeof globalThis.setTimeout>;
private heartbeatTimer?: ReturnType<typeof globalThis.setInterval>;
private handshakeTimer?: ReturnType<typeof globalThis.setTimeout>;
private handshakeResolve?: () => void;
private handshakeReject?: (error: Error) => void;
private connectPromise?: Promise<void>;
private pairingSocket?: WebSocket;
private pairingTimer?: ReturnType<typeof globalThis.setTimeout>;
private pairingResolve?: (status: BridgePairingStatus) => void;
private pairingReject?: (error: Error) => void;
private pairingContext?: {
config: BridgeConfig;
clientNonce: string;
publicKey: BridgePublicKey;
privateKey: CryptoKey;
requestId?: string;
engineIdentityId?: string;
enginePublicKey?: BridgePublicKey;
};
private readonly inFlight = new Map<string, AbortController>();
private readonly outgoing = new Map<string, OutgoingRequest>();
private readonly chunks = new Map<string, ChunkAssembly>();
private heartbeatSequence = 0;
private manuallyClosed = false;
private status: BridgeStatus = { state: 'disconnected', message: '未连接引擎' };
private pairingStatus: BridgePairingStatus = { state: 'idle', message: '尚未配对' };
getStatus(): BridgeStatus {
return this.status;
}
getPairingStatus(): BridgePairingStatus {
return this.pairingStatus;
}
emitEvent(method: string, params: unknown): void {
if (this.status.state === 'connected') this.send({ type: 'event', method, params });
}
requestEngine<T>(method: string, params: unknown, timeoutMs = ENGINE_REQUEST_TIMEOUT): Promise<T> {
if (this.status.state !== 'connected') return Promise.reject(new ExtensionError('bridge_disconnected', 'Yak 引擎未连接'));
if (!this.nativePort && this.socket?.readyState !== WebSocket.OPEN) {
return Promise.reject(new ExtensionError('bridge_disconnected', 'Yak 引擎连接不可用'));
}
if (this.outgoing.size >= MAX_OUTGOING_REQUESTS) {
return Promise.reject(new ExtensionError('server_busy', `插件到 Yak 的并行请求已达到 ${MAX_OUTGOING_REQUESTS} 个上限`));
}
if (this.status.capabilities && !this.status.capabilities.includes(method)) {
return Promise.reject(new ExtensionError('engine_capability_unavailable', `Yak 引擎不支持能力: ${method}`));
}
const id = `extension-${crypto.randomUUID()}`;
return new Promise<T>((resolve, reject) => {
const timer = globalThis.setTimeout(() => {
this.outgoing.delete(id);
this.send({ type: 'cancel', id });
reject(new ExtensionError('engine_timeout', `Yak 引擎请求超过 ${timeoutMs}ms`));
}, timeoutMs);
this.outgoing.set(id, { resolve: (value) => resolve(value as T), reject, timer });
try {
this.send({ type: 'request', id, method, params });
} catch (error) {
globalThis.clearTimeout(timer);
this.outgoing.delete(id);
reject(error instanceof Error ? error : new Error(String(error)));
}
});
}
async connect(config?: BridgeConfig): Promise<void> {
if (this.status.state === 'connected') return;
if (this.connectPromise) return this.connectPromise;
const effectiveConfig = config || (await getState()).bridge;
if (!effectiveConfig.pairedEngine) throw new Error('浏览器插件尚未与 Yak 引擎配对');
const attempt = (effectiveConfig.transport === 'native'
? this.connectNative(effectiveConfig)
: this.connectWebSocket(effectiveConfig)).catch((error) => {
const failure = error instanceof Error ? error : new Error(String(error));
if (!this.manuallyClosed && this.status.state !== 'error') this.setStatus({ state: 'error', message: failure.message });
throw failure;
});
const tracked = attempt.finally(() => {
if (this.connectPromise === tracked) this.connectPromise = undefined;
});
this.connectPromise = tracked;
return tracked;
}
disconnect(): void {
this.manuallyClosed = true;
if (this.reconnectTimer) globalThis.clearTimeout(this.reconnectTimer);
this.failHandshake(new Error('Bridge 连接已取消'));
this.stopHeartbeat();
this.abortInFlight();
this.rejectOutgoing(new ExtensionError('bridge_disconnected', 'Bridge 已断开'));
this.socket?.close(1000, 'user disconnected');
this.socket = undefined;
this.nativePort?.disconnect();
this.nativePort = undefined;
this.setStatus({ state: 'disconnected', message: '已手动断开' });
}
cancelActiveRequests(): void {
this.abortInFlight();
}
private async connectWebSocket(config: BridgeConfig): Promise<void> {
if (!isLoopbackEndpoint(config.endpoint)) {
throw new Error('Bridge 仅允许连接本机 ws://127.0.0.1、localhost 或 ::1');
}
this.manuallyClosed = false;
this.setStatus({ state: 'connecting', message: '正在连接本地 Yak 引擎' });
const socket = new WebSocket(config.endpoint);
this.socket = socket;
const negotiated = this.createHandshakePromise();
socket.addEventListener('open', () => this.setStatus({ state: 'negotiating', message: '正在验证 Yak 引擎与浏览器身份' }));
socket.addEventListener('message', (event) => void this.onMessage(String(event.data)));
socket.addEventListener('error', () => {
const error = new Error('Bridge 连接失败');
this.failHandshake(error);
this.setStatus({ state: 'error', message: error.message });
});
socket.addEventListener('close', () => {
this.stopHeartbeat();
if (this.socket === socket) {
this.socket = undefined;
this.abortInFlight();
this.rejectOutgoing(new ExtensionError('bridge_disconnected', '与 Yak 引擎的连接已断开'));
}
this.failHandshake(new Error('Bridge 在协议协商完成前断开'));
if (!this.manuallyClosed) this.setStatus({ state: 'disconnected', message: '与 Yak 引擎的连接已断开' });
if (!this.manuallyClosed && config.autoConnect) this.scheduleReconnect(config);
});
return negotiated;
}
private async connectNative(config: BridgeConfig): Promise<void> {
if (!config.nativeHost.trim()) throw new Error('Native Messaging Host 名称不能为空');
this.manuallyClosed = false;
this.setStatus({ state: 'connecting', message: '正在连接 Yakit Native Host' });
const port = browser.runtime.connectNative(config.nativeHost.trim());
this.nativePort = port;
const negotiated = this.createHandshakePromise();
port.onMessage.addListener((message) => void this.onMessage(message));
port.onDisconnect.addListener(() => {
const lastError = browser.runtime.lastError?.message;
if (this.nativePort === port) {
this.nativePort = undefined;
this.abortInFlight();
this.rejectOutgoing(new ExtensionError('bridge_disconnected', 'Native Host 已断开'));
}
this.failHandshake(new Error(lastError || 'Native Host 在协议协商完成前断开'));
this.stopHeartbeat();
this.setStatus({ state: lastError ? 'error' : 'disconnected', message: lastError || 'Native Host 已断开' });
if (!this.manuallyClosed && config.autoConnect) this.scheduleReconnect(config);
});
this.setStatus({ state: 'negotiating', message: '正在验证 Yak 引擎与浏览器身份' });
return negotiated;
}
private async answerChallenge(config: BridgeConfig, challenge: BridgeEnvelope): Promise<void> {
const paired = config.pairedEngine;
if (!paired || !challenge.publicKey || !challenge.engineIdentityId || !challenge.engineInstanceId || !challenge.challenge || !challenge.signature || !challenge.timestamp) {
throw new Error('Yak 引擎返回了不完整的身份挑战');
}
if (Math.abs(Date.now() - challenge.timestamp) > 60_000) throw new Error('Yak 引擎身份挑战已经过期');
if (paired.engineIdentityId !== challenge.engineIdentityId || !publicKeysEqual(paired.publicKey, challenge.publicKey)) {
throw new Error('Yak 引擎身份与首次配对记录不一致');
}
const verified = await verifyBridgePayload(challenge.publicKey, engineChallengePayload({
engineIdentityId: challenge.engineIdentityId,
engineInstanceId: challenge.engineInstanceId,
challenge: challenge.challenge,
timestamp: challenge.timestamp,
}), challenge.signature);
if (!verified) throw new Error('Yak 引擎身份签名验证失败');
const [state, previousSession] = await Promise.all([getState(), getBridgeRuntimeSession()]);
const identity = await getOrCreateBrowserBridgeIdentity(config.installationId);
const auth: BridgeEnvelope = {
type: 'auth',
client: 'yakit-browser-extension',
version: browser.runtime.getManifest().version,
protocolVersion: BRIDGE_PROTOCOL_VERSION,
capabilities: [...BRIDGE_CAPABILITIES],
installationId: config.installationId,
taskId: state.activeGrant?.taskId,
grantId: state.activeGrant?.id,
resumeSessionId: previousSession?.sessionId,
challenge: challenge.challenge,
};
auth.signature = await signBridgePayload(identity.privateKey, clientAuthPayload({
origin: browser.runtime.getURL('').replace(/\/$/, ''),
engineIdentityId: challenge.engineIdentityId,
engineInstanceId: challenge.engineInstanceId,
challenge: challenge.challenge,
envelope: auth,
}));
this.send(auth);
}
private createHandshakePromise(): Promise<void> {
this.failHandshake(new Error('Bridge 协议协商已被新连接替代'));
return new Promise<void>((resolve, reject) => {
this.handshakeResolve = resolve;
this.handshakeReject = reject;
this.handshakeTimer = globalThis.setTimeout(() => {
const error = new Error(`Bridge 协议协商超过 ${HANDSHAKE_TIMEOUT / 1_000}`);
this.failHandshake(error);
this.setStatus({ state: 'error', message: error.message });
this.socket?.close(1002, 'handshake timeout');
this.nativePort?.disconnect();
}, HANDSHAKE_TIMEOUT);
});
}
private async completeHandshake(message: BridgeEnvelope): Promise<void> {
if (!this.handshakeResolve) return;
if (this.handshakeTimer) globalThis.clearTimeout(this.handshakeTimer);
const resolve = this.handshakeResolve;
this.handshakeTimer = undefined;
this.handshakeResolve = undefined;
this.handshakeReject = undefined;
this.setStatus({
state: 'connected',
message: '已连接 Yak 引擎',
connectedAt: Date.now(),
engineVersion: message.version,
protocolVersion: message.protocolVersion,
capabilities: message.capabilities,
sessionId: message.sessionId,
engineInstanceId: message.engineInstanceId,
engineIdentityId: message.engineIdentityId,
connectionId: message.connectionId,
taskId: message.taskId,
grantId: message.grantId,
resumed: message.resumed,
});
await setBridgeRuntimeSession({
sessionId: message.sessionId!,
engineInstanceId: message.engineInstanceId!,
engineIdentityId: message.engineIdentityId,
taskId: message.taskId,
grantId: message.grantId,
updatedAt: Date.now(),
});
this.startHeartbeat();
resolve();
}
private failHandshake(error: Error): void {
if (this.handshakeTimer) globalThis.clearTimeout(this.handshakeTimer);
const reject = this.handshakeReject;
this.handshakeTimer = undefined;
this.handshakeResolve = undefined;
this.handshakeReject = undefined;
reject?.(error);
}
private async onMessage(raw: unknown): Promise<void> {
let message: BridgeEnvelope;
try {
message = parseBridgeEnvelope(raw);
} catch (error) {
const failure = error instanceof Error ? error : new Error(String(error));
if (this.status.state === 'negotiating') {
this.failHandshake(failure);
this.setStatus({ state: 'error', message: failure.message });
this.socket?.close(1002, 'invalid handshake');
this.nativePort?.disconnect();
}
return;
}
if (message.type === 'chunk') {
try {
const assembled = this.acceptChunk(message);
if (assembled !== undefined) await this.onMessage(assembled);
} catch (error) {
this.setStatus({ ...this.status, message: error instanceof Error ? error.message : String(error) });
}
return;
}
if (message.type === 'challenge') {
try {
await this.answerChallenge((await getState()).bridge, message);
} catch (error) {
const failure = error instanceof Error ? error : new Error(String(error));
this.failHandshake(failure);
this.setStatus({ state: 'error', message: failure.message });
this.socket?.close(1008, 'identity verification failed');
this.nativePort?.disconnect();
}
return;
}
if (message.type === 'hello_ack') {
await this.completeHandshake(message);
return;
}
if (message.type === 'response' && message.error && this.status.state === 'negotiating') {
const error = new Error(message.error.message || 'Bridge 拒绝连接');
this.failHandshake(error);
this.setStatus({ state: 'error', message: error.message });
return;
}
if (message.type === 'response' && message.id) {
const pending = this.outgoing.get(message.id);
if (!pending) return;
globalThis.clearTimeout(pending.timer);
this.outgoing.delete(message.id);
if (message.error) pending.reject(new ExtensionError(message.error.code, message.error.message));
else pending.resolve(message.result);
return;
}
if (message.type === 'ping') {
this.send({
type: 'pong', id: message.id, sequence: message.sequence,
timestamp: message.timestamp, replyTimestamp: Date.now(),
});
return;
}
if (message.type === 'pong') {
const now = Date.now();
const latencyMs = Math.max(0, now - Number(message.timestamp));
recordHeartbeat(latencyMs);
this.setStatus({
...this.status,
heartbeatSequence: message.sequence,
latencyMs,
lastHeartbeatAt: now,
});
return;
}
if (message.type === 'cancel' && message.id) {
this.inFlight.get(message.id)?.abort();
return;
}
if (this.status.state !== 'connected' || message.type !== 'request' || !message.id || !message.method) return;
if (this.inFlight.size >= MAX_CONCURRENT_REQUESTS) {
this.send({
type: 'response',
id: message.id,
error: { code: 'server_busy', message: `Bridge 并行请求已达到 ${MAX_CONCURRENT_REQUESTS} 个上限` },
});
void appendAuditEvent({
category: 'capability', action: message.method, outcome: 'denied', errorCode: 'server_busy',
targetTabId: typeof (message.params as { tabId?: unknown } | undefined)?.tabId === 'number'
? (message.params as { tabId: number }).tabId
: undefined,
});
return;
}
if (this.inFlight.has(message.id)) {
this.send({
type: 'response', id: message.id,
error: { code: 'duplicate_request_id', message: 'Bridge 请求 ID 正在使用中' },
});
return;
}
const controller = new AbortController();
this.inFlight.set(message.id, controller);
const cancelled = new Promise<never>((_, reject) => {
controller.signal.addEventListener('abort', () => reject(new ExtensionError('cancelled', 'Bridge 请求已取消')), { once: true });
});
const startedAt = performance.now();
let taskId: string | undefined;
let actionId: string | undefined;
let targetTabId = typeof (message.params as { tabId?: unknown } | undefined)?.tabId === 'number'
? (message.params as { tabId: number }).tabId
: undefined;
try {
const grant = (await getState()).activeGrant;
taskId = grant?.taskId;
targetTabId ??= grant?.targets[0]?.tabId;
if (grant) {
actionId = (await beginAgentAction(grant, {
requestId: message.id, method: message.method, targetTabId,
})).id;
}
const operation = routeCapability(message.method, message.params, (method, params) => this.requestEngine(method, params));
const result = await Promise.race([operation, cancelled]);
const durationMs = performance.now() - startedAt;
this.send({ type: 'response', id: message.id, result });
if (actionId) void finishAgentAction(actionId, 'success');
recordCapabilityMetric(message.method, durationMs, false);
void appendAuditEvent({
category: 'capability', action: message.method, outcome: 'success', taskId,
targetTabId, durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
});
} catch (error) {
const code = errorCode(error);
recordCapabilityMetric(message.method, performance.now() - startedAt, true);
this.send({
type: 'response',
id: message.id,
error: { code, message: error instanceof Error ? error.message : String(error) },
});
if (actionId) {
void finishAgentAction(
actionId,
code === 'cancelled' ? 'cancelled' : isDeniedErrorCode(code) ? 'denied' : 'error',
code,
);
}
void appendAuditEvent({
category: 'capability', action: message.method,
outcome: code === 'cancelled' ? 'cancelled' : isDeniedErrorCode(code) ? 'denied' : 'error',
taskId, targetTabId, errorCode: code,
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
});
} finally {
this.inFlight.delete(message.id);
}
}
private send(message: BridgeEnvelope): void {
let encoded = JSON.stringify(message);
if (new TextEncoder().encode(encoded).byteLength > BRIDGE_MAX_MESSAGE_BYTES) {
if (message.type !== 'response' || !message.id) throw new Error('Bridge 出站消息超过 16 MiB 限制');
encoded = JSON.stringify({
type: 'response',
id: message.id,
error: { code: 'payload_too_large', message: 'Bridge 响应超过 16 MiB 限制' },
} satisfies BridgeEnvelope);
}
const bytes = new TextEncoder().encode(encoded);
if (bytes.byteLength > BRIDGE_CHUNK_THRESHOLD_BYTES) {
const transferId = `chunk-${crypto.randomUUID()}`;
const total = Math.ceil(bytes.byteLength / BRIDGE_CHUNK_BYTES);
for (let index = 0; index < total; index += 1) {
const start = index * BRIDGE_CHUNK_BYTES;
this.sendRaw(JSON.stringify({
type: 'chunk', transferId, index, total, originalBytes: bytes.byteLength,
data: bytesToBase64(bytes.subarray(start, Math.min(start + BRIDGE_CHUNK_BYTES, bytes.byteLength))),
} satisfies BridgeEnvelope));
}
return;
}
this.sendRaw(encoded);
}
private sendRaw(encoded: string): void {
if (this.nativePort) {
this.nativePort.postMessage(JSON.parse(encoded) as BridgeEnvelope);
return;
}
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(encoded);
}
private acceptChunk(message: BridgeEnvelope): string | undefined {
const now = Date.now();
for (const [id, assembly] of this.chunks) {
if (now - assembly.createdAt > BRIDGE_CHUNK_TIMEOUT_MS) this.chunks.delete(id);
}
const transferId = message.transferId!;
let assembly = this.chunks.get(transferId);
if (!assembly) {
if (this.chunks.size >= BRIDGE_MAX_CHUNK_TRANSFERS) throw new Error('Bridge 并行分片传输超过上限');
assembly = {
createdAt: now, total: message.total!, originalBytes: message.originalBytes!,
parts: new Array<Uint8Array | undefined>(message.total!),
};
this.chunks.set(transferId, assembly);
}
if (assembly.total !== message.total || assembly.originalBytes !== message.originalBytes) {
this.chunks.delete(transferId);
throw new Error('Bridge 分片元数据不一致');
}
const part = base64ToBytes(message.data!);
if (part.byteLength > BRIDGE_CHUNK_BYTES || (message.index! < assembly.total - 1 && part.byteLength !== BRIDGE_CHUNK_BYTES)) {
this.chunks.delete(transferId);
throw new Error('Bridge 分片大小无效');
}
assembly.parts[message.index!] = part;
if (assembly.parts.some((item) => item === undefined)) return undefined;
const bytes = new Uint8Array(assembly.originalBytes);
let offset = 0;
for (const item of assembly.parts) {
bytes.set(item!, offset);
offset += item!.byteLength;
}
this.chunks.delete(transferId);
if (offset !== assembly.originalBytes) throw new Error('Bridge 分片重组大小不匹配');
return new TextDecoder().decode(bytes);
}
private scheduleReconnect(config: BridgeConfig): void {
if (this.reconnectTimer) globalThis.clearTimeout(this.reconnectTimer);
this.reconnectTimer = globalThis.setTimeout(() => void this.connect(config).catch(() => undefined), RECONNECT_DELAY);
}
private abortInFlight(): void {
for (const controller of this.inFlight.values()) controller.abort();
this.inFlight.clear();
this.chunks.clear();
}
private rejectOutgoing(error: Error): void {
for (const pending of this.outgoing.values()) {
globalThis.clearTimeout(pending.timer);
pending.reject(error);
}
this.outgoing.clear();
}
private startHeartbeat(): void {
this.stopHeartbeat();
const ping = () => {
const sequence = ++this.heartbeatSequence;
this.send({ type: 'ping', id: `heartbeat-${sequence}`, sequence, timestamp: Date.now() });
};
ping();
this.heartbeatTimer = globalThis.setInterval(ping, HEARTBEAT_INTERVAL);
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) globalThis.clearInterval(this.heartbeatTimer);
this.heartbeatTimer = undefined;
}
private setStatus(status: BridgeStatus): void {
if (status.state !== this.status.state && ['connecting', 'connected', 'disconnected', 'error'].includes(status.state)) {
recordBridgeState(status.state as 'connecting' | 'connected' | 'disconnected' | 'error');
}
this.status = status;
void browser.runtime.sendMessage({ action: STATUS_EVENT, payload: status }).catch(() => undefined);
}
async startPairing(): Promise<BridgePairingStatus> {
const config = (await getState()).bridge;
if (config.pairedEngine) return { state: 'approved', message: '当前浏览器已经完成配对', engineIdentityId: config.pairedEngine.engineIdentityId };
if (!isLoopbackEndpoint(config.endpoint)) throw new Error('配对仅允许访问本机 Yak Bridge');
if (this.pairingSocket && ['requesting', 'pending'].includes(this.pairingStatus.state)) return this.pairingStatus;
this.cancelPairing(false);
const identity = await getOrCreateBrowserBridgeIdentity(config.installationId);
const clientNonce = randomBridgeNonce();
const pairingURL = new URL(config.endpoint);
pairingURL.pathname = '/pairing';
pairingURL.search = '';
pairingURL.hash = '';
const socket = new WebSocket(pairingURL.toString());
this.pairingSocket = socket;
this.pairingContext = { config, clientNonce, publicKey: identity.publicKey, privateKey: identity.privateKey };
this.setPairingStatus({ state: 'requesting', message: '正在向本机 Yak 引擎申请配对' });
const pending = new Promise<BridgePairingStatus>((resolve, reject) => {
this.pairingResolve = resolve;
this.pairingReject = reject;
this.pairingTimer = globalThis.setTimeout(() => {
const error = new Error('Yak 引擎配对请求超过 5 秒未响应');
this.failPairing(error);
socket.close(1000, 'pairing timeout');
}, HANDSHAKE_TIMEOUT);
});
socket.addEventListener('open', () => {
socket.send(JSON.stringify({
type: 'pair_request', protocolVersion: BRIDGE_PROTOCOL_VERSION,
installationId: config.installationId,
client: 'yakit-browser-extension', version: browser.runtime.getManifest().version,
nonce: clientNonce, publicKey: identity.publicKey,
} satisfies BridgePairingEnvelope));
});
socket.addEventListener('message', (event) => void this.onPairingMessage(String(event.data)));
socket.addEventListener('error', () => this.failPairing(new Error('无法连接本机 Yak 配对服务')));
socket.addEventListener('close', () => {
if (this.pairingSocket === socket) this.pairingSocket = undefined;
if (['requesting', 'pending'].includes(this.pairingStatus.state)) this.failPairing(new Error('Yak 配对连接已断开'));
});
return pending;
}
cancelPairing(notify = true): BridgePairingStatus {
if (this.pairingTimer) globalThis.clearTimeout(this.pairingTimer);
this.pairingTimer = undefined;
this.pairingResolve = undefined;
this.pairingReject = undefined;
this.pairingContext = undefined;
this.pairingSocket?.close(1000, 'pairing cancelled');
this.pairingSocket = undefined;
const status: BridgePairingStatus = { state: 'idle', message: '配对已取消' };
if (notify) this.setPairingStatus(status);
return status;
}
async unpair(): Promise<void> {
const state = await getState();
this.disconnect();
this.cancelPairing(false);
await clearBrowserBridgeIdentity(state.bridge.installationId);
await updateState((current) => ({
...current,
bridge: {
...current.bridge,
pairedEngine: undefined,
autoConnect: false,
},
}));
this.setPairingStatus({ state: 'idle', message: '本地配对凭据已清除,浏览器安装身份保持不变' });
}
private async onPairingMessage(raw: unknown): Promise<void> {
let message: BridgePairingEnvelope;
try {
message = parseBridgePairingEnvelope(raw);
} catch (error) {
this.failPairing(error instanceof Error ? error : new Error(String(error)));
return;
}
const context = this.pairingContext;
if (!context) return;
if (message.type === 'pair_pending') {
const code = await pairingVerificationCode({
engineIdentityId: message.engineIdentityId!, requestId: message.requestId!,
origin: browser.runtime.getURL('').replace(/\/$/, ''), installationId: context.config.installationId,
clientNonce: context.clientNonce, serverNonce: message.serverNonce!, publicKey: context.publicKey,
});
if (code !== message.code) {
this.failPairing(new Error('Yak 配对验证码校验失败'));
this.pairingSocket?.close(1008, 'pairing transcript mismatch');
return;
}
context.requestId = message.requestId;
context.engineIdentityId = message.engineIdentityId;
context.enginePublicKey = message.publicKey;
if (this.pairingTimer) globalThis.clearTimeout(this.pairingTimer);
this.pairingTimer = undefined;
const status: BridgePairingStatus = {
state: 'pending', message: '请在 Yakit 中确认相同的验证码',
requestId: message.requestId, code, engineIdentityId: message.engineIdentityId, expiresAt: message.expiresAt,
};
this.setPairingStatus(status);
this.pairingResolve?.(status);
this.pairingResolve = undefined;
this.pairingReject = undefined;
return;
}
if (message.type === 'pair_approved') {
if (!context.requestId || message.requestId !== context.requestId || !context.engineIdentityId || !context.enginePublicKey
|| message.engineIdentityId !== context.engineIdentityId || !message.publicKey || !publicKeysEqual(message.publicKey, context.enginePublicKey)) {
this.failPairing(new Error('Yak 配对批准信息与当前申请不一致'));
return;
}
const next = await updateState((current) => ({
...current,
bridge: {
...current.bridge,
autoConnect: true,
pairedEngine: {
engineIdentityId: message.engineIdentityId!, deviceId: message.deviceId!,
publicKey: message.publicKey!, pairedAt: Date.now(),
},
},
}));
this.setPairingStatus({ state: 'approved', message: '已与 Yak 引擎安全配对', engineIdentityId: message.engineIdentityId });
this.pairingContext = undefined;
this.pairingSocket?.close(1000, 'pairing approved');
this.pairingSocket = undefined;
await this.connect(next.bridge);
return;
}
const state = message.type === 'pair_rejected' ? 'rejected' : message.type === 'pair_expired' ? 'expired' : 'error';
const status: BridgePairingStatus = { state, message: message.message || 'Yak 引擎拒绝了配对申请', requestId: message.requestId };
this.setPairingStatus(status);
this.pairingContext = undefined;
this.pairingSocket?.close(1000, state);
this.pairingSocket = undefined;
}
private failPairing(error: Error): void {
if (this.pairingTimer) globalThis.clearTimeout(this.pairingTimer);
this.pairingTimer = undefined;
this.pairingReject?.(error);
this.pairingResolve = undefined;
this.pairingReject = undefined;
this.pairingContext = undefined;
this.setPairingStatus({ state: 'error', message: error.message });
}
private setPairingStatus(status: BridgePairingStatus): void {
this.pairingStatus = status;
void browser.runtime.sendMessage({ action: PAIRING_STATUS_EVENT, payload: status }).catch(() => undefined);
}
}
export const engineBridge = new EngineBridge();
@@ -0,0 +1,219 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
AlertTriangle, Braces, Check, ChevronLeft, ChevronRight, Copy, ExternalLink, GripVertical,
EyeOff, Network, Pause, Play, Radio, RefreshCw, Settings, ShieldCheck, X,
} from 'lucide-react';
import { browser } from 'wxt/browser';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { HANDOFF_REASON_LABELS, waitingHandoff } from '@/features/handoff/presentation';
import { READ_CAPABILITY_SCOPES, isControlScopeSet } from '@/protocol/capabilities';
import { AGENT_RUNTIME_STORAGE_KEY, isStateStorageChange } from '@/protocol/storage';
import type { ActiveTabInfo, AgentRuntime, BridgeStatus, ExtensionState, PageContext } from '@/types/models';
import { errorMessage, request } from '@/platform/messaging/runtime';
interface FloatingPanelProps {
initialState: ExtensionState;
initialTab?: ActiveTabInfo;
initialBridge: BridgeStatus;
yakIconUrl: string;
embedded?: boolean;
}
export function FloatingPanel({ initialState, initialTab, initialBridge, yakIconUrl, embedded = false }: FloatingPanelProps) {
const [state, setState] = useState(initialState);
const [bridge, setBridge] = useState(initialBridge);
const [tab] = useState(initialTab);
const [expanded, setExpanded] = useState(embedded);
const [side, setSide] = useState(initialState.floatingPanel.side);
const [y, setY] = useState(initialState.floatingPanel.y);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState('');
const [context, setContext] = useState<PageContext>();
const [runtime, setRuntime] = useState<AgentRuntime>({ state: 'idle', updatedAt: Date.now(), actions: [] });
const drag = useRef<{ pointerId: number; startX: number; startY: number; moved: boolean } | undefined>(undefined);
const activeProfile = useMemo(
() => state.proxyProfiles.find((profile) => profile.id === state.activeProxyId),
[state],
);
const grantActive = Boolean(
state.activeGrant && state.activeGrant.expiresAt > Date.now() && tab && state.activeGrant.targets.some((target) => target.tabId === tab.id),
);
const pendingHandoff = waitingHandoff(state.handoff);
const handoff = pendingHandoff?.target.tabId === tab?.id ? pendingHandoff : undefined;
useEffect(() => {
void request('agent.runtime.get').then(setRuntime).catch(() => undefined);
const listener = (changes: Record<string, unknown>) => {
if (isStateStorageChange(changes)) {
void request('state.get').then((next) => {
setState(next);
setSide(next.floatingPanel.side);
setY(next.floatingPanel.y);
}).catch(() => undefined);
}
if (AGENT_RUNTIME_STORAGE_KEY in changes) void request('agent.runtime.get').then(setRuntime).catch(() => undefined);
};
browser.storage.onChanged.addListener(listener);
return () => browser.storage.onChanged.removeListener(listener);
}, []);
useEffect(() => {
const listener = (message: unknown) => {
const input = message as { action?: string; payload?: BridgeStatus };
if (input?.action === 'bridge.status.changed' && input.payload) setBridge(input.payload);
};
browser.runtime.onMessage.addListener(listener);
return () => browser.runtime.onMessage.removeListener(listener);
}, []);
// Embedded mode: report natural content height so the host shell can size the iframe (no dead space, internal scroll when clamped).
useEffect(() => {
if (!embedded) return undefined;
const post = () => {
const header = document.querySelector('.floating-panel__header');
const body = document.querySelector('.floating-panel__body');
const height = (header?.getBoundingClientRect().height || 46) + (body?.scrollHeight || 0);
window.parent.postMessage({ channel: 'yakit-floating-host', type: 'resize', height: Math.ceil(height) }, '*');
};
post();
const observer = new ResizeObserver(post);
observer.observe(document.body);
return () => observer.disconnect();
}, [embedded]);
const run = async (task: () => Promise<void>) => {
setBusy(true);
setNotice('');
try {
await task();
} catch (error) {
setNotice(errorMessage(error));
} finally {
setBusy(false);
}
};
const openWorkspace = (section: string) => {
const target = tab ? `?tabId=${tab.id}` : '';
window.open(browser.runtime.getURL(`/options.html${target}#${section}`), '_blank', 'noopener');
};
const hideCurrentSite = () => run(async () => {
if (!tab?.url) return;
const origin = new URL(tab.url).origin;
const current = state.floatingPanel;
const siteOrigins = current.siteMode === 'allowlist'
? current.siteOrigins.filter((item) => item !== origin)
: [...new Set([...current.siteOrigins, origin])];
setState(await request('panel.update', {
siteMode: current.siteMode === 'allowlist' ? 'allowlist' : 'denylist', siteOrigins,
}));
});
const onPointerDown = (event: React.PointerEvent<HTMLElement>) => {
if (event.button !== 0) return;
drag.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, moved: false };
event.currentTarget.setPointerCapture(event.pointerId);
};
const onPointerMove = (event: React.PointerEvent<HTMLElement>) => {
const current = drag.current;
if (!current || current.pointerId !== event.pointerId) return;
if (Math.hypot(event.clientX - current.startX, event.clientY - current.startY) > 4) current.moved = true;
if (!current.moved) return;
setY(Math.min(Math.max(event.clientY / window.innerHeight, 0.08), 0.92));
setSide(event.clientX < window.innerWidth / 2 ? 'left' : 'right');
};
const onPointerUp = (event: React.PointerEvent<HTMLElement>) => {
const current = drag.current;
if (!current || current.pointerId !== event.pointerId) return;
drag.current = undefined;
if (current.moved) {
const nextSide = event.clientX < window.innerWidth / 2 ? 'left' : 'right';
const nextY = Math.min(Math.max(event.clientY / window.innerHeight, 0.08), 0.92);
setSide(nextSide);
setY(nextY);
void request('panel.update', { side: nextSide, y: nextY }).then(setState).catch(() => undefined);
} else {
setExpanded((value) => !value);
}
};
if (!state.floatingPanel.enabled) return null;
const collapseEmbedded = () => window.parent.postMessage({ channel: 'yakit-floating-host', type: 'collapse' }, '*');
return (
<div className={`floating-panel floating-panel--${side} ${embedded ? 'floating-panel--embedded' : ''} ${expanded ? 'is-expanded' : ''}`} style={embedded ? undefined : { top: `${y * 100}%` }}>
<div className="floating-panel__header" onClick={embedded ? collapseEmbedded : undefined} onPointerDown={embedded ? undefined : onPointerDown} onPointerMove={embedded ? undefined : onPointerMove} onPointerUp={embedded ? undefined : onPointerUp}>
<button className="floating-panel__brand" aria-label={expanded ? '收起 Yakit Browser Agent' : '展开 Yakit Browser Agent'}>
<img src={yakIconUrl} alt="Yak" draggable={false} />
<span className={`floating-panel__signal ${bridge.state}`} />
</button>
{expanded && <>
<div className="floating-panel__title">
<strong>Yakit Browser Agent</strong>
<span>{activeProfile?.name || (state.activeProxyId === 'rules' ? '按规则分流' : '浏览器工具')}</span>
</div>
<GripVertical className="floating-panel__grip" size={15} aria-hidden="true" />
{side === 'right' ? <ChevronRight size={15} /> : <ChevronLeft size={15} />}
</>}
</div>
{expanded && (
<div className="floating-panel__body">
<Tabs key={handoff?.id || 'default'} defaultValue={handoff ? 'agent' : 'proxy'}>
<TabsList className="floating-tabs">
<TabsTrigger value="proxy"><Network size={13} /></TabsTrigger>
<TabsTrigger value="context"><Braces size={13} /></TabsTrigger>
<TabsTrigger value="agent">{handoff ? <AlertTriangle size={13} /> : <ShieldCheck size={13} />}Agent</TabsTrigger>
</TabsList>
<TabsContent value="proxy" className="floating-tab-content">
<div className="floating-section-heading"><span></span><Button size="icon" variant="ghost" title="代理设置" onClick={() => openWorkspace('proxies')}><Settings size={15} /></Button></div>
<div className="floating-option-list">
{state.proxyProfiles.map((profile) => (
<button key={profile.id} className={state.activeProxyId === profile.id ? 'is-active' : ''} disabled={busy} onClick={() => void run(async () => setState(await request('proxy.switch', { id: profile.id })))}>
<i className="floating-radio" />
<span><strong>{profile.name}</strong><small>{profile.kind === 'fixed_servers' ? `${profile.host}:${profile.port}` : profile.kind}</small></span>
</button>
))}
{state.proxyRules.length > 0 && <button className={state.activeProxyId === 'rules' ? 'is-active' : ''} disabled={busy} onClick={() => void run(async () => setState(await request('proxy.rules.apply')))}><i className="floating-radio" /><span><strong></strong><small>{state.proxyRules.filter((rule) => rule.enabled).length} </small></span></button>}
</div>
</TabsContent>
<TabsContent value="context" className="floating-tab-content">
<div className="floating-page-meta"><strong title={tab?.title}>{tab?.title || '当前页面不可访问'}</strong><span title={tab?.url}>{tab?.url || '仅支持 HTTP(S) 页面'}</span></div>
<Button variant="primary" disabled={busy || !tab?.url?.startsWith('http')} onClick={() => void run(async () => setContext(await request('context.capture', { includeDom: true, includeStorage: true, includeCookies: true, tabId: tab?.id })))}>
{busy ? <RefreshCw className="spin" size={14} /> : <Radio size={14} />}
</Button>
{context && <div className="floating-result"><span>{context.document?.forms.length || 0} · {context.document?.interactive.length || 0} </span><Button size="icon" variant="ghost" title="复制上下文 JSON" onClick={() => void navigator.clipboard.writeText(JSON.stringify(context, null, 2))}><Copy size={14} /></Button></div>}
<Button variant="ghost" onClick={() => openWorkspace('context')}><ExternalLink size={14} /></Button>
</TabsContent>
<TabsContent value="agent" className="floating-tab-content">
<div className="floating-status-row"><span className={`floating-dot ${bridge.state}`} /><span><strong>{bridge.state === 'connected' ? 'Yak 引擎在线' : 'Yak 引擎离线'}</strong><small>{bridge.message}</small></span><Button size="sm" variant="ghost" disabled={busy} onClick={() => void run(async () => { if (bridge.state === 'connected') await request('bridge.disconnect'); else await request('bridge.connect'); setBridge(await request('bridge.status')); })}>{bridge.state === 'connected' ? '断开' : '连接'}</Button></div>
{handoff ? <div className="floating-handoff" aria-live="assertive">
<div className="floating-handoff__copy"><AlertTriangle size={16} /><span><strong>{HANDOFF_REASON_LABELS[handoff.reason]}</strong><small>{handoff.message}</small></span></div>
<div className="floating-handoff__actions">
<Button variant="primary" disabled={busy} onClick={() => void run(async () => setState(await request('handoff.resolve', { id: handoff.id, outcome: 'completed' })))}><Check size={14} /></Button>
<Button variant="ghost" disabled={busy} onClick={() => void run(async () => setState(await request('handoff.resolve', { id: handoff.id, outcome: 'cancelled' })))}><X size={14} /></Button>
</div>
</div> : <>
{grantActive && <div className={`floating-agent-task ${runtime.state}`}><span><strong>{runtime.state === 'paused' ? 'Agent 已暂停' : runtime.state === 'running' ? 'Agent 正在操作' : '共享会话活动'}</strong><small title={state.activeGrant?.taskId}>{state.activeGrant?.taskId} · {state.activeGrant && isControlScopeSet(state.activeGrant.scopes) ? '控制权限' : '只读权限'}</small></span>{runtime.state === 'paused' ? <Button size="icon" variant="ghost" title="恢复 Agent" onClick={() => void run(async () => setRuntime(await request('agent.resume')))}><Play size={14} /></Button> : <Button size="icon" variant="ghost" title="暂停 Agent" onClick={() => void run(async () => setRuntime(await request('agent.pause')))}><Pause size={14} /></Button>}</div>}
<label className="floating-share-row"><span><strong> frame</strong><small>30 </small></span><Switch checked={grantActive} disabled={!tab || busy} onCheckedChange={(checked) => void run(async () => setState(checked ? await request('grant.create', { targets: [{ tabId: tab!.id, frameId: 0 }], scopes: READ_CAPABILITY_SCOPES, durationMinutes: 30 }) : await request('grant.revoke')))} /></label>
<Button variant="secondary" onClick={() => openWorkspace('engine')}><Settings size={14} /></Button>
<Button variant="ghost" disabled={!tab?.url} onClick={() => void hideCurrentSite()}><EyeOff size={14} /></Button>
</>}
</TabsContent>
</Tabs>
{notice && <div className="floating-notice">{notice}</div>}
</div>
)}
</div>
);
}
+314
View File
@@ -0,0 +1,314 @@
import { browser } from 'wxt/browser';
import {
clearNetworkRequests, exportNetworkRequest, listNetworkRequests, networkCaptureStatus,
redactNetworkRequests, startNetworkCapture, stopNetworkCapture,
stopNetworkCapturesForGrant,
} from '@/features/network-capture/service';
import {
clearPageObservations, listPageObservations, pageObservationStatus, startPageObservation,
stopPageObservation, stopPageObservationsForGrant,
} from '@/features/page-observation/service';
import { capturedRequestEnginePayload } from '@/features/network-capture/workflows';
import type {
BridgeGrant, BrowserRequestAnalysisBundle, BrowserTarget, CapabilityScope, HandoffReason,
PageContextOptions, YakPocGenerateResult,
} from '@/types/models';
import { CONTROL_CAPABILITY_SCOPES, READ_CAPABILITY_SCOPES } from '@/protocol/capabilities';
import { parseCapabilityParams } from '@/protocol/bridge';
import { getFrameInventory } from '@/features/page-context/frames';
import { activateTab, getTab, resolveDocumentTarget } from '@/platform/browser/targets';
import {
actOnPageNode, capturePageContext, evalInPage, inspectPageNode, invokePageFunction,
} from '@/features/page-context/service';
import { listCookies } from '@/features/cookies/service';
import { getState, updateState } from '@/platform/storage/state';
import { switchProxy } from '@/features/proxy/service';
import { ExtensionError } from '@/shared/errors';
import { setAgentRuntimeState } from '@/features/agent-runtime/service';
export { CONTROL_CAPABILITY_SCOPES, READ_CAPABILITY_SCOPES } from '@/protocol/capabilities';
const CAPABILITY_SCOPES: Record<string, CapabilityScope> = {
'browser.tabs': 'browser.tabs.read',
'browser.frames': 'browser.tabs.read',
'browser.context': 'browser.dom.read',
'browser.node.inspect': 'browser.dom.read',
'browser.node.action': 'browser.dom.write',
'browser.cookies': 'browser.cookies.read',
'browser.takeover': 'browser.tab.activate',
'browser.handoff.request': 'browser.human.takeover',
'browser.handoff.status': 'browser.human.takeover',
'browser.network.start': 'browser.network.capture',
'browser.network.status': 'browser.network.read',
'browser.network.list': 'browser.network.read',
'browser.network.clear': 'browser.network.capture',
'browser.network.stop': 'browser.network.capture',
'browser.network.export': 'browser.network.sensitive.read',
'browser.network.poc': 'browser.network.sensitive.read',
'browser.network.analysis': 'browser.network.sensitive.read',
'browser.observe.start': 'browser.observation.control',
'browser.observe.status': 'browser.observation.read',
'browser.observe.list': 'browser.observation.read',
'browser.observe.clear': 'browser.observation.control',
'browser.observe.stop': 'browser.observation.control',
'browser.invoke': 'browser.page.invoke',
'browser.eval': 'browser.page.eval.expression',
'proxy.list': 'browser.proxy.read',
'proxy.switch': 'browser.proxy.write',
};
async function activeGrant(required: CapabilityScope): Promise<BridgeGrant> {
const state = await getState();
const grant = state.activeGrant;
if (!grant || grant.expiresAt <= Date.now()) {
if (grant) {
const state = await updateState((current) => ({
...current,
activeGrant: undefined,
handoff: current.handoff?.state === 'waiting_for_user'
? { ...current.handoff, state: 'cancelled', resolvedAt: Date.now() }
: current.handoff,
}));
await Promise.all([
stopNetworkCapturesForGrant(grant.id),
stopPageObservationsForGrant(grant.id),
]);
await setAgentRuntimeState('expired', grant);
if (state.handoff) await browser.action.setBadgeText({ text: '', tabId: state.handoff.target.tabId });
}
throw new ExtensionError('grant_expired', '浏览器共享会话不存在或已经过期');
}
if (!grant.scopes.includes(required)) throw new ExtensionError('permission_denied', `共享会话未授权能力: ${required}`);
return grant;
}
function originOf(url: string): string {
try {
const origin = new URL(url).origin;
return origin === 'null' ? '' : origin;
} catch {
return '';
}
}
async function allowedTarget(grant: BridgeGrant, input: Record<string, unknown>): Promise<BrowserTarget> {
const requested = typeof input.tabId === 'number' ? input.tabId : grant.targets[0]?.tabId;
const requestedFrameId = typeof input.frameId === 'number' ? input.frameId : 0;
const target = grant.targets.find((item) => item.tabId === requested && item.frameId === requestedFrameId);
if (!target) throw new ExtensionError('target_denied', '目标标签页不在本次共享会话中');
const currentFrame = await browser.webNavigation.getFrame({ tabId: target.tabId, frameId: target.frameId });
if (!currentFrame) throw new ExtensionError('target_unavailable', '目标 frame 已不存在');
let currentOrigin = originOf(currentFrame.url);
if (!currentOrigin) {
currentOrigin = (await getFrameInventory(target.tabId)).find((frame) => frame.frameId === target.frameId)?.origin || '';
}
if (currentOrigin !== target.origin) throw new ExtensionError('origin_changed', '目标 frame 已经跨来源导航,请重新授权');
if (target.documentId && currentFrame.documentId && target.documentId !== currentFrame.documentId) {
throw new ExtensionError('stale_document', '目标 frame 已经刷新或导航,请重新授权');
}
if (typeof input.documentId === 'string' && target.documentId && input.documentId !== target.documentId) {
throw new ExtensionError('stale_document', '请求的页面文档已经失效,请重新授权');
}
const resolved = await resolveDocumentTarget(target);
if (target.documentId && resolved.documentId && target.documentId !== resolved.documentId) {
throw new ExtensionError('stale_document', '目标页面已经刷新或导航,请重新授权');
}
return resolved;
}
function requireScope(grant: BridgeGrant, scope: CapabilityScope): void {
if (!grant.scopes.includes(scope)) throw new ExtensionError('permission_denied', `共享会话未授权能力: ${scope}`);
}
export async function routeCapability(
method: string,
params: unknown,
requestEngine?: <T>(method: string, params: unknown) => Promise<T>,
): Promise<unknown> {
if (method === 'system.ping') return { now: Date.now(), extensionVersion: browser.runtime.getManifest().version };
if (import.meta.env.FIREFOX && import.meta.env.MODE === 'store' && ['browser.invoke', 'browser.eval'].includes(method)) {
throw new ExtensionError('channel_unavailable', 'Firefox AMO 渠道不提供页面函数调用或通用 Eval');
}
const input = parseCapabilityParams(method, params);
const required = method === 'browser.eval' && input.mode === 'program'
? 'browser.page.eval.program'
: CAPABILITY_SCOPES[method];
if (!required) throw new Error(`不支持的 Bridge 方法: ${method}`);
const grant = await activeGrant(required);
if (method === 'browser.tabs') {
const tabIds = [...new Set(grant.targets.map((target) => target.tabId))];
const tabs = await Promise.all(tabIds.map(async (tabId) => {
const targets = grant.targets.filter((target) => target.tabId === tabId);
for (const target of targets) {
try {
await allowedTarget(grant, { tabId, frameId: target.frameId, documentId: target.documentId });
return getTab(tabId);
} catch {
// A tab remains visible while at least one explicitly granted frame is current.
}
}
return undefined;
}));
return tabs.filter(Boolean);
}
if (method === 'browser.frames') {
const tabId = typeof input.tabId === 'number' ? input.tabId : grant.targets[0]?.tabId;
if (!tabId || !grant.targets.some((target) => target.tabId === tabId)) {
throw new ExtensionError('target_denied', '目标标签页不在本次共享会话中');
}
return getFrameInventory(tabId);
}
if (method === 'browser.handoff.status') {
const handoff = (await getState()).handoff;
return handoff?.taskId === grant.taskId ? handoff : { state: 'idle' };
}
if (method === 'browser.handoff.request') {
const resolvedTarget = await allowedTarget(grant, input);
const grantTarget = grant.targets.find((target) => target.tabId === resolvedTarget.tabId && target.frameId === resolvedTarget.frameId);
if (!grantTarget) throw new Error('目标标签页不在本次共享会话中');
const now = Date.now();
const state = await updateState((current) => {
if (current.activeGrant?.id !== grant.id || current.activeGrant.expiresAt <= Date.now()) {
throw new ExtensionError('grant_expired', '浏览器共享会话已经变化,请重新发起请求');
}
if (current.handoff?.state === 'waiting_for_user') {
throw new ExtensionError('handoff_in_progress', '已有人工接管请求正在等待处理');
}
return {
...current,
handoff: {
id: crypto.randomUUID(),
taskId: grant.taskId,
target: grantTarget,
reason: input.reason as HandoffReason,
message: typeof input.message === 'string' ? input.message : '',
state: 'waiting_for_user',
requestedAt: now,
},
};
});
await activateTab(resolvedTarget.tabId);
await browser.action.setBadgeBackgroundColor({ color: '#ee7815' });
await browser.action.setBadgeText({ text: '待确认', tabId: resolvedTarget.tabId });
await setAgentRuntimeState('waiting_for_human', grant);
return state.handoff;
}
if (method.startsWith('browser.network.')) {
const target = await allowedTarget(grant, input);
if (method === 'browser.network.start') {
if (input.captureHeaders === true || input.captureBody === true) requireScope(grant, 'browser.network.sensitive.read');
return startNetworkCapture(target, {
captureHeaders: input.captureHeaders === true,
captureBody: input.captureBody === true,
maxEntries: typeof input.maxEntries === 'number' ? input.maxEntries : undefined,
maxBodyBytes: typeof input.maxBodyBytes === 'number' ? input.maxBodyBytes : undefined,
}, { kind: 'grant', grantId: grant.id, expiresAt: grant.expiresAt });
}
if (method === 'browser.network.status') return networkCaptureStatus(target);
if (method === 'browser.network.list') {
const records = await listNetworkRequests(target, typeof input.limit === 'number' ? input.limit : 100);
return grant.scopes.includes('browser.network.sensitive.read') ? records : redactNetworkRequests(records);
}
if (method === 'browser.network.clear') return clearNetworkRequests(target);
if (method === 'browser.network.stop') return stopNetworkCapture(target);
if (method === 'browser.network.export') return exportNetworkRequest(target, String(input.id));
if (method === 'browser.network.poc') {
if (!requestEngine) throw new ExtensionError('bridge_disconnected', 'Yak 引擎请求通道不可用');
return requestEngine<YakPocGenerateResult>('yakit.poc.generate', await capturedRequestEnginePayload(target, String(input.id), false));
}
if (method === 'browser.network.analysis') {
if (!requestEngine) throw new ExtensionError('bridge_disconnected', 'Yak 引擎请求通道不可用');
return requestEngine<BrowserRequestAnalysisBundle>(
'yakit.browser_request.prepare_analysis',
await capturedRequestEnginePayload(target, String(input.id), grant.scopes.includes('browser.observation.read')),
);
}
}
if (method.startsWith('browser.observe.')) {
const target = await allowedTarget(grant, input);
if (method === 'browser.observe.start') {
if (input.captureValues === true) requireScope(grant, 'browser.observation.sensitive.read');
return startPageObservation(target, {
captureValues: input.captureValues === true,
maxEntries: typeof input.maxEntries === 'number' ? input.maxEntries : undefined,
maxValueBytes: typeof input.maxValueBytes === 'number' ? input.maxValueBytes : undefined,
expiresAt: grant.expiresAt,
}, { kind: 'grant', grantId: grant.id });
}
if (method === 'browser.observe.status') return pageObservationStatus(target);
if (method === 'browser.observe.list') {
return listPageObservations(
target,
typeof input.limit === 'number' ? input.limit : 100,
grant.scopes.includes('browser.observation.sensitive.read'),
);
}
if (method === 'browser.observe.clear') return clearPageObservations(target);
if (method === 'browser.observe.stop') return stopPageObservation(target);
}
if (method === 'browser.context') {
const options: PageContextOptions = {
includeDom: input.includeDom !== false,
includeStorage: input.includeStorage === true,
includeCookies: input.includeCookies === true,
};
if (options.includeStorage) requireScope(grant, 'browser.storage.read');
if (options.includeCookies) requireScope(grant, 'browser.cookies.read');
return capturePageContext(options, await allowedTarget(grant, input));
}
if (method === 'browser.node.inspect') {
const target = await allowedTarget(grant, input);
return inspectPageNode(String(input.captureId), String(input.nodeId), target);
}
if (method === 'browser.node.action') {
const target = await allowedTarget(grant, input);
return actOnPageNode(
String(input.captureId),
String(input.nodeId),
input.action as 'click' | 'focus' | 'scroll' | 'setValue',
target,
typeof input.value === 'string' ? input.value : undefined,
);
}
if (method === 'browser.cookies') {
const target = await allowedTarget(grant, input);
const frame = await browser.webNavigation.getFrame({ tabId: target.tabId, frameId: target.frameId });
const grantTarget = grant.targets.find((item) => item.tabId === target.tabId && item.frameId === target.frameId);
const url = frame?.url && /^https?:/i.test(frame.url) ? frame.url : `${grantTarget?.origin || ''}/`;
if (!/^https?:/i.test(url)) throw new ExtensionError('target_unavailable', '目标 frame 没有可读取 Cookie 的 HTTP 来源');
return listCookies(url);
}
if (method === 'browser.takeover') {
const target = await allowedTarget(grant, input);
await activateTab(target.tabId);
await browser.action.setBadgeBackgroundColor({ color: '#f28c28' });
await browser.action.setBadgeText({ text: '接管', tabId: target.tabId });
globalThis.setTimeout(() => void browser.action.setBadgeText({ text: '', tabId: target.tabId }), 10_000);
return { activated: true, target };
}
if (method === 'browser.invoke') {
if (typeof input.path !== 'string') throw new Error('缺少页面函数路径');
const timeoutMs = typeof input.timeoutMs === 'number' ? input.timeoutMs : 10_000;
return invokePageFunction(input.path, Array.isArray(input.args) ? input.args : [], await allowedTarget(grant, input), timeoutMs);
}
if (method === 'browser.eval') {
if (typeof input.code !== 'string' || !input.code.trim()) throw new Error('缺少页面执行代码');
const timeoutMs = typeof input.timeoutMs === 'number' ? input.timeoutMs : 10_000;
return evalInPage(input.code, input.mode as 'expression' | 'program', await allowedTarget(grant, input), timeoutMs);
}
const state = await getState();
if (method === 'proxy.list') return state.proxyProfiles;
if (method === 'proxy.switch') {
if (typeof input.id !== 'string') throw new Error('缺少代理配置 ID');
await switchProxy(input.id);
return { activeProxyId: input.id };
}
throw new Error(`不支持的 Bridge 方法: ${method}`);
}
+28
View File
@@ -0,0 +1,28 @@
import type { AuditEvent, HandoffReason, HumanHandoff } from '@/types/models';
export const HANDOFF_REASON_LABELS: Record<HandoffReason, string> = {
qr_code: '需要扫码',
mfa: '需要二次验证',
captcha: '需要完成验证码',
device_confirmation: '需要设备确认',
other: '需要人工操作',
};
export const AUDIT_CATEGORY_LABELS: Record<AuditEvent['category'], string> = {
grant: '授权',
bridge: 'Bridge',
capability: '能力调用',
handoff: '人工接管',
settings: '设置',
};
export const AUDIT_OUTCOME_LABELS: Record<AuditEvent['outcome'], string> = {
success: '成功',
denied: '已拒绝',
error: '错误',
cancelled: '已取消',
};
export function waitingHandoff(handoff?: HumanHandoff): HumanHandoff | undefined {
return handoff?.state === 'waiting_for_user' ? handoff : undefined;
}
+20
View File
@@ -0,0 +1,20 @@
import { vi, describe, expect, it } from 'vitest';
vi.mock('wxt/browser', () => ({ browser: { declarativeNetRequest: {} } }));
import { buildUserAgentDnrRules } from './user-agent';
describe('User-Agent DNR rules', () => {
it('normalizes domains and covers browser request resource types', () => {
const [rule] = buildUserAgentDnrRules([{
id: 'ua-1', name: 'Test', enabled: true, userAgent: 'Yakit-E2E/1.0', domains: ['https://*.example.test/path'],
}]);
expect(rule.condition.urlFilter).toBe('||example.test^');
expect(rule.condition.resourceTypes).toContain('websocket');
expect(rule.action).toMatchObject({ requestHeaders: [{ header: 'user-agent', operation: 'set', value: 'Yakit-E2E/1.0' }] });
});
it('ignores disabled rules', () => {
expect(buildUserAgentDnrRules([{ id: 'x', name: 'X', enabled: false, userAgent: 'x', domains: [] }])).toHaveLength(0);
});
});
+48
View File
@@ -0,0 +1,48 @@
import { browser } from 'wxt/browser';
import type { UserAgentRule } from '@/types/models';
const RULE_ID_BASE = 20_000;
const MAX_UA_RULES = 5_000;
function domainFilter(domain: string): string {
const normalized = domain.trim().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^\*\./, '');
return normalized ? `||${normalized}^` : '*';
}
export function buildUserAgentDnrRules(rules: UserAgentRule[]): Browser.declarativeNetRequest.Rule[] {
const addRules: Browser.declarativeNetRequest.Rule[] = [];
let nextRuleId = RULE_ID_BASE;
for (const rule of rules.filter((item) => item.enabled)) {
const domains = rule.domains.length > 0 ? [...new Set(rule.domains)] : [''];
for (const domain of domains) {
if (nextRuleId >= RULE_ID_BASE + MAX_UA_RULES) {
throw new Error(`User-Agent 动态规则超过 ${MAX_UA_RULES} 条限制`);
}
addRules.push({
id: nextRuleId,
priority: nextRuleId - RULE_ID_BASE + 1,
action: {
type: 'modifyHeaders',
requestHeaders: [{ header: 'user-agent', operation: 'set', value: rule.userAgent }],
},
condition: {
urlFilter: domainFilter(domain),
resourceTypes: [
'main_frame', 'sub_frame', 'xmlhttprequest', 'script', 'image', 'stylesheet',
'font', 'media', 'websocket', 'other',
],
},
});
nextRuleId += 1;
}
}
return addRules;
}
export async function applyUserAgentRules(rules: UserAgentRule[]): Promise<void> {
const oldRuleIds = (await browser.declarativeNetRequest.getDynamicRules())
.map((rule) => rule.id)
.filter((id) => id >= RULE_ID_BASE && id < RULE_ID_BASE + 10_000);
await browser.declarativeNetRequest.updateDynamicRules({ removeRuleIds: oldRuleIds, addRules: buildUserAgentDnrRules(rules) });
}
+409
View File
@@ -0,0 +1,409 @@
import { browser, type Browser } from 'wxt/browser';
import { NETWORK_CAPTURE_STORAGE_KEY } from '@/protocol/storage';
import type {
BrowserTarget, NetworkBody, NetworkCaptureOptions, NetworkCaptureStatus, NetworkHeader,
NetworkRequestExport, NetworkRequestRecord,
} from '@/types/models';
import { ExtensionError } from '@/shared/errors';
const DEFAULT_OPTIONS: NetworkCaptureOptions = {
captureHeaders: false,
captureBody: false,
maxEntries: 100,
maxBodyBytes: 32 * 1024,
};
const MAX_ENTRIES = 200;
const MAX_BODY_BYTES = 64 * 1024;
const MAX_HEADER_COUNT = 256;
const MAX_HEADER_VALUE_LENGTH = 16 * 1024;
const MAX_HEADER_BYTES = 64 * 1024;
const MAX_SESSION_BYTES = 5 * 1024 * 1024;
const CAPTURED_RESOURCE_TYPES = ['xmlhttprequest', 'ping', 'other', 'main_frame', 'sub_frame'] as const;
interface CaptureSession {
target: BrowserTarget;
startedAt: number;
droppedCount: number;
options: NetworkCaptureOptions;
records: NetworkRequestRecord[];
owner: { kind: 'local' } | { kind: 'grant'; grantId: string; expiresAt: number };
}
interface SessionStorageArea {
get(key: string): Promise<Record<string, unknown>>;
set(items: Record<string, unknown>): Promise<void>;
}
const captureSessions = new Map<number, CaptureSession>();
const sessionStorage = (browser.storage as unknown as { session?: SessionStorageArea }).session;
let persistTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
let notifyTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
const pendingNotificationTabs = new Set<number>();
function normalizedOptions(input?: Partial<NetworkCaptureOptions>): NetworkCaptureOptions {
return {
captureHeaders: input?.captureHeaders === true,
captureBody: input?.captureBody === true,
maxEntries: Math.min(Math.max(input?.maxEntries || DEFAULT_OPTIONS.maxEntries, 10), MAX_ENTRIES),
maxBodyBytes: Math.min(Math.max(input?.maxBodyBytes || DEFAULT_OPTIONS.maxBodyBytes, 1024), MAX_BODY_BYTES),
};
}
function isCaptureSession(value: unknown): value is CaptureSession {
if (!value || typeof value !== 'object') return false;
const session = value as Partial<CaptureSession>;
return Boolean(
session.target && Number.isSafeInteger(session.target.tabId) && Number.isSafeInteger(session.target.frameId)
&& typeof session.startedAt === 'number' && Array.isArray(session.records),
);
}
async function restoreSessions(): Promise<void> {
if (!sessionStorage) return;
try {
const stored = await sessionStorage.get(NETWORK_CAPTURE_STORAGE_KEY);
const sessions = stored[NETWORK_CAPTURE_STORAGE_KEY];
if (!Array.isArray(sessions)) return;
for (const value of sessions) {
if (!isCaptureSession(value)) continue;
const session: CaptureSession = {
...value,
droppedCount: Number.isSafeInteger(value.droppedCount) ? value.droppedCount : 0,
options: normalizedOptions(value.options),
records: value.records.slice(-MAX_ENTRIES),
owner: value.owner?.kind === 'grant' && typeof value.owner.grantId === 'string' && typeof value.owner.expiresAt === 'number'
? value.owner
: { kind: 'local' },
};
captureSessions.set(session.target.tabId, session);
}
} catch {
// Session persistence is an optimization; capture still works in memory.
}
}
const restorePromise = restoreSessions();
function schedulePersist(): void {
if (!sessionStorage || persistTimer) return;
persistTimer = globalThis.setTimeout(() => {
persistTimer = undefined;
void sessionStorage.set({ [NETWORK_CAPTURE_STORAGE_KEY]: [...captureSessions.values()] }).catch(() => undefined);
}, 250);
}
function notifyChanged(tabId: number): void {
pendingNotificationTabs.add(tabId);
if (notifyTimer) return;
notifyTimer = globalThis.setTimeout(() => {
notifyTimer = undefined;
const tabIds = [...pendingNotificationTabs];
pendingNotificationTabs.clear();
for (const changedTabId of tabIds) {
void browser.runtime.sendMessage({ action: 'network.capture.changed', payload: { tabId: changedTabId } }).catch(() => undefined);
}
}, 100);
}
function matchingSession(details: Pick<Browser.webRequest.WebRequestDetails, 'tabId' | 'frameId' | 'type'> & { documentId?: string }): CaptureSession | undefined {
const session = captureSessions.get(details.tabId);
if (!session || details.frameId !== session.target.frameId) return undefined;
if (session.owner.kind === 'grant' && session.owner.expiresAt <= Date.now()) {
captureSessions.delete(details.tabId);
schedulePersist();
notifyChanged(details.tabId);
return undefined;
}
const isFrameNavigation = details.type === 'main_frame' || details.type === 'sub_frame';
if (!isFrameNavigation && session.target.documentId && details.documentId && session.target.documentId !== details.documentId) return undefined;
return session;
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
}
return btoa(binary);
}
function base64ToBytes(value: string): Uint8Array {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return bytes;
}
function encodeBody(bytes: Uint8Array, byteLength: number, truncated: boolean): NetworkBody {
try {
return { encoding: 'utf8', data: new TextDecoder('utf-8', { fatal: true }).decode(bytes), byteLength, truncated };
} catch {
return { encoding: 'base64', data: bytesToBase64(bytes), byteLength, truncated };
}
}
function requestBody(details: Browser.webRequest.OnBeforeRequestDetails, maxBytes: number): NetworkBody | undefined {
const raw = details.requestBody?.raw || [];
if (raw.length > 0) {
const parts = raw.flatMap((part) => part.bytes ? [new Uint8Array(part.bytes)] : []);
const byteLength = parts.reduce((total, part) => total + part.byteLength, 0);
const output = new Uint8Array(Math.min(byteLength, maxBytes));
let offset = 0;
for (const part of parts) {
if (offset >= output.length) break;
const slice = part.subarray(0, output.length - offset);
output.set(slice, offset);
offset += slice.length;
}
const body = encodeBody(output, byteLength, byteLength > output.length);
if (parts.length !== raw.length) body.reconstructed = true;
return body;
}
const formData = details.requestBody?.formData;
if (!formData) return undefined;
const params = new URLSearchParams();
for (const [key, values] of Object.entries(formData)) {
for (const value of values) params.append(key, typeof value === 'string' ? value : '[binary]');
}
const bytes = new TextEncoder().encode(params.toString());
return { ...encodeBody(bytes.subarray(0, maxBytes), bytes.byteLength, bytes.byteLength > maxBytes), reconstructed: true };
}
function normalizeHeaders(headers?: Browser.webRequest.HttpHeader[]): NetworkHeader[] | undefined {
if (!headers) return undefined;
const output: NetworkHeader[] = [];
let remaining = MAX_HEADER_BYTES;
for (const header of headers.slice(0, MAX_HEADER_COUNT)) {
const name = header.name.slice(0, 256);
const sourceValue = header.value || (header.binaryValue ? `[binary:${header.binaryValue.byteLength}]` : '');
const value = sourceValue.slice(0, Math.min(MAX_HEADER_VALUE_LENGTH, Math.max(remaining - name.length, 0)));
if (remaining <= name.length) break;
output.push({ name, value });
remaining -= name.length + value.length;
}
return output;
}
function findRecord(session: CaptureSession, requestId: string): NetworkRequestRecord | undefined {
for (let index = session.records.length - 1; index >= 0; index -= 1) {
if (session.records[index].requestId === requestId) return session.records[index];
}
return undefined;
}
function commit(session: CaptureSession, tabId: number): void {
while (session.records.length > session.options.maxEntries) {
session.records.shift();
session.droppedCount += 1;
}
let estimatedBytes = session.records.reduce((total, record) => total + JSON.stringify(record).length, 0);
while (estimatedBytes > MAX_SESSION_BYTES && session.records.length > 1) {
const removed = session.records.shift();
estimatedBytes -= removed ? JSON.stringify(removed).length : 0;
session.droppedCount += 1;
}
schedulePersist();
notifyChanged(tabId);
}
function onBeforeRequest(details: Browser.webRequest.OnBeforeRequestDetails): Browser.webRequest.BlockingResponse | undefined {
const session = matchingSession(details);
if (!session) return undefined;
let record = findRecord(session, details.requestId);
if (!record) {
record = {
id: crypto.randomUUID(), requestId: details.requestId, tabId: details.tabId, frameId: details.frameId,
documentId: details.documentId, url: details.url, method: details.method, resourceType: details.type,
initiator: details.initiator, startedAt: details.timeStamp, requestHeadersCaptured: session.options.captureHeaders,
requestBodyCaptured: session.options.captureBody, redirects: [],
};
session.records.push(record);
} else {
record.url = details.url;
record.method = details.method;
record.startedAt = details.timeStamp;
}
if (session.options.captureBody) record.requestBody = requestBody(details, session.options.maxBodyBytes);
commit(session, details.tabId);
return undefined;
}
function onBeforeSendHeaders(details: Browser.webRequest.OnBeforeSendHeadersDetails): Browser.webRequest.BlockingResponse | undefined {
const session = matchingSession(details);
if (!session?.options.captureHeaders) return undefined;
const record = findRecord(session, details.requestId);
if (!record) return undefined;
record.requestHeaders = normalizeHeaders(details.requestHeaders);
commit(session, details.tabId);
return undefined;
}
function onBeforeRedirect(details: Browser.webRequest.OnBeforeRedirectDetails): void {
const session = matchingSession(details);
if (!session) return;
const record = findRecord(session, details.requestId);
if (!record) return;
record.redirects.push({ url: details.url, statusCode: details.statusCode, redirectUrl: details.redirectUrl, timestamp: details.timeStamp });
record.statusCode = details.statusCode;
record.statusLine = details.statusLine;
commit(session, details.tabId);
}
function completeRecord(details: Browser.webRequest.OnCompletedDetails): void {
const session = matchingSession(details);
if (!session) return;
const record = findRecord(session, details.requestId);
if (!record) return;
record.completedAt = details.timeStamp;
record.durationMs = Math.max(0, Math.round((details.timeStamp - record.startedAt) * 100) / 100);
record.statusCode = details.statusCode;
record.statusLine = details.statusLine;
record.fromCache = details.fromCache;
record.ip = details.ip;
if (session.options.captureHeaders) record.responseHeaders = normalizeHeaders(details.responseHeaders);
const contentType = details.responseHeaders?.find((header) => header.name.toLowerCase() === 'content-type')?.value;
const contentLength = details.responseHeaders?.find((header) => header.name.toLowerCase() === 'content-length')?.value;
record.responseContentType = contentType?.slice(0, 512);
if (contentLength && Number.isSafeInteger(Number(contentLength))) record.responseSize = Number(contentLength);
commit(session, details.tabId);
}
function errorRecord(details: Browser.webRequest.OnErrorOccurredDetails): void {
const session = matchingSession(details);
if (!session) return;
const record = findRecord(session, details.requestId);
if (!record) return;
record.completedAt = details.timeStamp;
record.durationMs = Math.max(0, Math.round((details.timeStamp - record.startedAt) * 100) / 100);
record.error = details.error.slice(0, 512);
commit(session, details.tabId);
}
browser.webRequest.onBeforeRequest.addListener(onBeforeRequest, { urls: ['<all_urls>'], types: [...CAPTURED_RESOURCE_TYPES] }, ['requestBody']);
browser.webRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, { urls: ['<all_urls>'], types: [...CAPTURED_RESOURCE_TYPES] }, ['requestHeaders', 'extraHeaders']);
browser.webRequest.onBeforeRedirect.addListener(onBeforeRedirect, { urls: ['<all_urls>'], types: [...CAPTURED_RESOURCE_TYPES] }, ['responseHeaders', 'extraHeaders']);
browser.webRequest.onCompleted.addListener(completeRecord, { urls: ['<all_urls>'], types: [...CAPTURED_RESOURCE_TYPES] }, ['responseHeaders', 'extraHeaders']);
browser.webRequest.onErrorOccurred.addListener(errorRecord, { urls: ['<all_urls>'], types: [...CAPTURED_RESOURCE_TYPES] });
browser.tabs.onRemoved.addListener((tabId) => {
if (captureSessions.delete(tabId)) schedulePersist();
});
function sameTarget(left: BrowserTarget, right: BrowserTarget): boolean {
return left.tabId === right.tabId && left.frameId === right.frameId
&& (!left.documentId || !right.documentId || left.documentId === right.documentId);
}
function sessionFor(target: BrowserTarget): CaptureSession | undefined {
const session = captureSessions.get(target.tabId);
if (session?.owner.kind === 'grant' && session.owner.expiresAt <= Date.now()) {
captureSessions.delete(target.tabId);
schedulePersist();
return undefined;
}
return session && sameTarget(session.target, target) ? session : undefined;
}
export async function startNetworkCapture(
target: BrowserTarget,
options?: Partial<NetworkCaptureOptions>,
owner: CaptureSession['owner'] = { kind: 'local' },
): Promise<NetworkCaptureStatus> {
await restorePromise;
const session: CaptureSession = { target, startedAt: Date.now(), droppedCount: 0, options: normalizedOptions(options), records: [], owner };
captureSessions.set(target.tabId, session);
schedulePersist();
notifyChanged(target.tabId);
return networkCaptureStatus(target);
}
export async function networkCaptureStatus(target: BrowserTarget): Promise<NetworkCaptureStatus> {
await restorePromise;
const session = sessionFor(target);
return session
? { active: true, target: session.target, startedAt: session.startedAt, count: session.records.length, droppedCount: session.droppedCount, options: session.options }
: { active: false, target, count: 0, droppedCount: 0 };
}
export async function listNetworkRequests(target: BrowserTarget, limit = 100): Promise<NetworkRequestRecord[]> {
await restorePromise;
const session = sessionFor(target);
if (!session) return [];
return structuredClone(session.records.slice(-Math.min(Math.max(limit, 1), MAX_ENTRIES)).reverse());
}
export async function clearNetworkRequests(target: BrowserTarget): Promise<NetworkCaptureStatus> {
await restorePromise;
const session = sessionFor(target);
if (session) {
session.records = [];
session.droppedCount = 0;
schedulePersist();
notifyChanged(target.tabId);
}
return networkCaptureStatus(target);
}
export async function stopNetworkCapture(target: BrowserTarget): Promise<NetworkCaptureStatus> {
await restorePromise;
captureSessions.delete(target.tabId);
schedulePersist();
notifyChanged(target.tabId);
return { active: false, target, count: 0, droppedCount: 0 };
}
export async function stopNetworkCapturesForGrant(grantId: string): Promise<void> {
await restorePromise;
let changed = false;
for (const [tabId, session] of captureSessions) {
if (session.owner.kind !== 'grant' || session.owner.grantId !== grantId) continue;
captureSessions.delete(tabId);
notifyChanged(tabId);
changed = true;
}
if (changed) schedulePersist();
}
function bodyBytes(body?: NetworkBody): Uint8Array {
if (!body) return new Uint8Array();
return body.encoding === 'base64' ? base64ToBytes(body.data) : new TextEncoder().encode(body.data);
}
export async function exportNetworkRequest(target: BrowserTarget, id: string): Promise<NetworkRequestExport> {
await restorePromise;
const session = sessionFor(target);
const record = session?.records.find((item) => item.id === id);
if (!record) throw new ExtensionError('network_request_not_found', '网络请求不存在或已经被有界缓冲区淘汰');
if (!record.requestHeadersCaptured || !record.requestHeaders) {
throw new ExtensionError('network_headers_not_captured', '该请求未捕获实际请求头,无法生成可重放数据包');
}
const url = new URL(record.url);
const headers = record.requestHeaders.filter((header) => !header.name.startsWith(':'));
if (!headers.some((header) => header.name.toLowerCase() === 'host')) {
headers.unshift({ name: 'Host', value: url.host });
}
const path = `${url.pathname || '/'}${url.search}`;
const head = `${record.method} ${path} HTTP/1.1\r\n${headers.map((header) => `${header.name}: ${header.value}`).join('\r\n')}\r\n\r\n`;
const headBytes = new TextEncoder().encode(head);
const body = bodyBytes(record.requestBody);
const packet = new Uint8Array(headBytes.length + body.length);
packet.set(headBytes);
packet.set(body, headBytes.length);
const limitations: string[] = [];
if (record.requestBody?.truncated) limitations.push(`请求体只保留前 ${body.length} 字节`);
if (record.requestBody?.reconstructed) limitations.push('浏览器未提供完整原始请求体,当前内容由可用字段重建');
if (!record.requestBody && !['GET', 'HEAD', 'OPTIONS'].includes(record.method.toUpperCase())) {
limitations.push(record.requestBodyCaptured ? '浏览器未提供该请求体,重放数据包可能不完整' : '捕获时未启用请求体,重放数据包可能不完整');
}
const rawRequest = record.requestBody?.encoding === 'base64'
? `${head}[binary body: ${record.requestBody.byteLength} bytes]`
: `${head}${record.requestBody?.data || ''}`;
return { id: record.id, url: record.url, isHttps: url.protocol === 'https:', rawRequest, rawRequestBase64: bytesToBase64(packet), limitations };
}
export function redactNetworkRequests(records: NetworkRequestRecord[]): NetworkRequestRecord[] {
return records.map(({ requestHeaders: _requestHeaders, responseHeaders: _responseHeaders, requestBody: _requestBody, ...record }) => ({
...record,
requestHeadersCaptured: false,
requestBodyCaptured: false,
}));
}
+18
View File
@@ -0,0 +1,18 @@
import { observationAnalysisWindow } from '@/features/page-observation/service';
import type { BrowserTarget } from '@/types/models';
import { exportNetworkRequest, listNetworkRequests } from './service';
export async function capturedRequestEnginePayload(target: BrowserTarget, id: string, includeObservations: boolean) {
const [exported, records] = await Promise.all([
exportNetworkRequest(target, id),
listNetworkRequests(target, 200),
]);
const record = records.find((item) => item.id === id);
return {
rawRequestBase64: exported.rawRequestBase64,
isHttps: exported.isHttps,
observations: includeObservations && record
? await observationAnalysisWindow(target, record.startedAt)
: [],
};
}
@@ -0,0 +1,74 @@
import { browser, type Browser } from 'wxt/browser';
import type { ContentScriptContext } from 'wxt/utils/content-script-context';
import { createOpaqueId } from '@/shared/id';
import {
PAGE_BRIDGE_CHANNEL,
PAGE_REQUEST_EVENT,
PAGE_RESPONSE_EVENT,
type PageBridgeRequest,
type PageBridgeResponse,
type PageOperation,
} from './protocol';
type InternalMessage = PageOperation & { channel: typeof PAGE_BRIDGE_CHANNEL; timeoutMs?: number };
export async function installPageWorldBridge(ctx: ContentScriptContext): Promise<void> {
const pending = new Map<string, {
resolve: (response: PageBridgeResponse) => void;
timer: ReturnType<typeof globalThis.setTimeout>;
}>();
const { script } = await injectScript('/page-main-world.js', {
keepInDom: true,
modifyScript(element) {
element.id = createOpaqueId('yakit-page-bridge');
},
});
const onResponse = (event: Event) => {
if (!(event instanceof CustomEvent) || typeof event.detail !== 'string') return;
let response: PageBridgeResponse;
try {
response = JSON.parse(event.detail) as PageBridgeResponse;
} catch {
return;
}
const task = pending.get(response.id);
if (!task) return;
globalThis.clearTimeout(task.timer);
pending.delete(response.id);
task.resolve(response);
};
script.addEventListener(PAGE_RESPONSE_EVENT, onResponse);
ctx.onInvalidated(() => {
script.removeEventListener(PAGE_RESPONSE_EVENT, onResponse);
script.remove();
for (const task of pending.values()) globalThis.clearTimeout(task.timer);
pending.clear();
});
const execute = (message: InternalMessage): Promise<PageBridgeResponse> => {
const id = createOpaqueId('page-request');
const timeoutMs = Math.min(Math.max(message.timeoutMs || 10_000, 250), 60_000);
const request: PageBridgeRequest = message.operation === 'eval'
? { id, timeoutMs, operation: 'eval', mode: message.mode, code: message.code }
: { id, timeoutMs, operation: 'invoke', path: message.path, args: message.args };
return new Promise((resolve) => {
const timer = globalThis.setTimeout(() => {
pending.delete(id);
resolve({ id, ok: false, error: { name: 'TimeoutError', message: `页面执行超过 ${timeoutMs}ms` } });
}, timeoutMs);
pending.set(id, { resolve, timer });
script.dispatchEvent(new CustomEvent(PAGE_REQUEST_EVENT, { detail: JSON.stringify(request) }));
});
};
const onMessage = (message: unknown, _sender: Browser.runtime.MessageSender, sendResponse: (response: PageBridgeResponse) => void) => {
const input = message as InternalMessage;
if (input?.channel !== PAGE_BRIDGE_CHANNEL || !['eval', 'invoke'].includes(input.operation)) return undefined;
void execute(input).then(sendResponse);
return true;
};
browser.runtime.onMessage.addListener(onMessage);
ctx.onInvalidated(() => browser.runtime.onMessage.removeListener(onMessage));
}
@@ -0,0 +1,27 @@
import { vi, describe, expect, it } from 'vitest';
vi.mock('wxt/browser', () => ({ browser: {} }));
Object.assign(globalThis, { Node: class Node {}, Element: class Element {} });
import { executeInUserScriptWorld } from './execution-adapter';
describe('page execution serializer', () => {
it('serializes BigInt and circular values without throwing', async () => {
const response = await executeInUserScriptWorld({
operation: 'eval', mode: 'expression',
code: '(() => { const value = { big: 42n }; value.self = value; return value; })()', timeoutMs: 500,
}, () => { const value: Record<string, unknown> = { big: 42n }; value.self = value; return value; });
expect(response.ok).toBe(true);
if (response.ok) {
expect(response.result.value).toMatchObject({ big: { $type: 'bigint', value: '42' }, self: { $type: 'circular' } });
expect(response.result.truncated).toBe(false);
}
});
it('distinguishes expression and program syntax', async () => {
const expression = await executeInUserScriptWorld({ operation: 'eval', mode: 'expression', code: '1 + 1', timeoutMs: 500 }, () => 1 + 1);
const program = await executeInUserScriptWorld({ operation: 'eval', mode: 'program', code: 'const answer = 40; answer + 2', timeoutMs: 500 }, () => { const answer = 40; return answer + 2; });
expect(expression.ok && expression.result.value).toBe(2);
expect(program.ok && program.result.value).toBe(42);
});
});
@@ -0,0 +1,230 @@
import { browser } from 'wxt/browser';
import type { BrowserTarget, PageEvalResult } from '@/types/models';
import { PAGE_BRIDGE_CHANNEL, type PageBridgeResponse, type PageOperation } from './protocol';
export type PageExecutionMode = 'user-scripts' | 'injected-bridge' | 'invoke-only';
interface PageExecutionAdapter {
readonly mode: PageExecutionMode;
execute(target: BrowserTarget, operation: PageOperation, timeoutMs: number): Promise<PageEvalResult>;
}
interface UserScriptInjectionResult {
frameId: number;
documentId?: string;
result?: unknown;
error?: string;
}
interface UserScriptsApi {
getScripts(): Promise<unknown[]>;
execute(injection: {
target: { tabId: number; frameIds?: number[]; documentIds?: string[] };
js: Array<{ code: string }>;
world: 'MAIN' | 'USER_SCRIPT';
}): Promise<UserScriptInjectionResult[]>;
}
type UserScriptExecutionResponse = {
ok: true;
result: PageEvalResult;
} | {
ok: false;
error: { name: string; message: string; stack?: string };
};
type PageEvaluation = () => unknown | Promise<unknown>;
function evaluationSource(operation: PageOperation): string {
if (operation.operation !== 'eval') return 'undefined';
if (operation.mode === 'expression') return `async () => (\n${operation.code}\n)`;
return `async () => {\n${operation.code}\n}`;
}
export async function executeInUserScriptWorld(
input: PageOperation & { timeoutMs: number },
evaluate?: PageEvaluation,
): Promise<UserScriptExecutionResponse> {
const MAX_DEPTH = 6;
const MAX_ITEMS = 100;
const MAX_STRING = 100_000;
const startedAt = performance.now();
const serialize = (value: unknown): Omit<PageEvalResult, 'durationMs'> => {
const seen = new WeakSet<object>();
let truncated = false;
const visit = (current: unknown, depth: number): unknown => {
if (current === null) return null;
if (typeof current === 'string') {
if (current.length > MAX_STRING) truncated = true;
return current.slice(0, MAX_STRING);
}
if (typeof current === 'number' || typeof current === 'boolean') return current;
if (typeof current === 'undefined') return { $type: 'undefined' };
if (typeof current === 'bigint') return { $type: 'bigint', value: current.toString() };
if (typeof current === 'symbol') return { $type: 'symbol', value: String(current) };
if (typeof current === 'function') {
const source = Function.prototype.toString.call(current);
if (source.length > 2_000) truncated = true;
return { $type: 'function', name: current.name || '', source: source.slice(0, 2_000) };
}
if (depth >= MAX_DEPTH) {
truncated = true;
return { $type: 'max-depth', constructor: (current as object).constructor?.name || 'Object' };
}
if (seen.has(current as object)) return { $type: 'circular' };
seen.add(current as object);
if (current instanceof Error) return { $type: 'error', name: current.name, message: current.message, stack: current.stack?.slice(0, 10_000) };
if (current instanceof Date) return { $type: 'date', value: current.toISOString() };
if (current instanceof RegExp) return { $type: 'regexp', value: String(current) };
if (current instanceof Node) {
const element = current instanceof Element ? current : current.parentElement;
const html = element?.outerHTML || current.textContent || '';
if (html.length > 10_000) truncated = true;
return { $type: 'node', name: current.nodeName, html: html.slice(0, 10_000) };
}
if (Array.isArray(current)) {
if (current.length > MAX_ITEMS) truncated = true;
return current.slice(0, MAX_ITEMS).map((item) => visit(item, depth + 1));
}
const output: Record<string, unknown> = {};
const allKeys = Reflect.ownKeys(current as object);
if (allKeys.length > MAX_ITEMS) truncated = true;
for (const key of allKeys.slice(0, MAX_ITEMS)) {
const name = typeof key === 'symbol' ? `[${String(key)}]` : key;
try {
output[name] = visit(Reflect.get(current as object, key), depth + 1);
} catch (error) {
output[name] = { $type: 'unreadable', message: error instanceof Error ? error.message : String(error) };
}
}
return output;
};
const normalized = visit(value, 0);
let preview: string;
try {
preview = typeof value === 'string' ? value : JSON.stringify(normalized);
} catch {
preview = String(value);
}
return {
value: normalized,
type: value === null ? 'null' : typeof value,
preview: preview.slice(0, 2_000),
truncated: truncated || preview.length > 2_000,
};
};
try {
const operation = (async () => {
if (input.operation === 'eval') {
if (!evaluate) throw new Error('页面 Eval 缺少直接 User Script 执行体');
return await evaluate();
}
const segments = input.path.split('.').filter(Boolean);
let owner: unknown = window;
let target: unknown = window;
for (const segment of segments) {
owner = target;
target = Reflect.get(target as object, segment);
}
if (typeof target !== 'function') throw new TypeError(`${input.path} is not a function`);
return await Reflect.apply(target, owner, input.args);
})();
let timeoutId: ReturnType<typeof globalThis.setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timeoutId = globalThis.setTimeout(() => reject(new Error(`页面执行超过 ${input.timeoutMs}ms`)), input.timeoutMs);
});
const serialized = serialize(await Promise.race([operation, timeout]).finally(() => {
if (timeoutId !== undefined) globalThis.clearTimeout(timeoutId);
}));
return { ok: true, result: { ...serialized, durationMs: Math.round((performance.now() - startedAt) * 100) / 100 } };
} catch (error) {
return {
ok: false,
error: {
name: error instanceof Error ? error.name : 'Error',
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack?.slice(0, 10_000) : undefined,
},
};
}
}
const injectedBridgeAdapter: PageExecutionAdapter = {
mode: 'injected-bridge',
async execute(target, operation, timeoutMs) {
const response = await browser.tabs.sendMessage(target.tabId, {
channel: PAGE_BRIDGE_CHANNEL,
...operation,
timeoutMs,
}, target.documentId ? { documentId: target.documentId } : { frameId: target.frameId }) as PageBridgeResponse;
if (!response?.ok) throw new Error(response?.error?.message || '页面主世界执行失败');
return response.result;
},
};
const userScriptsAdapter: PageExecutionAdapter = {
mode: 'user-scripts',
async execute(target, operation, timeoutMs) {
const userScripts = (browser as unknown as { userScripts?: UserScriptsApi }).userScripts;
if (!userScripts?.execute) {
throw new Error('User Scripts API 不可用;Chrome 138+ 还需要在扩展详情中启用“允许用户脚本”');
}
const input = JSON.stringify({ ...operation, timeoutMs }).replaceAll('<', '\\u003c');
const code = `(${executeInUserScriptWorld.toString()})(${input},${evaluationSource(operation)})`;
const [injection] = await userScripts.execute({
target: target.documentId
? { tabId: target.tabId, documentIds: [target.documentId] }
: { tabId: target.tabId, frameIds: [target.frameId] },
world: 'MAIN',
js: [{ code }],
});
if (!injection) throw new Error('User Scripts API 没有返回主框架执行结果');
if (injection.error) throw new Error(injection.error);
const response = injection.result as UserScriptExecutionResponse | undefined;
if (!response) throw new Error('User Scripts API 返回了空执行结果');
if (!response.ok) throw new Error(response.error.message);
return response.result;
},
};
const enterpriseAdapter: PageExecutionAdapter = {
mode: 'user-scripts',
async execute(target, operation, timeoutMs) {
const userScripts = (browser as unknown as { userScripts?: UserScriptsApi }).userScripts;
if (!userScripts?.execute || !userScripts.getScripts) {
return injectedBridgeAdapter.execute(target, operation, timeoutMs);
}
try {
await userScripts.getScripts();
} catch {
return injectedBridgeAdapter.execute(target, operation, timeoutMs);
}
return userScriptsAdapter.execute(target, operation, timeoutMs);
},
};
const invokeOnlyAdapter: PageExecutionAdapter = {
mode: 'invoke-only',
async execute() {
throw new Error('Firefox AMO 渠道仅提供结构化浏览器命令,不包含页面函数调用或 Eval');
},
};
const executionAdapter = import.meta.env.FIREFOX && import.meta.env.MODE === 'store'
? invokeOnlyAdapter
: !import.meta.env.FIREFOX
&& (import.meta.env.MODE === 'production' || import.meta.env.MODE === 'store')
? userScriptsAdapter
: !import.meta.env.FIREFOX && import.meta.env.MODE === 'enterprise'
? enterpriseAdapter
: injectedBridgeAdapter;
export function getPageExecutionMode(): PageExecutionMode {
return executionAdapter.mode;
}
export function executePageOperation(target: BrowserTarget, operation: PageOperation, timeoutMs = 10_000): Promise<PageEvalResult> {
return executionAdapter.execute(target, operation, Math.min(Math.max(timeoutMs, 250), 60_000));
}
+81
View File
@@ -0,0 +1,81 @@
import { browser, type Browser } from 'wxt/browser';
import type { PageFrameSummary } from '@/types/models';
interface FrameProbe {
title: string;
name: string;
origin: string;
url: string;
readyState: string;
sandbox: string[];
}
type FrameProbeResult = Browser.scripting.InjectionResult<FrameProbe> & { documentId?: string };
function probeFrame(): FrameProbe {
let sandbox: string[] = [];
try {
sandbox = Array.from(window.frameElement?.getAttribute('sandbox')?.split(/\s+/).filter(Boolean) || []).slice(0, 32);
} catch {
// Cross-origin parent access is not required for frame inventory.
}
return {
title: document.title.slice(0, 1_000),
name: window.name.slice(0, 240),
origin: location.origin,
url: location.href.slice(0, 8_192),
readyState: document.readyState,
sandbox,
};
}
function urlOrigin(url: string): string {
try {
const parsed = new URL(url);
return ['http:', 'https:'].includes(parsed.protocol) ? parsed.origin : '';
} catch {
return '';
}
}
export async function getFrameInventory(tabId: number): Promise<PageFrameSummary[]> {
const [navigationFrames, probeResults] = await Promise.all([
browser.webNavigation.getAllFrames({ tabId }).catch(() => null),
browser.scripting.executeScript({
target: { tabId, allFrames: true },
world: 'MAIN',
func: probeFrame,
}).catch(() => [] as FrameProbeResult[]),
]);
const probes = new Map((probeResults as FrameProbeResult[]).map((probe) => [probe.frameId, probe]));
const navigation = navigationFrames || [];
const frameIds = new Set<number>([...navigation.map((frame) => frame.frameId), ...probes.keys()]);
const topNavigation = navigation.find((frame) => frame.frameId === 0);
const topProbe = probes.get(0)?.result;
const topOrigin = topProbe?.origin && topProbe.origin !== 'null' ? topProbe.origin : urlOrigin(topNavigation?.url || topProbe?.url || '');
return [...frameIds].sort((left, right) => left - right).slice(0, 256).map((frameId) => {
const navigationFrame = navigation.find((frame) => frame.frameId === frameId);
const injection = probes.get(frameId);
const probe = injection?.result;
const url = probe?.url || navigationFrame?.url || '';
const detectedOrigin = probe?.origin && probe.origin !== 'null' ? probe.origin : urlOrigin(url);
const origin = detectedOrigin || (navigationFrame?.parentFrameId === 0 && /^about:(blank|srcdoc)/.test(url) ? topOrigin : '');
return {
tabId,
frameId,
documentId: injection?.documentId || navigationFrame?.documentId,
parentFrameId: navigationFrame?.parentFrameId ?? (frameId === 0 ? -1 : 0),
parentDocumentId: navigationFrame?.parentDocumentId,
url,
origin,
title: probe?.title || (frameId === 0 ? 'Main frame' : `Frame ${frameId}`),
name: probe?.name || '',
frameType: String(navigationFrame?.frameType || (frameId === 0 ? 'outermost_frame' : 'sub_frame')),
documentLifecycle: String(navigationFrame?.documentLifecycle || 'active'),
isTop: frameId === 0,
sameOrigin: Boolean(origin && topOrigin && origin === topOrigin),
accessible: Boolean(injection?.result),
sandbox: probe?.sandbox || [],
};
});
}
+77
View File
@@ -0,0 +1,77 @@
import { browser } from 'wxt/browser';
import { PAGE_LIFECYCLE_STORAGE_KEY } from '@/protocol/storage';
import type { PageLifecycleEvent } from '@/types/models';
const MAX_EVENTS_PER_TAB = 100;
const MAX_PERSISTED_TABS = 16;
const eventsByTab = new Map<number, PageLifecycleEvent[]>();
const sessionStorage = (browser.storage as unknown as {
session?: { get(key: string): Promise<Record<string, unknown>>; set(items: Record<string, unknown>): Promise<void> };
}).session;
let persistTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
function isLifecycleEvent(value: unknown): value is PageLifecycleEvent {
if (!value || typeof value !== 'object') return false;
const event = value as Partial<PageLifecycleEvent>;
return typeof event.id === 'string' && ['document', 'history', 'fragment'].includes(String(event.kind))
&& Number.isSafeInteger(event.tabId) && Number.isSafeInteger(event.frameId)
&& typeof event.url === 'string' && typeof event.timestamp === 'number';
}
async function restore(): Promise<void> {
if (!sessionStorage) return;
try {
const stored = await sessionStorage.get(PAGE_LIFECYCLE_STORAGE_KEY);
const values = stored[PAGE_LIFECYCLE_STORAGE_KEY];
if (!Array.isArray(values)) return;
for (const entry of values.slice(-MAX_PERSISTED_TABS)) {
if (!Array.isArray(entry) || typeof entry[0] !== 'number' || !Array.isArray(entry[1])) continue;
eventsByTab.set(entry[0], entry[1].filter(isLifecycleEvent).slice(-MAX_EVENTS_PER_TAB));
}
} catch {
// Lifecycle tracking remains available in memory.
}
}
const restored = restore();
function schedulePersist(): void {
if (!sessionStorage || persistTimer) return;
persistTimer = globalThis.setTimeout(() => {
persistTimer = undefined;
void sessionStorage.set({ [PAGE_LIFECYCLE_STORAGE_KEY]: [...eventsByTab].slice(-MAX_PERSISTED_TABS) }).catch(() => undefined);
}, 250);
}
async function record(
kind: PageLifecycleEvent['kind'],
details: { tabId: number; frameId: number; documentId?: string; url: string; timeStamp: number; transitionType?: string },
): Promise<void> {
if (details.tabId < 0 || !/^(https?|about):/i.test(details.url)) return;
await restored;
const event: PageLifecycleEvent = {
id: crypto.randomUUID(),
kind,
tabId: details.tabId,
frameId: details.frameId,
documentId: details.documentId,
url: details.url.slice(0, 8_192),
timestamp: details.timeStamp,
transitionType: details.transitionType,
};
eventsByTab.set(details.tabId, [...(eventsByTab.get(details.tabId) || []), event].slice(-MAX_EVENTS_PER_TAB));
schedulePersist();
}
browser.webNavigation.onCommitted.addListener((details) => void record('document', details));
browser.webNavigation.onHistoryStateUpdated.addListener((details) => void record('history', details));
browser.webNavigation.onReferenceFragmentUpdated.addListener((details) => void record('fragment', details));
browser.tabs.onRemoved.addListener((tabId) => {
if (eventsByTab.delete(tabId)) schedulePersist();
});
export async function getPageLifecycle(tabId: number, frameId: number, documentId?: string): Promise<PageLifecycleEvent[]> {
await restored;
return (eventsByTab.get(tabId) || []).filter((event) => event.frameId === frameId
&& (!documentId || !event.documentId || event.documentId === documentId)).slice(-50);
}
+24
View File
@@ -0,0 +1,24 @@
import type { PageEvalResult } from '@/types/models';
export const PAGE_BRIDGE_CHANNEL = 'yakit-page-bridge-v1';
export const PAGE_REQUEST_EVENT = 'yakit:page-request:v1';
export const PAGE_RESPONSE_EVENT = 'yakit:page-response:v1';
export type PageOperation =
| { operation: 'eval'; mode: 'expression' | 'program'; code: string }
| { operation: 'invoke'; path: string; args: unknown[] };
export type PageBridgeRequest = PageOperation & {
id: string;
timeoutMs: number;
};
export type PageBridgeResponse = {
id: string;
ok: true;
result: PageEvalResult;
} | {
id: string;
ok: false;
error: { name: string; message: string; stack?: string };
};
+763
View File
@@ -0,0 +1,763 @@
import { browser } from 'wxt/browser';
import type {
ActiveTabInfo, BrowserStorageInventory, BrowserTarget, PageAuthenticationSignals, PageContext, PageContextChange,
PageContextDiff, PageContextOptions, PageEvalResult, PageNodeAction, PageNodeActionResult,
PageFormSummary, PageNodeDetails, PageNodeSummary, PageStorageSummary,
} from '@/types/models';
import { executePageOperation } from '@/features/page-context/execution-adapter';
import { getFrameInventory } from '@/features/page-context/frames';
import { getPageLifecycle } from '@/features/page-context/lifecycle';
import { CONTEXT_DIGEST_STORAGE_KEY } from '@/protocol/storage';
import { listCookies } from '@/features/cookies/service';
import { ExtensionError } from '@/shared/errors';
import { getTab, resolveDocumentTarget, scriptingTarget } from '@/platform/browser/targets';
async function collectDocumentContext(input: { options: PageContextOptions; captureId: string }) {
const MAX_SCANNED_ELEMENTS = 10_000;
const MAX_NODES = 400;
const MAX_FORMS = 50;
const MAX_HEADINGS = 80;
const MAX_BODY_TEXT = 20 * 1024;
const MAX_STORAGE_ENTRIES = 100;
const MAX_STORAGE_VALUE = 4 * 1024;
const MAX_STORAGE_BYTES = 128 * 1024;
const encoder = new TextEncoder();
const decoder = new TextDecoder('utf-8', { fatal: true });
const trim = (value: string | null | undefined, max = 240) => (value || '').replace(/\s+/g, ' ').trim().slice(0, max);
const truncateUtf8 = (value: string, maxBytes: number) => {
const bytes = encoder.encode(value);
if (bytes.byteLength <= maxBytes) return { value, byteLength: bytes.byteLength, truncated: false };
let end = maxBytes;
while (end > 0) {
try { return { value: decoder.decode(bytes.subarray(0, end)), byteLength: bytes.byteLength, truncated: true }; }
catch { end -= 1; }
}
return { value: '', byteLength: bytes.byteLength, truncated: true };
};
const registryKey = Symbol.for('com.yaklang.browser.context.registry.v1');
const nodes = new Map<string, Element>();
const summaries = new Map<string, PageNodeSummary>();
const nodeIds = new WeakMap<Element, string>();
const semanticOccurrences = new Map<string, number>();
const interactive: PageNodeSummary[] = [];
const forms: PageFormSummary[] = [];
const headings: Array<{ level: number; text: string }> = [];
const meta: Record<string, string> = {};
const limitsReached = new Set<string>();
let scannedElementCount = 0;
let passwordFieldCount = 0;
let hasLoginControl = false;
let hasLogoutControl = false;
let hasAccountControl = false;
let metaCount = 0;
const selectorHint = (element: Element) => {
if (element.id) return `#${CSS.escape(element.id)}`.slice(0, 240);
const testId = element.getAttribute('data-testid');
if (testId) return `[data-testid="${CSS.escape(testId)}"]`.slice(0, 240);
const name = element.getAttribute('name');
if (name) return `${element.tagName.toLowerCase()}[name="${CSS.escape(name)}"]`.slice(0, 240);
const role = element.getAttribute('role');
return `${element.tagName.toLowerCase()}${role ? `[role="${CSS.escape(role)}"]` : ''}`.slice(0, 240);
};
const accessibleName = (element: Element) => {
const labelledBy = element.getAttribute('aria-labelledby');
const labelledText = labelledBy?.split(/\s+/).map((id) => document.getElementById(id)?.textContent || '').join(' ');
const labels = 'labels' in element
? Array.from((element as HTMLInputElement).labels || []).map((label) => label.textContent || '').join(' ')
: '';
return trim(element.getAttribute('aria-label') || labelledText || labels || element.getAttribute('alt')
|| element.getAttribute('title') || element.getAttribute('placeholder') || element.textContent);
};
const semanticBase = (element: Element, name: string) => {
const tag = element.tagName.toLowerCase();
if (element.id) return `${tag}#${trim(element.id, 120)}`;
const testId = element.getAttribute('data-testid');
if (testId) return `${tag}[testid=${trim(testId, 120)}]`;
const fieldName = element.getAttribute('name');
if (fieldName) return `${tag}[name=${trim(fieldName, 120)}]`;
let href = '';
const rawHref = element.getAttribute('href');
if (rawHref) {
try {
const parsed = new URL(rawHref, location.href);
href = `${parsed.origin}${parsed.pathname}`;
} catch {
href = rawHref.split('?')[0];
}
}
return `${tag}|${element.getAttribute('role') || ''}|${element.getAttribute('type') || ''}|${trim(href, 180)}|${name}`;
};
const register = (element: Element, shadowDepth: number) => {
const existing = nodeIds.get(element);
if (existing) return summaries.get(existing);
if (nodes.size >= MAX_NODES) {
limitsReached.add('interactive_nodes');
return undefined;
}
const name = accessibleName(element);
const base = semanticBase(element, name);
const occurrence = semanticOccurrences.get(base) || 0;
semanticOccurrences.set(base, occurrence + 1);
const nodeId = `n${(nodes.size + 1).toString(36)}`;
const style = getComputedStyle(element);
const visible = element.getClientRects().length > 0 && style.display !== 'none' && style.visibility !== 'hidden';
const rawHref = element.getAttribute('href');
let href: string | undefined;
if (rawHref) {
try { href = new URL(rawHref, location.href).href.slice(0, 2_048); } catch { href = rawHref.slice(0, 2_048); }
}
const control = element as HTMLInputElement;
const summary: PageNodeSummary = {
nodeId,
semanticKey: `${base}|${occurrence}`.slice(0, 500),
tag: element.tagName.toLowerCase(),
role: trim(element.getAttribute('role'), 120),
type: trim(element.getAttribute('type'), 120),
name: trim(element.getAttribute('name'), 240),
text: trim(element.textContent),
accessibleName: name,
selectorHint: selectorHint(element),
visible,
disabled: Boolean(control.disabled || element.getAttribute('aria-disabled') === 'true'),
required: Boolean(control.required || element.getAttribute('aria-required') === 'true'),
...(element instanceof HTMLInputElement && ['checkbox', 'radio'].includes(element.type) ? { checked: element.checked } : {}),
...(href ? { href } : {}),
...(element.getAttribute('placeholder') ? { placeholder: trim(element.getAttribute('placeholder')) } : {}),
...(element.getAttribute('autocomplete') ? { autocomplete: trim(element.getAttribute('autocomplete')) } : {}),
shadowDepth,
};
nodes.set(nodeId, element);
nodeIds.set(element, nodeId);
summaries.set(nodeId, summary);
return summary;
};
const interactiveSelector = 'a[href],button,input,select,textarea,summary,[role="button"],[role="link"],[role="checkbox"],[role="radio"],[role="tab"],[contenteditable="true"]';
const visitRoot = (root: Document | ShadowRoot, shadowDepth: number) => {
if (root instanceof ShadowRoot && root.host.tagName.toLowerCase() === 'yakit-browser-agent') return;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let current = walker.nextNode();
while (current) {
const element = current as Element;
if (scannedElementCount >= MAX_SCANNED_ELEMENTS) {
limitsReached.add('scanned_elements');
return;
}
scannedElementCount += 1;
if (input.options.includeDom !== false && element.matches(interactiveSelector)) {
const summary = register(element, shadowDepth);
if (summary) {
interactive.push(summary);
const label = String(summary.accessibleName || summary.text || '');
if (element instanceof HTMLInputElement && element.type === 'password') passwordFieldCount += 1;
if (/\b(log\s?in|sign\s?in)\b|登录|登入/i.test(label)) hasLoginControl = true;
if (/\b(log\s?out|sign\s?out)\b|退出|注销/i.test(label)) hasLogoutControl = true;
if (/\b(account|profile|dashboard)\b|账户|账号|个人中心/i.test(label)) hasAccountControl = true;
}
}
if (input.options.includeDom !== false && /^H[1-6]$/.test(element.tagName) && headings.length < MAX_HEADINGS) {
headings.push({ level: Number(element.tagName.slice(1)), text: trim(element.textContent, 500) });
}
if (input.options.includeDom !== false && element instanceof HTMLFormElement && forms.length < MAX_FORMS) {
const formSummary = register(element, shadowDepth);
if (formSummary) {
const fieldNodeIds: string[] = [];
const fieldWalker = document.createTreeWalker(element, NodeFilter.SHOW_ELEMENT);
let field = fieldWalker.nextNode();
while (field && fieldNodeIds.length < 100) {
const fieldElement = field as Element;
if (fieldElement.matches('input,select,textarea,button')) {
const fieldNodeId = register(fieldElement, shadowDepth)?.nodeId;
if (fieldNodeId) fieldNodeIds.push(fieldNodeId);
}
field = fieldWalker.nextNode();
}
forms.push({
nodeId: formSummary.nodeId,
semanticKey: formSummary.semanticKey,
action: element.action.slice(0, 2_048),
method: element.method || 'get',
name: element.name.slice(0, 240),
fieldNodeIds,
});
}
}
if (element instanceof HTMLMetaElement && metaCount < 80) {
const key = element.getAttribute('name') || element.getAttribute('property') || '';
if (key) {
meta[key.slice(0, 240)] = (element.content || '').slice(0, 2_048);
metaCount += 1;
}
}
if (element.shadowRoot) visitRoot(element.shadowRoot, shadowDepth + 1);
current = walker.nextNode();
}
};
if (input.options.includeDom !== false) visitRoot(document, 0);
if (headings.length >= MAX_HEADINGS) limitsReached.add('headings');
if (forms.length >= MAX_FORMS) limitsReached.add('forms');
const storageError = (error: unknown) => {
try { return (error instanceof Error ? error.message : String(error)).slice(0, 500); }
catch { return 'Storage access failed'; }
};
const readStorage = (name: 'localStorage' | 'sessionStorage'): PageStorageSummary => {
const entries: Array<{ key: string; value: string; byteLength: number; authRelated: boolean; truncated: boolean }> = [];
let approximateBytes = 0;
let storage: Storage | undefined;
try {
storage = globalThis[name];
} catch (error) {
return { supported: false, entries, totalEntries: 0, approximateBytes, truncated: false, error: storageError(error) };
}
if (!storage) return { supported: false, entries, totalEntries: 0, approximateBytes, truncated: false };
let totalEntries = 0;
try {
totalEntries = storage.length;
for (let index = 0; index < totalEntries && entries.length < MAX_STORAGE_ENTRIES; index += 1) {
const key = storage.key(index);
if (!key) continue;
const raw = storage.getItem(key) || '';
if (approximateBytes >= MAX_STORAGE_BYTES) break;
const bounded = truncateUtf8(raw, Math.min(MAX_STORAGE_VALUE, MAX_STORAGE_BYTES - approximateBytes));
approximateBytes += encoder.encode(bounded.value).byteLength;
entries.push({ key: key.slice(0, 500), value: bounded.value, byteLength: bounded.byteLength, authRelated: /(auth|token|jwt|session|login|user|csrf|sid)/i.test(key), truncated: bounded.truncated });
}
return { supported: true, entries, totalEntries, approximateBytes, truncated: entries.length < totalEntries };
} catch (error) {
return { supported: true, entries, totalEntries, approximateBytes, truncated: true, error: storageError(error) };
}
};
const collectStorageInventory = async (): Promise<BrowserStorageInventory> => {
const normalizeKey = (key: IDBValidKey): string | number => {
if (typeof key === 'string') return key.slice(0, 500);
if (typeof key === 'number') return key;
if (key instanceof Date) return key.toISOString();
if (Array.isArray(key)) return JSON.stringify(key).slice(0, 500);
return `[binary key: ${key.byteLength} bytes]`;
};
const requestValue = <T,>(request: IDBRequest<T>, timeoutMs = 700): Promise<T> => new Promise((resolve, reject) => {
const timer = globalThis.setTimeout(() => reject(new Error('IndexedDB request timed out')), timeoutMs);
request.onsuccess = () => { globalThis.clearTimeout(timer); resolve(request.result); };
request.onerror = () => { globalThis.clearTimeout(timer); reject(request.error || new Error('IndexedDB request failed')); };
});
let indexedDBApi: IDBFactory | undefined;
let indexedDBAccessError: string | undefined;
try { indexedDBApi = globalThis.indexedDB; }
catch (error) { indexedDBAccessError = storageError(error); }
const openDatabase = (api: IDBFactory, name: string): Promise<IDBDatabase> => new Promise((resolve, reject) => {
const request = api.open(name);
let settled = false;
const timer = globalThis.setTimeout(() => { settled = true; reject(new Error('IndexedDB open timed out')); }, 700);
request.onsuccess = () => {
globalThis.clearTimeout(timer);
if (settled) request.result.close(); else { settled = true; resolve(request.result); }
};
request.onerror = () => { globalThis.clearTimeout(timer); if (!settled) { settled = true; reject(request.error || new Error('IndexedDB open failed')); } };
request.onblocked = () => { globalThis.clearTimeout(timer); if (!settled) { settled = true; reject(new Error('IndexedDB open was blocked')); } };
});
const indexedResult: BrowserStorageInventory['indexedDB'] = {
supported: Boolean(indexedDBApi && typeof indexedDBApi.databases === 'function'),
databases: [],
truncated: false,
...(indexedDBAccessError ? { error: indexedDBAccessError } : {}),
};
if (indexedResult.supported && indexedDBApi) {
try {
const allDatabases = await Promise.race([
indexedDBApi.databases(),
new Promise<never>((_, reject) => globalThis.setTimeout(() => reject(new Error('IndexedDB inventory timed out')), 1_000)),
]);
const databases = allDatabases.filter((database) => database.name).slice(0, 10);
indexedResult.truncated = allDatabases.length > databases.length;
let remainingStores = 50;
for (const databaseInfo of databases) {
const name = databaseInfo.name!;
try {
const database = await openDatabase(indexedDBApi, name);
const storeNames = Array.from(database.objectStoreNames).slice(0, Math.min(20, remainingStores));
const databaseSummary: BrowserStorageInventory['indexedDB']['databases'][number] = {
name: name.slice(0, 500), version: database.version, stores: [],
truncated: database.objectStoreNames.length > storeNames.length,
};
if (storeNames.length > 0) {
for (const storeName of storeNames) {
try {
const store = database.transaction(storeName, 'readonly').objectStore(storeName);
const [count, keys] = await Promise.all([
requestValue(store.count()),
requestValue(store.getAllKeys(undefined, 10)),
]);
databaseSummary.stores.push({
name: storeName.slice(0, 500),
keyPath: typeof store.keyPath === 'string'
? store.keyPath.slice(0, 500)
: Array.isArray(store.keyPath) ? store.keyPath.map((item) => item.slice(0, 500)).slice(0, 20) : null,
autoIncrement: store.autoIncrement,
count,
sampleKeys: keys.map(normalizeKey),
truncated: count > keys.length,
});
} catch (error) {
databaseSummary.stores.push({
name: storeName.slice(0, 500), keyPath: null, autoIncrement: false, sampleKeys: [], truncated: true,
error: storageError(error),
});
}
remainingStores -= 1;
}
}
database.close();
indexedResult.databases.push(databaseSummary);
if (remainingStores <= 0) { indexedResult.truncated = true; break; }
} catch (error) {
indexedResult.databases.push({
name: name.slice(0, 500), version: databaseInfo.version || 0, stores: [], truncated: true,
error: storageError(error),
});
}
}
} catch (error) {
indexedResult.error = storageError(error);
}
}
let cacheStorageApi: CacheStorage | undefined;
let cacheStorageAccessError: string | undefined;
try { cacheStorageApi = globalThis.caches; }
catch (error) { cacheStorageAccessError = storageError(error); }
const cacheResult: BrowserStorageInventory['cacheStorage'] = {
supported: Boolean(cacheStorageApi && typeof cacheStorageApi.keys === 'function'),
names: [],
truncated: false,
...(cacheStorageAccessError ? { error: cacheStorageAccessError } : {}),
};
if (cacheResult.supported && cacheStorageApi) {
try {
const names = await cacheStorageApi.keys();
cacheResult.names = names.slice(0, 50).map((name) => name.slice(0, 500));
cacheResult.truncated = names.length > cacheResult.names.length;
} catch (error) {
cacheResult.error = storageError(error);
}
}
return { indexedDB: indexedResult, cacheStorage: cacheResult };
};
const cryptoPattern = /(encrypt|decrypt|crypto|cipher|sign|hash|md5|sha|aes|rsa|sm2|sm3|sm4|encode|decode)/i;
const cryptoCandidates: Array<{ path: string; kind: string }> = [];
for (const key of Object.getOwnPropertyNames(window).slice(0, 5_000)) {
if (!cryptoPattern.test(key)) continue;
try { cryptoCandidates.push({ path: key, kind: typeof Reflect.get(window, key) }); }
catch { cryptoCandidates.push({ path: key, kind: 'unreadable' }); }
if (cryptoCandidates.length >= 100) break;
}
const collectBodyText = () => {
if (input.options.includeDom === false || !document.body) return { value: '', truncated: false };
const parts: string[] = [];
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
let remaining = MAX_BODY_TEXT;
let visited = 0;
let truncated = false;
let current = walker.nextNode();
while (current && remaining > 0 && visited < 5_000) {
visited += 1;
const parent = current.parentElement;
if (parent && !parent.closest('script,style,noscript,template,[hidden],[aria-hidden="true"],yakit-browser-agent')) {
const raw = current.nodeValue || '';
const normalized = raw.slice(0, MAX_BODY_TEXT).replace(/\s+/g, ' ').trim();
if (normalized) {
const separatorBytes = parts.length ? 1 : 0;
if (remaining <= separatorBytes) { truncated = true; break; }
const bounded = truncateUtf8(normalized, remaining - separatorBytes);
parts.push(bounded.value);
remaining -= encoder.encode(bounded.value).byteLength + separatorBytes;
truncated ||= bounded.truncated || raw.length > MAX_BODY_TEXT;
}
}
current = walker.nextNode();
}
if (current || visited >= 5_000) truncated = true;
return { value: parts.join('\n'), truncated };
};
const bodyText = collectBodyText();
let storageInventory: BrowserStorageInventory | undefined;
if (input.options.includeStorage) {
try {
storageInventory = await collectStorageInventory();
} catch (error) {
const message = storageError(error);
storageInventory = {
indexedDB: { supported: false, databases: [], truncated: false, error: message },
cacheStorage: { supported: false, names: [], truncated: false, error: message },
};
}
}
const localStorageSummary = input.options.includeStorage ? readStorage('localStorage') : undefined;
const sessionStorageSummary = input.options.includeStorage ? readStorage('sessionStorage') : undefined;
const registry = { captureId: input.captureId, nodes, summaries };
Reflect.set(globalThis, registryKey, registry);
return {
document: {
title: document.title.slice(0, 1_000),
url: location.href.slice(0, 8_192),
referrer: document.referrer.slice(0, 8_192),
language: (document.documentElement.lang || navigator.language).slice(0, 100),
charset: document.characterSet,
readyState: document.readyState,
bodyText: bodyText.value,
bodyTextTruncated: bodyText.truncated,
headings,
forms,
interactive,
meta,
localStorage: localStorageSummary,
sessionStorage: sessionStorageSummary,
storageInventory,
cryptoCandidates,
scannedElementCount,
limitsReached: [...limitsReached],
},
authenticationSeed: { passwordFieldCount, hasLoginControl, hasLogoutControl, hasAccountControl },
};
}
interface ContextDigest {
captureId: string;
documentId?: string;
title: string;
url: string;
authentication: PageAuthenticationSignals['status'];
included: string;
nodes: Map<string, PageContextChange & { signature: string }>;
formSignature: string;
storageKeys: Set<string>;
cookieNames: Set<string>;
}
const contextDigests = new Map<string, ContextDigest>();
const MAX_MEMORY_CONTEXT_DIGESTS = 32;
const MAX_PERSISTED_CONTEXT_DIGESTS = 8;
const contextSessionStorage = (browser.storage as unknown as {
session?: { get(key: string): Promise<Record<string, unknown>>; set(items: Record<string, unknown>): Promise<void> };
}).session;
let contextPersistTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
interface StoredContextDigest extends Omit<ContextDigest, 'nodes' | 'storageKeys' | 'cookieNames'> {
nodes: Array<[string, PageContextChange & { signature: string }]>;
storageKeys: string[];
cookieNames: string[];
}
function storedDigest(input: ContextDigest): StoredContextDigest {
return { ...input, nodes: [...input.nodes], storageKeys: [...input.storageKeys], cookieNames: [...input.cookieNames] };
}
async function restoreContextDigests(): Promise<void> {
if (!contextSessionStorage) return;
try {
const stored = await contextSessionStorage.get(CONTEXT_DIGEST_STORAGE_KEY);
const values = stored[CONTEXT_DIGEST_STORAGE_KEY];
if (!Array.isArray(values)) return;
for (const item of values.slice(-MAX_PERSISTED_CONTEXT_DIGESTS)) {
const entry = item as Partial<StoredContextDigest> & { key?: unknown };
if (typeof entry.key !== 'string' || typeof entry.captureId !== 'string' || typeof entry.title !== 'string'
|| typeof entry.url !== 'string' || !Array.isArray(entry.nodes) || !Array.isArray(entry.storageKeys)
|| !Array.isArray(entry.cookieNames) || !['authenticated', 'unauthenticated', 'unknown'].includes(String(entry.authentication))) continue;
contextDigests.set(entry.key, {
captureId: entry.captureId,
documentId: typeof entry.documentId === 'string' ? entry.documentId : undefined,
title: entry.title,
url: entry.url,
authentication: entry.authentication as PageAuthenticationSignals['status'],
included: typeof entry.included === 'string' ? entry.included : 'dom',
nodes: new Map(entry.nodes),
formSignature: typeof entry.formSignature === 'string' ? entry.formSignature : '',
storageKeys: new Set(entry.storageKeys.filter((value): value is string => typeof value === 'string')),
cookieNames: new Set(entry.cookieNames.filter((value): value is string => typeof value === 'string')),
});
}
} catch {
// Context diff remains available in memory when session storage is unavailable.
}
}
const contextDigestRestore = restoreContextDigests();
function scheduleContextDigestPersist(): void {
if (!contextSessionStorage || contextPersistTimer) return;
contextPersistTimer = globalThis.setTimeout(() => {
contextPersistTimer = undefined;
const values = [...contextDigests].slice(-MAX_PERSISTED_CONTEXT_DIGESTS).map(([key, digest]) => ({ key, ...storedDigest(digest) }));
void contextSessionStorage.set({ [CONTEXT_DIGEST_STORAGE_KEY]: values }).catch(() => undefined);
}, 250);
}
browser.tabs.onRemoved.addListener((tabId) => {
let changed = false;
for (const key of contextDigests.keys()) {
if (!key.startsWith(`${tabId}:`)) continue;
contextDigests.delete(key);
changed = true;
}
if (changed) scheduleContextDigestPersist();
});
function difference(left: Set<string>, right: Set<string>, limit = 100): string[] {
return [...left].filter((item) => !right.has(item)).slice(0, limit);
}
async function contextDiff(context: Omit<PageContext, 'diff'>): Promise<PageContextDiff> {
await contextDigestRestore;
const key = `${context.target.tabId}:${context.target.frameId}`;
const nodes = new Map(context.document.interactive.map((node) => [node.semanticKey, {
semanticKey: node.semanticKey, tag: node.tag, text: node.accessibleName || node.text, nodeId: node.nodeId,
signature: `${node.visible}|${node.disabled}|${node.required}|${node.checked ?? ''}|${node.href || ''}`,
}]));
const storageKeys = new Set([
...(context.document.localStorage?.entries.map((entry) => `local:${entry.key}`) || []),
...(context.document.sessionStorage?.entries.map((entry) => `session:${entry.key}`) || []),
]);
const cookieNames = new Set(context.authentication.cookieNames);
const current: ContextDigest = {
captureId: context.captureId,
documentId: context.target.documentId,
title: context.document.title,
url: context.document.url,
authentication: context.authentication.status,
included: `${context.included.dom}:${context.included.storage}:${context.included.cookies}`,
nodes,
formSignature: context.document.forms.map((form) => `${form.semanticKey}|${form.method}|${form.action}|${form.fieldNodeIds.length}`).join('\n'),
storageKeys,
cookieNames,
};
const previous = contextDigests.get(key);
contextDigests.delete(key);
contextDigests.set(key, current);
while (contextDigests.size > MAX_MEMORY_CONTEXT_DIGESTS) contextDigests.delete(contextDigests.keys().next().value!);
scheduleContextDigestPersist();
if (!previous) {
return {
kind: 'initial', toCaptureId: context.captureId, changedSections: [],
addedNodes: [], removedNodes: [], addedStorageKeys: [], removedStorageKeys: [], addedCookieNames: [], removedCookieNames: [],
};
}
const changedSections = new Set<PageContextDiff['changedSections'][number]>();
const sameOptions = previous.included === current.included;
const [previousDom, previousStorage, previousCookies] = previous.included.split(':').map((value) => value === 'true');
if (!sameOptions) changedSections.add('capture_options');
if (previous.title !== current.title || previous.url !== current.url || previous.documentId !== current.documentId) changedSections.add('document');
if (sameOptions && previous.authentication !== current.authentication) changedSections.add('authentication');
if (previousDom && context.included.dom && previous.formSignature !== current.formSignature) changedSections.add('forms');
const addedNodes = previousDom && context.included.dom ? [...current.nodes.entries()].filter(([semanticKey, node]) => {
const old = previous.nodes.get(semanticKey);
return !old || old.signature !== node.signature;
}).map(([, node]) => node).slice(0, 50) : [];
const removedNodes = previousDom && context.included.dom ? [...previous.nodes.entries()].filter(([semanticKey, node]) => {
const next = current.nodes.get(semanticKey);
return !next || next.signature !== node.signature;
}).map(([, node]) => ({ semanticKey: node.semanticKey, tag: node.tag, text: node.text })).slice(0, 50) : [];
if (addedNodes.length || removedNodes.length) changedSections.add('interactive');
const addedStorageKeys = previousStorage && context.included.storage ? difference(current.storageKeys, previous.storageKeys) : [];
const removedStorageKeys = previousStorage && context.included.storage ? difference(previous.storageKeys, current.storageKeys) : [];
if (addedStorageKeys.length || removedStorageKeys.length) changedSections.add('storage');
const addedCookieNames = previousCookies && context.included.cookies ? difference(current.cookieNames, previous.cookieNames) : [];
const removedCookieNames = previousCookies && context.included.cookies ? difference(previous.cookieNames, current.cookieNames) : [];
if (addedCookieNames.length || removedCookieNames.length) changedSections.add('cookies');
const documentChanged = Boolean(previous.documentId && current.documentId && previous.documentId !== current.documentId);
return {
kind: documentChanged ? 'document_changed' : changedSections.size ? 'changed' : 'unchanged',
fromCaptureId: previous.captureId,
toCaptureId: context.captureId,
changedSections: [...changedSections], addedNodes, removedNodes,
addedStorageKeys, removedStorageKeys, addedCookieNames, removedCookieNames,
};
}
function authenticationSignals(
seed: { passwordFieldCount: number; hasLoginControl: boolean; hasLogoutControl: boolean; hasAccountControl: boolean },
documentContext: PageContext['document'],
cookieNames: string[],
): PageAuthenticationSignals {
const evidence: string[] = [];
let score = 0;
if (seed.hasLogoutControl) { score += 3; evidence.push('页面存在退出登录控件'); }
if (seed.hasAccountControl) { score += 2; evidence.push('页面存在账户或个人中心控件'); }
if (seed.passwordFieldCount > 0) { score -= 2; evidence.push(`页面存在 ${seed.passwordFieldCount} 个密码输入框`); }
if (seed.hasLoginControl) { score -= 1; evidence.push('页面存在登录控件'); }
const authCookieNames = cookieNames.filter((name) => /(auth|token|jwt|session|login|sid)/i.test(name));
if (authCookieNames.length > 0) { score += 2; evidence.push(`发现 ${authCookieNames.length} 个疑似认证 Cookie 名称`); }
const storageKeys = [
...(documentContext.localStorage?.entries || []),
...(documentContext.sessionStorage?.entries || []),
].filter((entry) => entry.authRelated).map((entry) => entry.key);
if (storageKeys.length > 0) { score += 2; evidence.push(`发现 ${storageKeys.length} 个疑似认证 Storage 键`); }
return {
status: score >= 2 ? 'authenticated' : score <= -2 ? 'unauthenticated' : 'unknown',
confidence: Math.min(0.95, Math.round((0.3 + Math.abs(score) * 0.1) * 100) / 100),
evidence: evidence.slice(0, 8),
passwordFieldCount: seed.passwordFieldCount,
cookieNames: cookieNames.slice(0, 200),
storageKeys: storageKeys.slice(0, 200),
};
}
export async function capturePageContext(options: PageContextOptions = {}, input?: BrowserTarget | number): Promise<PageContext> {
const tab = await getTab(typeof input === 'number' ? input : input?.tabId);
if (!/^https?:/i.test(tab.url)) throw new Error('当前页面不允许采集上下文');
const target = await resolveDocumentTarget(input || tab.id);
const captureId = crypto.randomUUID();
let injections: Array<Browser.scripting.InjectionResult & { error?: string }>;
try {
injections = await browser.scripting.executeScript({
target: scriptingTarget(target),
world: 'MAIN',
func: collectDocumentContext,
args: [{ options, captureId }],
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new ExtensionError('context_capture_failed', `页面上下文采集失败:${message}`);
}
if (injections.length !== 1) throw new ExtensionError('context_capture_failed', '页面上下文采集无法唯一定位目标文档');
const [{ result, error }] = injections;
if (error) throw new ExtensionError('context_capture_failed', `页面上下文采集失败:${error}`);
if (result === undefined) throw new ExtensionError('context_capture_failed', '页面上下文采集脚本没有返回结果');
const collected = result as Awaited<ReturnType<typeof collectDocumentContext>>;
const [cookies, frames, lifecycle] = await Promise.all([
options.includeCookies ? listCookies(collected.document.url) : undefined,
getFrameInventory(target.tabId),
getPageLifecycle(target.tabId, target.frameId, target.documentId),
]);
const authentication = authenticationSignals(collected.authenticationSeed, collected.document, cookies?.map((cookie) => cookie.name) || []);
const contextWithoutDiff: Omit<PageContext, 'diff'> = {
captureId,
capturedAt: Date.now(),
included: { dom: options.includeDom !== false, storage: options.includeStorage === true, cookies: options.includeCookies === true },
tab,
target,
frames,
lifecycle,
authentication,
document: collected.document,
cookies,
};
return { ...contextWithoutDiff, diff: await contextDiff(contextWithoutDiff) };
}
function operateRegisteredNode(input: { captureId: string; nodeId: string; operation: 'inspect' | PageNodeAction; value?: string }) {
const registryKey = Symbol.for('com.yaklang.browser.context.registry.v1');
const registry = Reflect.get(globalThis, registryKey) as {
captureId?: string;
nodes?: Map<string, Element>;
summaries?: Map<string, PageNodeSummary>;
} | undefined;
if (!registry || registry.captureId !== input.captureId) {
return { ok: false as const, code: 'stale_node', message: '上下文快照已经失效,请重新采集页面上下文' };
}
const element = registry.nodes?.get(input.nodeId);
const summary = registry.summaries?.get(input.nodeId);
if (!element || !summary || !element.isConnected) {
return { ok: false as const, code: 'stale_node', message: '页面元素已被替换或移除,请重新采集页面上下文' };
}
const safeAttributes = new Set(['id', 'name', 'type', 'role', 'href', 'action', 'method', 'placeholder', 'autocomplete', 'disabled', 'required', 'checked', 'aria-label', 'aria-labelledby', 'aria-disabled', 'aria-required']);
const attributes: Record<string, string> = {};
for (const attribute of Array.from(element.attributes).slice(0, 80)) {
if (safeAttributes.has(attribute.name)) attributes[attribute.name] = attribute.value.slice(0, 2_048);
}
const rect = element.getBoundingClientRect();
const node = {
...summary,
connected: true,
attributes,
...(rect.width || rect.height ? { bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } } : {}),
};
if (input.operation === 'inspect') return { ok: true as const, node };
const control = element as HTMLInputElement;
if (input.operation === 'click') {
if (control.disabled || element.getAttribute('aria-disabled') === 'true') {
return { ok: false as const, code: 'node_not_actionable', message: '页面元素当前不可点击' };
}
const click = (element as HTMLElement).click;
if (typeof click !== 'function') return { ok: false as const, code: 'node_not_actionable', message: '页面元素不支持原生 click 操作' };
globalThis.setTimeout(() => click.call(element), 0);
} else if (input.operation === 'focus') {
if (!(element instanceof HTMLElement)) return { ok: false as const, code: 'node_not_actionable', message: '页面元素不支持聚焦' };
element.focus({ preventScroll: true });
} else if (input.operation === 'scroll') {
element.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
} else if (input.operation === 'setValue') {
if (typeof input.value !== 'string') return { ok: false as const, code: 'invalid_node_action', message: 'setValue 缺少 value' };
if (element instanceof HTMLInputElement) {
if (element.type === 'file') return { ok: false as const, code: 'node_not_actionable', message: '不能通过 setValue 写入文件输入框' };
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
setter?.call(element, input.value);
} else if (element instanceof HTMLTextAreaElement) {
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
setter?.call(element, input.value);
} else if (element instanceof HTMLSelectElement) {
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
setter?.call(element, input.value);
} else if (element instanceof HTMLElement && element.isContentEditable) {
element.textContent = input.value;
} else {
return { ok: false as const, code: 'node_not_actionable', message: '页面元素不支持 setValue' };
}
element.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true, inputType: 'insertText', data: input.value }));
element.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
}
return { ok: true as const, node };
}
async function operateNode(
captureId: string,
nodeId: string,
operation: 'inspect' | PageNodeAction,
input: BrowserTarget | number,
value?: string,
): Promise<PageNodeDetails> {
const target = await resolveDocumentTarget(input);
const [{ result }] = await browser.scripting.executeScript({
target: scriptingTarget(target),
world: 'MAIN',
func: operateRegisteredNode,
args: [{ captureId, nodeId, operation, value }],
});
if (!result?.ok) throw new ExtensionError(result?.code || 'node_operation_failed', result?.message || '页面元素操作失败');
return {
...(result.node as unknown as PageNodeSummary),
connected: true,
attributes: (result.node.attributes || {}) as Record<string, string>,
bounds: result.node.bounds,
reference: { captureId, nodeId, ...target },
};
}
export function inspectPageNode(captureId: string, nodeId: string, input: BrowserTarget | number): Promise<PageNodeDetails> {
return operateNode(captureId, nodeId, 'inspect', input);
}
export async function actOnPageNode(
captureId: string,
nodeId: string,
action: PageNodeAction,
input: BrowserTarget | number,
value?: string,
): Promise<PageNodeActionResult> {
const node = await operateNode(captureId, nodeId, action, input, value);
return { action, completedAt: Date.now(), node };
}
export async function invokePageFunction(path: string, args: unknown[], input?: BrowserTarget | number, timeoutMs = 10_000): Promise<PageEvalResult> {
const tab = await getTab(typeof input === 'number' ? input : input?.tabId);
const target = await resolveDocumentTarget(input || tab.id);
return executePageOperation(target, { operation: 'invoke', path, args }, timeoutMs);
}
export async function evalInPage(code: string, mode: 'expression' | 'program', input?: BrowserTarget | number, timeoutMs = 10_000): Promise<PageEvalResult> {
if (!code.trim()) throw new Error('执行代码不能为空');
const tab = await getTab(typeof input === 'number' ? input : input?.tabId);
const target = await resolveDocumentTarget(input || tab.id);
return executePageOperation(target, { operation: 'eval', mode, code }, timeoutMs);
}
+217
View File
@@ -0,0 +1,217 @@
import { browser, type Browser } from 'wxt/browser';
import { scriptingTarget } from '@/platform/browser/targets';
import type {
BrowserTarget, PageObservationOptions, PageObservationRecord, PageObservationStatus,
} from '@/types/models';
import { ExtensionError } from '@/shared/errors';
const OBSERVER_SCRIPT = '/page-observer-main-world.js' as const;
const DEFAULT_OPTIONS: PageObservationOptions = { captureValues: false, maxEntries: 100, maxValueBytes: 2_048 };
const MAX_ENTRIES = 200;
interface PageObserverSnapshot {
version: 2;
active: boolean;
startedAt?: number;
count: number;
droppedCount: number;
options?: PageObservationOptions;
records: PageObservationRecord[];
}
interface OwnedObservation {
target: BrowserTarget;
owner: { kind: 'local' } | { kind: 'grant'; grantId: string };
}
type ObserverCommand = 'start' | 'status' | 'list' | 'clear' | 'stop';
const ownedObservations = new Map<string, OwnedObservation>();
function targetKey(target: BrowserTarget): string {
return `${target.tabId}:${target.frameId}`;
}
function pageObserverCommand(command: ObserverCommand, input: Record<string, unknown>): unknown {
const controller = (window as unknown as Record<string, unknown>).__YAKIT_PAGE_OBSERVER_V2__ as {
version?: unknown;
command?: (name: ObserverCommand, params: Record<string, unknown>) => unknown;
} | undefined;
if (controller?.version !== 2 || typeof controller.command !== 'function') {
if (command === 'status') return { version: 2, active: false, count: 0, droppedCount: 0, records: [] };
throw new Error('页面观测器未安装');
}
return controller.command(command, input);
}
function finiteNumber(value: unknown, fallback = 0): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function optionalString(value: unknown, maxLength: number): string | undefined {
return typeof value === 'string' ? value.slice(0, maxLength) : undefined;
}
function normalizeOptions(input?: Partial<PageObservationOptions>): PageObservationOptions {
return {
captureValues: input?.captureValues === true,
maxEntries: Math.max(10, Math.min(Math.floor(input?.maxEntries || DEFAULT_OPTIONS.maxEntries), MAX_ENTRIES)),
maxValueBytes: Math.max(256, Math.min(Math.floor(input?.maxValueBytes || DEFAULT_OPTIONS.maxValueBytes), 8_192)),
expiresAt: typeof input?.expiresAt === 'number' && Number.isFinite(input.expiresAt) ? input.expiresAt : undefined,
};
}
function normalizeRecord(value: unknown, allowSensitive: boolean): PageObservationRecord | undefined {
if (!value || typeof value !== 'object') return undefined;
const input = value as Record<string, unknown>;
const kinds = ['fetch', 'xhr', 'form', 'websocket', 'webcrypto', 'cryptojs'] as const;
if (typeof input.id !== 'string' || !kinds.includes(input.kind as typeof kinds[number]) || typeof input.operation !== 'string') return undefined;
const output = {
id: input.id.slice(0, 160),
sequence: Math.max(0, Math.floor(finiteNumber(input.sequence))),
timestamp: finiteNumber(input.timestamp),
kind: input.kind as PageObservationRecord['kind'],
operation: input.operation.slice(0, 160),
sensitiveCaptured: allowSensitive && input.sensitiveCaptured === true,
} as PageObservationRecord & Record<string, unknown>;
const stringLimits: Record<string, number> = {
url: 8_192, method: 32, algorithm: 240, socketId: 160, dataType: 120,
stack: 4_096, scriptUrl: 2_048, error: 512,
};
for (const [key, limit] of Object.entries(stringLimits)) {
const normalized = optionalString(input[key], limit);
if (normalized !== undefined) output[key] = normalized;
}
if (input.direction === 'send' || input.direction === 'receive') output.direction = input.direction;
for (const key of ['byteLength', 'resultByteLength'] as const) {
if (input[key] !== undefined) output[key] = Math.max(0, finiteNumber(input[key]));
}
if (allowSensitive) {
output.inputPreview = optionalString(input.inputPreview, 8_192);
output.outputPreview = optionalString(input.outputPreview, 8_192);
}
return output;
}
function normalizeSnapshot(value: unknown, allowSensitive: boolean): PageObserverSnapshot {
if (!value || typeof value !== 'object') throw new ExtensionError('observer_unavailable', '页面观测器返回了无效状态');
const input = value as Record<string, unknown>;
if (input.version !== 2 || typeof input.active !== 'boolean' || !Array.isArray(input.records)) {
throw new ExtensionError('observer_unavailable', '页面观测器协议不兼容');
}
const pageOptions = input.options && typeof input.options === 'object'
? normalizeOptions(input.options as Partial<PageObservationOptions>)
: undefined;
return {
version: 2,
active: input.active,
startedAt: input.startedAt === undefined ? undefined : finiteNumber(input.startedAt),
count: Math.max(0, Math.floor(finiteNumber(input.count))),
droppedCount: Math.max(0, Math.floor(finiteNumber(input.droppedCount))),
options: pageOptions,
records: input.records.slice(-MAX_ENTRIES).map((item) => normalizeRecord(item, allowSensitive)).filter((item): item is PageObservationRecord => Boolean(item)),
};
}
async function executeCommand(
target: BrowserTarget,
command: ObserverCommand,
input: Record<string, unknown> = {},
allowSensitive = false,
): Promise<PageObserverSnapshot> {
let results: Browser.scripting.InjectionResult[];
try {
results = await browser.scripting.executeScript({
target: scriptingTarget(target),
world: 'MAIN',
func: pageObserverCommand,
args: [command, input],
});
} catch (error) {
throw new ExtensionError('observer_unavailable', error instanceof Error ? error.message : String(error));
}
if (results.length !== 1) throw new ExtensionError('observer_unavailable', '页面观测器无法唯一定位目标文档');
return normalizeSnapshot(results[0].result, allowSensitive);
}
async function install(target: BrowserTarget): Promise<void> {
try {
await browser.scripting.executeScript({ target: scriptingTarget(target), world: 'MAIN', files: [OBSERVER_SCRIPT] });
} catch (error) {
throw new ExtensionError('observer_unavailable', error instanceof Error ? error.message : String(error));
}
}
function statusFrom(target: BrowserTarget, snapshot: PageObserverSnapshot): PageObservationStatus {
return {
active: snapshot.active,
target,
startedAt: snapshot.startedAt,
count: snapshot.count,
droppedCount: snapshot.droppedCount,
options: snapshot.options,
};
}
export async function startPageObservation(
target: BrowserTarget,
input?: Partial<PageObservationOptions>,
owner: OwnedObservation['owner'] = { kind: 'local' },
): Promise<PageObservationStatus> {
const options = normalizeOptions(input);
await install(target);
const snapshot = await executeCommand(target, 'start', { ...options }, options.captureValues);
ownedObservations.set(targetKey(target), { target, owner });
return statusFrom(target, snapshot);
}
export async function pageObservationStatus(target: BrowserTarget): Promise<PageObservationStatus> {
try {
return statusFrom(target, await executeCommand(target, 'status'));
} catch (error) {
if (error instanceof ExtensionError && error.code === 'observer_unavailable') {
return { active: false, target, count: 0, droppedCount: 0 };
}
throw error;
}
}
export async function listPageObservations(target: BrowserTarget, limit = 100, allowSensitive = false): Promise<PageObservationRecord[]> {
const snapshot = await executeCommand(target, 'list', { limit: Math.max(1, Math.min(Math.floor(limit), MAX_ENTRIES)) }, allowSensitive);
return snapshot.records;
}
export async function clearPageObservations(target: BrowserTarget): Promise<PageObservationStatus> {
return statusFrom(target, await executeCommand(target, 'clear'));
}
export async function stopPageObservation(target: BrowserTarget): Promise<PageObservationStatus> {
const snapshot = await executeCommand(target, 'stop').catch(() => undefined);
ownedObservations.delete(targetKey(target));
return snapshot ? statusFrom(target, snapshot) : { active: false, target, count: 0, droppedCount: 0 };
}
export async function stopPageObservationsForGrant(grantId: string): Promise<void> {
const matches = [...ownedObservations.values()].filter((item) => item.owner.kind === 'grant' && item.owner.grantId === grantId);
await Promise.allSettled(matches.map((item) => stopPageObservation(item.target)));
}
export async function observationAnalysisWindow(target: BrowserTarget, centerTimestamp: number): Promise<Array<Pick<
PageObservationRecord,
'kind' | 'operation' | 'algorithm' | 'direction' | 'scriptUrl' | 'byteLength' | 'resultByteLength' | 'timestamp'
>>> {
const records = await listPageObservations(target, MAX_ENTRIES, false).catch(() => []);
return records.filter((item) => Math.abs(item.timestamp - centerTimestamp) <= 60_000).map((item) => ({
kind: item.kind,
operation: item.operation,
algorithm: item.algorithm,
direction: item.direction,
scriptUrl: item.scriptUrl,
byteLength: item.byteLength,
resultByteLength: item.resultByteLength,
timestamp: item.timestamp,
}));
}
browser.tabs.onRemoved.addListener((tabId) => {
for (const [key, observation] of ownedObservations) if (observation.target.tabId === tabId) ownedObservations.delete(key);
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import type { ProxyProfile, ProxyRule } from '@/types/models';
import { compileProxyRules, previewProxyRules, proxyPatternMatches } from './compiler';
const profiles: ProxyProfile[] = [
{ id: 'direct', name: 'Direct', kind: 'direct', bypass: [] },
{ id: 'mitm', name: 'MITM', kind: 'fixed_servers', scheme: 'http', host: '127.0.0.1', port: 8083, bypass: [] },
];
const rules: ProxyRule[] = [
{ id: 'low', name: 'Low', enabled: true, patterns: ['*.example.test'], proxyProfileId: 'direct', priority: 10 },
{ id: 'high', name: 'High', enabled: true, patterns: ['api.example.test'], proxyProfileId: 'mitm', priority: 20 },
];
describe('proxy compiler', () => {
it('matches exact, subdomain, wildcard and URL patterns', () => {
expect(proxyPatternMatches('example.test', 'https://api.example.test/path')).toBe(true);
expect(proxyPatternMatches('*.example.test', 'https://example.test/path')).toBe(true);
expect(proxyPatternMatches('api?.example.test', 'https://api1.example.test/')).toBe(true);
expect(proxyPatternMatches('https://*/api/*', 'https://api.example.test/api/1')).toBe(true);
expect(proxyPatternMatches('example.test', 'not-a-url')).toBe(false);
});
it('orders PAC branches by priority and applies fail-open', () => {
const pac = compileProxyRules(rules, profiles, { defaultProfileId: 'direct', failMode: 'open' });
expect(pac.indexOf('High [priority=20]')).toBeLessThan(pac.indexOf('Low [priority=10]'));
expect(pac).toContain('PROXY 127.0.0.1:8083; DIRECT');
expect(pac.trim().endsWith('}')).toBe(true);
});
it('reports deterministic conflicts and winner', () => {
const preview = previewProxyRules('https://api.example.test/', rules, profiles, { defaultProfileId: 'direct', failMode: 'closed' });
expect(preview.conflict).toBe(true);
expect(preview.matchedRuleIds).toEqual(['high', 'low']);
expect(preview.effectiveProfileId).toBe('mitm');
});
});
+92
View File
@@ -0,0 +1,92 @@
import type { ProxyProfile, ProxyRoutingSettings, ProxyRule, ProxyRulePreview } from '@/types/models';
function profileToPac(profile: ProxyProfile, failMode: ProxyRoutingSettings['failMode'] = 'closed'): string {
if (profile.kind === 'direct') return 'DIRECT';
if (profile.kind === 'system' || profile.kind === 'pac_script') throw new Error(`${profile.name} 不能嵌套到规则 PAC 中`);
const host = profile.host || '127.0.0.1';
const port = profile.port || 8083;
const proxy = profile.scheme === 'socks4' ? `SOCKS ${host}:${port}`
: profile.scheme === 'socks5' ? `SOCKS5 ${host}:${port}`
: profile.scheme === 'https' ? `HTTPS ${host}:${port}` : `PROXY ${host}:${port}`;
return failMode === 'open' ? `${proxy}; DIRECT` : proxy;
}
function pacLiteral(value: string): string {
return JSON.stringify(value).replaceAll('\u2028', '\\u2028').replaceAll('\u2029', '\\u2029');
}
export function sortedProxyRules(rules: ProxyRule[]): ProxyRule[] {
return [...rules].sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id));
}
function pacCondition(rawPattern: string): string {
const pattern = rawPattern.trim();
if (!pattern) return '';
if (pattern.includes('://') || pattern.includes('/')) return `shExpMatch(url, ${pacLiteral(pattern)})`;
if (pattern.startsWith('*.')) {
const domain = pattern.slice(2);
return `(host === ${pacLiteral(domain)} || dnsDomainIs(host, ${pacLiteral(`.${domain}`)}))`;
}
if (pattern.includes('*') || pattern.includes('?')) return `shExpMatch(host, ${pacLiteral(pattern)})`;
return `(host === ${pacLiteral(pattern)} || dnsDomainIs(host, ${pacLiteral(`.${pattern}`)}))`;
}
export function compileProxyRules(
rules: ProxyRule[],
profiles: ProxyProfile[],
routing: ProxyRoutingSettings = { defaultProfileId: 'direct', failMode: 'closed' },
): string {
const profileMap = new Map(profiles.map((profile) => [profile.id, profile]));
const branches = sortedProxyRules(rules)
.filter((rule) => rule.enabled && rule.patterns.length > 0)
.flatMap((rule) => {
const profile = profileMap.get(rule.proxyProfileId);
if (!profile) return [];
const conditions = rule.patterns.map(pacCondition).filter(Boolean);
return conditions.length > 0 ? [` // ${rule.name} [priority=${rule.priority}]\n if (${conditions.join(' || ')}) return ${pacLiteral(profileToPac(profile, routing.failMode))};`] : [];
});
const fallback = profileMap.get(routing.defaultProfileId) || profileMap.get('direct');
return `function FindProxyForURL(url, host) {\n${branches.join('\n')}\n return ${pacLiteral(fallback ? profileToPac(fallback, routing.failMode) : 'DIRECT')};\n}`;
}
function wildcardRegexp(pattern: string): RegExp {
return new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replaceAll('*', '.*').replaceAll('?', '.')}$`, 'i');
}
export function proxyPatternMatches(rawPattern: string, rawUrl: string): boolean {
try {
const url = new URL(rawUrl);
const pattern = rawPattern.trim();
if (!pattern) return false;
if (pattern.includes('://') || pattern.includes('/')) return wildcardRegexp(pattern).test(rawUrl);
if (pattern.startsWith('*.')) {
const domain = pattern.slice(2).toLowerCase();
return url.hostname.toLowerCase() === domain || url.hostname.toLowerCase().endsWith(`.${domain}`);
}
if (pattern.includes('*') || pattern.includes('?')) return wildcardRegexp(pattern).test(url.hostname);
return url.hostname.toLowerCase() === pattern.toLowerCase() || url.hostname.toLowerCase().endsWith(`.${pattern.toLowerCase()}`);
} catch {
return false;
}
}
export function previewProxyRules(
url: string,
rules: ProxyRule[],
profiles: ProxyProfile[],
routing: ProxyRoutingSettings,
): ProxyRulePreview {
const matches = sortedProxyRules(rules).filter((rule) => rule.enabled && rule.patterns.some((pattern) => proxyPatternMatches(pattern, url)));
const profileIds = [...new Set(matches.map((rule) => rule.proxyProfileId))];
const effectiveProfileId = matches[0]?.proxyProfileId || routing.defaultProfileId;
const profile = profiles.find((item) => item.id === effectiveProfileId) || profiles.find((item) => item.id === 'direct')!;
return {
url,
matchedRuleIds: matches.map((rule) => rule.id),
effectiveRuleId: matches[0]?.id,
effectiveProfileId: profile.id,
effectiveProxy: profileToPac(profile, routing.failMode),
conflict: profileIds.length > 1,
conflictProfileIds: profileIds,
};
}
+170
View File
@@ -0,0 +1,170 @@
import { browser } from 'wxt/browser';
import { isStateStorageChange, PROXY_AUTH_STORAGE_KEY, PROXY_STATS_STORAGE_KEY } from '@/protocol/storage';
import type {
ExtensionState, ProxyProfile, ProxyRoutingSettings, ProxyRule, ProxyRulePreview, ProxyRuleStats,
} from '@/types/models';
import { getState, updateState } from '@/platform/storage/state';
import {
compileProxyRules, previewProxyRules, proxyPatternMatches, sortedProxyRules,
} from './compiler';
export { compileProxyRules, previewProxyRules, proxyPatternMatches } from './compiler';
function isFirefox(): boolean {
return Boolean(import.meta.env.FIREFOX);
}
function chromeProxyValue(profile: ProxyProfile): object {
if (profile.kind === 'direct') return { mode: 'direct' };
if (profile.kind === 'system') return { mode: 'system' };
if (profile.kind === 'pac_script') {
return {
mode: 'pac_script',
pacScript: profile.pacScript
? { data: profile.pacScript, mandatory: true }
: { url: profile.pacUrl, mandatory: true },
};
}
return {
mode: 'fixed_servers',
rules: {
singleProxy: {
scheme: profile.scheme || 'http',
host: profile.host || '127.0.0.1',
port: profile.port || 8083,
},
bypassList: profile.bypass,
},
};
}
function firefoxProxyValue(profile: ProxyProfile): object {
if (profile.kind === 'direct') return { proxyType: 'none' };
if (profile.kind === 'system') return { proxyType: 'system' };
if (profile.kind === 'pac_script') {
return profile.pacUrl
? { proxyType: 'autoConfig', autoConfigUrl: profile.pacUrl }
: { proxyType: 'autoConfig', autoConfigUrl: `data:application/x-ns-proxy-autoconfig,${encodeURIComponent(profile.pacScript || '')}` };
}
if (profile.scheme === 'socks4' || profile.scheme === 'socks5') {
return {
proxyType: 'manual',
socks: `${profile.host}:${profile.port}`,
socksVersion: profile.scheme === 'socks4' ? 4 : 5,
proxyDNS: true,
passthrough: profile.bypass.join(', '),
};
}
const address = `${profile.scheme || 'http'}://${profile.host}:${profile.port}`;
return { proxyType: 'manual', http: address, ssl: address, httpProxyAll: true, passthrough: profile.bypass.join(', ') };
}
export async function switchProxy(profileId: string): Promise<void> {
const state = await getState();
const profile = state.proxyProfiles.find((item) => item.id === profileId);
if (!profile) throw new Error('代理配置不存在');
if (!browser.proxy?.settings) throw new Error('当前浏览器不支持代理 API');
const value = isFirefox() ? firefoxProxyValue(profile) : chromeProxyValue(profile);
await browser.proxy.settings.set({ value: value as Browser.proxy.ProxyConfig, scope: 'regular' });
await updateState((current) => ({ ...current, activeProxyId: profileId }));
}
export async function applyProxyRules(): Promise<void> {
const state = await getState();
const pacScript = compileProxyRules(state.proxyRules, state.proxyProfiles, state.proxyRouting);
if (isFirefox()) {
await browser.proxy.settings.set({
value: { proxyType: 'autoConfig', autoConfigUrl: `data:application/x-ns-proxy-autoconfig,${encodeURIComponent(pacScript)}` } as unknown as Browser.proxy.ProxyConfig,
scope: 'regular',
});
} else {
await browser.proxy.settings.set({
value: { mode: 'pac_script', pacScript: { data: pacScript, mandatory: true } },
scope: 'regular',
});
}
await updateState((current) => ({ ...current, activeProxyId: 'rules' }));
}
interface StorageArea {
get(key: string): Promise<Record<string, unknown>>;
set(items: Record<string, unknown>): Promise<void>;
}
const sessionStorage = (browser.storage as unknown as { session?: StorageArea }).session;
const authPasswords = new Map<string, string>();
const ruleStats = new Map<string, ProxyRuleStats>();
let routingState: ExtensionState | undefined;
let statsTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
void getState().then((state) => { routingState = state; }).catch(() => undefined);
if (sessionStorage) {
void sessionStorage.get(PROXY_AUTH_STORAGE_KEY).then((stored) => {
const values = stored[PROXY_AUTH_STORAGE_KEY];
if (values && typeof values === 'object') for (const [id, password] of Object.entries(values)) if (typeof password === 'string') authPasswords.set(id, password);
}).catch(() => undefined);
void sessionStorage.get(PROXY_STATS_STORAGE_KEY).then((stored) => {
const values = stored[PROXY_STATS_STORAGE_KEY];
if (Array.isArray(values)) for (const item of values) {
const stat = item as ProxyRuleStats;
if (typeof stat.ruleId === 'string' && Number.isFinite(stat.hits)) ruleStats.set(stat.ruleId, stat);
}
}).catch(() => undefined);
}
browser.storage.onChanged.addListener((changes) => {
if (isStateStorageChange(changes)) void getState().then((state) => { routingState = state; }).catch(() => undefined);
});
function persistStats(): void {
if (!sessionStorage || statsTimer) return;
statsTimer = globalThis.setTimeout(() => {
statsTimer = undefined;
void sessionStorage.set({ [PROXY_STATS_STORAGE_KEY]: [...ruleStats.values()] }).catch(() => undefined);
}, 1_000);
}
browser.webRequest.onBeforeRequest.addListener((details) => {
const state = routingState;
if (!state || state.activeProxyId !== 'rules') return;
const rule = sortedProxyRules(state.proxyRules).find((item) => item.enabled && item.patterns.some((pattern) => proxyPatternMatches(pattern, details.url)));
if (!rule) return;
const current = ruleStats.get(rule.id) || { ruleId: rule.id, hits: 0 };
ruleStats.set(rule.id, { ...current, hits: current.hits + 1, lastHitAt: Date.now(), lastUrl: details.url.slice(0, 2_048) });
persistStats();
}, { urls: ['<all_urls>'] });
browser.webRequest.onAuthRequired.addListener((details, asyncCallback) => {
const state = routingState;
const profile = state?.proxyProfiles.find((item) => item.id === state.activeProxyId);
const password = profile && authPasswords.get(profile.id);
const response = details.isProxy && profile?.authEnabled && profile.authUsername && password
? { authCredentials: { username: profile.authUsername, password } }
: {};
if (asyncCallback) {
asyncCallback(response);
return undefined;
}
return response;
}, { urls: ['<all_urls>'] }, [isFirefox() ? 'blocking' : 'asyncBlocking']);
export async function setProxyAuthPassword(profileId: string, password: string): Promise<void> {
if (password) authPasswords.set(profileId, password);
else authPasswords.delete(profileId);
if (sessionStorage) await sessionStorage.set({ [PROXY_AUTH_STORAGE_KEY]: Object.fromEntries(authPasswords) });
}
export function hasProxyAuthPassword(profileId: string): boolean {
return authPasswords.has(profileId);
}
export function getProxyRuleStats(): ProxyRuleStats[] {
return [...ruleStats.values()].sort((left, right) => right.hits - left.hits);
}
export async function clearProxyRuleStats(): Promise<void> {
ruleStats.clear();
if (sessionStorage) await sessionStorage.set({ [PROXY_STATS_STORAGE_KEY]: [] });
}
+5
View File
@@ -0,0 +1,5 @@
import { clsx, type ClassValue } from 'clsx';
export function cn(...values: ClassValue[]): string {
return clsx(values);
}
+66
View File
@@ -0,0 +1,66 @@
import { browser, type Browser } from 'wxt/browser';
import type { ActiveTabInfo, BrowserTarget } from '@/types/models';
import { ExtensionError } from '@/shared/errors';
type DocumentProbeResult = Browser.scripting.InjectionResult & { documentId?: string };
function probeDocument() {
return { url: location.href };
}
export function scriptingTarget(target: BrowserTarget): Browser.scripting.InjectionTarget {
if (target.documentId && !import.meta.env.FIREFOX) {
return { tabId: target.tabId, documentIds: [target.documentId] } as unknown as Browser.scripting.InjectionTarget;
}
return { tabId: target.tabId, frameIds: [target.frameId] };
}
export async function resolveDocumentTarget(input: BrowserTarget | number): Promise<BrowserTarget> {
const requested: BrowserTarget = typeof input === 'number'
? { tabId: input, frameId: 0 }
: { ...input, frameId: input.frameId ?? 0 };
let probe: DocumentProbeResult | undefined;
try {
[probe] = await browser.scripting.executeScript({
target: { tabId: requested.tabId, frameIds: [requested.frameId] },
world: 'MAIN',
func: probeDocument,
}) as DocumentProbeResult[];
} catch (error) {
throw new ExtensionError('target_unavailable', error instanceof Error ? error.message : String(error));
}
if (!probe) throw new ExtensionError('target_unavailable', '无法定位目标页面文档');
if (requested.documentId && probe.documentId && requested.documentId !== probe.documentId) {
throw new ExtensionError('stale_document', '目标页面已经刷新或导航,请重新授权');
}
return { tabId: requested.tabId, frameId: probe.frameId, documentId: probe.documentId || requested.documentId };
}
async function findRecentHttpTab(): Promise<Browser.tabs.Tab | undefined> {
const active = (await browser.tabs.query({ active: true, currentWindow: true }))[0];
if (active?.url && /^https?:/i.test(active.url)) return active;
const tabs = await browser.tabs.query({ currentWindow: true });
return tabs.filter((tab) => tab.url && /^https?:/i.test(tab.url))
.sort((left, right) => (right.lastAccessed || 0) - (left.lastAccessed || 0))[0];
}
export async function getTab(tabId?: number): Promise<ActiveTabInfo> {
const tab = tabId ? await browser.tabs.get(tabId) : await findRecentHttpTab();
if (!tab?.id || !tab.url) throw new Error('无法读取当前标签页');
return {
id: tab.id,
windowId: tab.windowId,
title: tab.title || '未命名页面',
url: tab.url,
favIconUrl: tab.favIconUrl,
lastAccessed: tab.lastAccessed,
};
}
export const getActiveTab = () => getTab();
export async function activateTab(tabId?: number): Promise<void> {
const tab = tabId ? await browser.tabs.get(tabId) : await browser.tabs.get((await getActiveTab()).id);
await browser.windows.update(tab.windowId, { focused: true });
await browser.tabs.update(tab.id, { active: true });
}
+18
View File
@@ -0,0 +1,18 @@
import { browser } from 'wxt/browser';
import type { ExtensionAction, ExtensionRequest, ExtensionResponse, RequestInput, RequestOutput } from '@/types/messages';
export async function request<A extends ExtensionAction>(
action: A,
...args: undefined extends RequestInput<A> ? [payload?: RequestInput<A>] : [payload: RequestInput<A>]
): Promise<RequestOutput<A>> {
const payload = args[0];
const response = (await browser.runtime.sendMessage({ action, payload } as ExtensionRequest)) as ExtensionResponse<RequestOutput<A>>;
if (!response?.ok) {
throw new Error(response?.error || `Extension request failed: ${action}`);
}
return response.data as RequestOutput<A>;
}
export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+24
View File
@@ -0,0 +1,24 @@
import { vi, describe, expect, it } from 'vitest';
vi.mock('wxt/browser', () => ({ browser: { storage: {} } }));
import type { BridgeConfig } from '@/types/models';
import { applyPolicyToBridge, assertGrantPolicy } from './managed';
const bridge: BridgeConfig = {
transport: 'websocket', endpoint: 'ws://127.0.0.1:64333/extension', nativeHost: 'default.host',
autoConnect: false, installationId: 'install-1',
};
describe('managed policy enforcement', () => {
it('forces Native Messaging without replacing the paired device identity', () => {
expect(applyPolicyToBridge(bridge, { disableWebSocket: true, nativeHost: 'managed.host', autoConnect: true }))
.toEqual({ ...bridge, transport: 'native', nativeHost: 'managed.host', autoConnect: true });
});
it('caps grants and rejects origins/program Eval', () => {
expect(assertGrantPolicy({ maxGrantMinutes: 30 }, { durationMinutes: 120, origins: ['https://a.test'], programEval: false })).toBe(30);
expect(() => assertGrantPolicy({ allowProgramEval: false }, { durationMinutes: 5, origins: [], programEval: true })).toThrow('禁止');
expect(() => assertGrantPolicy({ grantAllowedOrigins: ['https://a.test'] }, { durationMinutes: 5, origins: ['https://b.test'], programEval: false })).toThrow('不允许');
});
});
+90
View File
@@ -0,0 +1,90 @@
import { browser } from 'wxt/browser';
import type { BridgeConfig, EnterprisePolicy, EnterprisePolicyStatus, ExtensionState } from '@/types/models';
import { ExtensionError } from '@/shared/errors';
interface ManagedStorageArea {
get(keys?: null): Promise<Record<string, unknown>>;
}
function stringValue(input: unknown, maxLength: number): string | undefined {
return typeof input === 'string' && input.trim() && input.length <= maxLength ? input.trim() : undefined;
}
export async function getEnterprisePolicy(): Promise<EnterprisePolicyStatus> {
const area = (browser.storage as unknown as { managed?: ManagedStorageArea }).managed;
if (!area) return { managed: false, policy: {}, warnings: [] };
let input: Record<string, unknown>;
try {
input = await area.get(null);
} catch {
return { managed: false, policy: {}, warnings: [] };
}
const warnings: string[] = [];
const policy: EnterprisePolicy = {};
if (input.bridgeTransport === 'native' || input.bridgeTransport === 'websocket') policy.bridgeTransport = input.bridgeTransport;
if (input.bridgeEndpoint !== undefined) {
const value = stringValue(input.bridgeEndpoint, 2_048);
if (value) policy.bridgeEndpoint = value; else warnings.push('bridgeEndpoint 无效');
}
if (input.nativeHost !== undefined) {
const value = stringValue(input.nativeHost, 253);
if (value) policy.nativeHost = value; else warnings.push('nativeHost 无效');
}
for (const key of ['autoConnect', 'disableWebSocket', 'floatingPanelEnabled', 'allowProgramEval'] as const) {
if (typeof input[key] === 'boolean') policy[key] = input[key];
}
if (Number.isSafeInteger(input.maxGrantMinutes) && Number(input.maxGrantMinutes) >= 5 && Number(input.maxGrantMinutes) <= 1_440) {
policy.maxGrantMinutes = Number(input.maxGrantMinutes);
} else if (input.maxGrantMinutes !== undefined) warnings.push('maxGrantMinutes 无效');
if (Array.isArray(input.grantAllowedOrigins)) {
const origins: string[] = [];
for (const item of input.grantAllowedOrigins.slice(0, 500)) {
try {
if (typeof item !== 'string') throw new Error('not a string');
const origin = new URL(item).origin;
if (origin === 'null' || !/^https?:/.test(origin)) throw new Error('not HTTP(S)');
origins.push(origin);
} catch {
warnings.push('grantAllowedOrigins 包含无效 origin');
}
}
policy.grantAllowedOrigins = [...new Set(origins)];
}
return { managed: Object.keys(input).length > 0, policy, warnings: [...new Set(warnings)] };
}
export function applyPolicyToBridge(config: BridgeConfig, policy: EnterprisePolicy): BridgeConfig {
const transport = policy.disableWebSocket ? 'native' : policy.bridgeTransport || config.transport;
return {
...config,
transport,
endpoint: policy.bridgeEndpoint || config.endpoint,
nativeHost: policy.nativeHost || config.nativeHost,
autoConnect: policy.autoConnect ?? config.autoConnect,
};
}
export function applyPolicyToState(state: ExtensionState, policy: EnterprisePolicy): ExtensionState {
return {
...state,
bridge: applyPolicyToBridge(state.bridge, policy),
floatingPanel: {
...state.floatingPanel,
enabled: policy.floatingPanelEnabled ?? state.floatingPanel.enabled,
},
};
}
export function assertGrantPolicy(
policy: EnterprisePolicy,
input: { durationMinutes: number; origins: string[]; programEval: boolean },
): number {
if (input.programEval && policy.allowProgramEval === false) {
throw new ExtensionError('policy_denied', '企业策略禁止 browser.page.eval.program');
}
if (policy.grantAllowedOrigins?.length) {
const denied = input.origins.find((origin) => !policy.grantAllowedOrigins!.includes(origin));
if (denied) throw new ExtensionError('policy_denied', `企业策略不允许授权 origin: ${denied}`);
}
return Math.min(input.durationMinutes, policy.maxGrantMinutes || input.durationMinutes);
}
+56
View File
@@ -0,0 +1,56 @@
import { browser } from 'wxt/browser';
export type ThemePreference = 'system' | 'light' | 'dark';
export const APPEARANCE_STORAGE_KEY = 'settings.appearance.v1';
interface AppearanceSettings {
theme: ThemePreference;
}
const DEFAULT_APPEARANCE: AppearanceSettings = { theme: 'system' };
export async function getAppearance(): Promise<AppearanceSettings> {
const stored = await browser.storage.local.get(APPEARANCE_STORAGE_KEY);
const value = stored[APPEARANCE_STORAGE_KEY] as AppearanceSettings | undefined;
return value && ['system', 'light', 'dark'].includes(value.theme) ? value : DEFAULT_APPEARANCE;
}
export async function setThemePreference(theme: ThemePreference): Promise<void> {
await browser.storage.local.set({ [APPEARANCE_STORAGE_KEY]: { theme } satisfies AppearanceSettings });
}
export function resolveTheme(theme: ThemePreference): 'light' | 'dark' {
if (theme !== 'system') return theme;
return globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
/**
* Applies the stored theme to <html data-theme> and keeps it in sync with
* both the storage key and the OS color scheme. Returns a cleanup function.
*/
export function watchTheme(root: HTMLElement = document.documentElement): () => void {
const media = globalThis.matchMedia?.('(prefers-color-scheme: dark)');
let current: ThemePreference = 'system';
const apply = () => {
root.dataset.theme = resolveTheme(current);
};
void getAppearance().then((appearance) => {
current = appearance.theme;
apply();
});
const onStorageChange = (changes: Record<string, unknown>, area: string) => {
if (area !== 'local' || !(APPEARANCE_STORAGE_KEY in changes)) return;
const next = (changes[APPEARANCE_STORAGE_KEY] as { newValue?: AppearanceSettings })?.newValue;
current = next && ['system', 'light', 'dark'].includes(next.theme) ? next.theme : 'system';
apply();
};
const onMediaChange = () => apply();
browser.storage.onChanged.addListener(onStorageChange);
media?.addEventListener('change', onMediaChange);
apply();
return () => {
browser.storage.onChanged.removeListener(onStorageChange);
media?.removeEventListener('change', onMediaChange);
};
}
+59
View File
@@ -0,0 +1,59 @@
import { vi, describe, expect, it } from 'vitest';
const stores = vi.hoisted(() => ({
local: {} as Record<string, unknown>,
session: {} as Record<string, unknown>,
}));
function area(data: Record<string, unknown>) {
return {
async get(keys: string | string[]) {
const list = Array.isArray(keys) ? keys : [keys];
return Object.fromEntries(list.filter((key) => key in data).map((key) => [key, data[key]]));
},
async set(items: Record<string, unknown>) { Object.assign(data, structuredClone(items)); },
};
}
vi.mock('wxt/browser', () => ({
browser: {
storage: { local: area(stores.local), session: area(stores.session) },
},
}));
import {
ACTIVE_SESSION_STORAGE_KEY, BRIDGE_SETTINGS_STORAGE_KEY, FLOATING_UI_STORAGE_KEY,
PROXY_SETTINGS_STORAGE_KEY, USER_AGENT_SETTINGS_STORAGE_KEY,
} from '@/protocol/storage';
import { DEFAULT_STATE, getState, setState, updateState } from './state';
describe('split state storage', () => {
it('writes durable domains to local and grant/handoff to session', async () => {
const now = Date.now();
await setState({
...structuredClone(DEFAULT_STATE),
activeGrant: {
id: 'grant-1', taskId: 'task-1', createdAt: now, expiresAt: now + 60_000,
scopes: ['browser.tabs.read'],
targets: [{ tabId: 1, frameId: 0, origin: 'https://example.test', grantedUrl: 'https://example.test/', title: 'Example' }],
},
});
expect(Object.keys(stores.local)).toEqual(expect.arrayContaining([
PROXY_SETTINGS_STORAGE_KEY, USER_AGENT_SETTINGS_STORAGE_KEY, BRIDGE_SETTINGS_STORAGE_KEY, FLOATING_UI_STORAGE_KEY,
]));
expect(stores.local).not.toHaveProperty('yakit-extension-state-v5');
expect(stores.session).toHaveProperty(ACTIVE_SESSION_STORAGE_KEY);
expect((await getState()).activeGrant?.taskId).toBe('task-1');
});
it('serializes concurrent cross-domain updates without losing either write', async () => {
await setState(structuredClone(DEFAULT_STATE));
await Promise.all([
updateState((state) => ({ ...state, activeProxyId: 'yakit-mitm' })),
updateState((state) => ({ ...state, floatingPanel: { ...state.floatingPanel, side: 'left' } })),
]);
const state = await getState();
expect(state.activeProxyId).toBe('yakit-mitm');
expect(state.floatingPanel.side).toBe('left');
});
});
+140
View File
@@ -0,0 +1,140 @@
import { browser } from 'wxt/browser';
import type { BridgeRuntimeSession, ExtensionState } from '@/types/models';
import {
ACTIVE_SESSION_STORAGE_KEY, BRIDGE_SETTINGS_STORAGE_KEY, FLOATING_UI_STORAGE_KEY,
PROXY_SETTINGS_STORAGE_KEY, USER_AGENT_SETTINGS_STORAGE_KEY, BRIDGE_SESSION_STORAGE_KEY,
} from '@/protocol/storage';
interface StorageArea {
get(keys: string | string[]): Promise<Record<string, unknown>>;
set(items: Record<string, unknown>): Promise<void>;
}
let mutationQueue: Promise<void> = Promise.resolve();
const sessionStorage = (browser.storage as unknown as { session?: StorageArea }).session;
export const DEFAULT_STATE: ExtensionState = {
version: 7,
proxyProfiles: [
{ id: 'direct', name: '直接连接', kind: 'direct', bypass: [], builtin: true },
{ id: 'system', name: '系统代理', kind: 'system', bypass: [], builtin: true },
{
id: 'yakit-mitm', name: 'Yakit MITM', kind: 'fixed_servers', scheme: 'http', host: '127.0.0.1', port: 8083,
bypass: ['localhost', '127.0.0.1', '<local>'], builtin: true,
},
],
proxyRules: [],
proxyRouting: { defaultProfileId: 'direct', failMode: 'closed' },
activeProxyId: 'direct',
userAgentRules: [],
bridge: {
transport: 'websocket', nativeHost: 'com.yaklang.browser_agent', endpoint: 'ws://127.0.0.1:64333/extension',
autoConnect: false, installationId: crypto.randomUUID(),
},
floatingPanel: {
enabled: true, side: 'right', y: 0.46, displayMode: 'always', siteMode: 'all', siteOrigins: [],
shortcutEnabled: true, autoCollapseFullscreen: true,
},
};
function defaultProfiles() {
return DEFAULT_STATE.proxyProfiles.map((profile) => ({ ...profile, bypass: [...profile.bypass] }));
}
function normalizeState(value: Partial<ExtensionState>): ExtensionState {
const profileMap = new Map(defaultProfiles().map((profile) => [profile.id, profile]));
for (const profile of value.proxyProfiles || []) profileMap.set(profile.id, { ...profile, bypass: profile.bypass || [] });
const proxyProfiles = [...profileMap.values()];
const routableIds = new Set(proxyProfiles.filter((profile) => ['direct', 'fixed_servers'].includes(profile.kind)).map((profile) => profile.id));
const proxyRouting = { ...DEFAULT_STATE.proxyRouting, ...value.proxyRouting };
if (!routableIds.has(proxyRouting.defaultProfileId)) proxyRouting.defaultProfileId = 'direct';
return {
...DEFAULT_STATE,
...value,
version: 7,
proxyProfiles,
proxyRules: (value.proxyRules || []).filter((rule) => routableIds.has(rule.proxyProfileId)).map((rule, index) => ({ ...rule, priority: rule.priority || 1_000 - index })),
proxyRouting,
userAgentRules: value.userAgentRules || [],
bridge: { ...DEFAULT_STATE.bridge, ...value.bridge },
floatingPanel: {
...DEFAULT_STATE.floatingPanel, ...value.floatingPanel,
siteOrigins: [...new Set(value.floatingPanel?.siteOrigins || [])].slice(0, 500),
},
activeGrant: value.activeGrant?.expiresAt && value.activeGrant.expiresAt > Date.now() ? value.activeGrant : undefined,
};
}
export async function getState(): Promise<ExtensionState> {
const localKeys = [PROXY_SETTINGS_STORAGE_KEY, USER_AGENT_SETTINGS_STORAGE_KEY, BRIDGE_SETTINGS_STORAGE_KEY, FLOATING_UI_STORAGE_KEY];
const sessionPromise: Promise<Record<string, unknown>> = sessionStorage?.get(ACTIVE_SESSION_STORAGE_KEY) || Promise.resolve({});
const [local, session] = await Promise.all([
browser.storage.local.get(localKeys),
sessionPromise,
]);
const state = normalizeState({
...(local[PROXY_SETTINGS_STORAGE_KEY] as Partial<ExtensionState> | undefined),
...(local[USER_AGENT_SETTINGS_STORAGE_KEY] as Partial<ExtensionState> | undefined),
...(local[BRIDGE_SETTINGS_STORAGE_KEY] as Partial<ExtensionState> | undefined),
...(local[FLOATING_UI_STORAGE_KEY] as Partial<ExtensionState> | undefined),
...(session[ACTIVE_SESSION_STORAGE_KEY] as Partial<ExtensionState> | undefined),
});
const storedBridge = local[BRIDGE_SETTINGS_STORAGE_KEY] as { bridge?: Partial<ExtensionState['bridge']> } | undefined;
if (!storedBridge?.bridge?.installationId) {
await browser.storage.local.set({
[BRIDGE_SETTINGS_STORAGE_KEY]: { ...storedBridge, bridge: state.bridge },
});
}
return state;
}
export async function getBridgeRuntimeSession(): Promise<BridgeRuntimeSession | undefined> {
if (!sessionStorage) return undefined;
const stored = (await sessionStorage.get(BRIDGE_SESSION_STORAGE_KEY))[BRIDGE_SESSION_STORAGE_KEY];
if (!stored || typeof stored !== 'object') return undefined;
const value = stored as Partial<BridgeRuntimeSession>;
if (!value.sessionId || !value.engineInstanceId || typeof value.updatedAt !== 'number') return undefined;
return value as BridgeRuntimeSession;
}
export async function setBridgeRuntimeSession(value: BridgeRuntimeSession): Promise<void> {
await sessionStorage?.set({ [BRIDGE_SESSION_STORAGE_KEY]: value });
}
export async function setState(input: ExtensionState): Promise<ExtensionState> {
const state = normalizeState(input);
await Promise.all([
browser.storage.local.set({
[PROXY_SETTINGS_STORAGE_KEY]: {
proxyProfiles: state.proxyProfiles, proxyRules: state.proxyRules,
proxyRouting: state.proxyRouting, activeProxyId: state.activeProxyId,
},
[USER_AGENT_SETTINGS_STORAGE_KEY]: { userAgentRules: state.userAgentRules },
[BRIDGE_SETTINGS_STORAGE_KEY]: { bridge: state.bridge },
[FLOATING_UI_STORAGE_KEY]: { floatingPanel: state.floatingPanel },
}),
sessionStorage?.set({
[ACTIVE_SESSION_STORAGE_KEY]: { activeGrant: state.activeGrant, handoff: state.handoff },
}) || Promise.resolve(),
]);
return state;
}
export async function updateState(
updater: (current: ExtensionState) => ExtensionState | Promise<ExtensionState>,
): Promise<ExtensionState> {
let resolveResult!: (state: ExtensionState) => void;
let rejectResult!: (error: unknown) => void;
const result = new Promise<ExtensionState>((resolve, reject) => {
resolveResult = resolve;
rejectResult = reject;
});
mutationQueue = mutationQueue.then(async () => {
try {
resolveResult(await setState(await updater(await getState())));
} catch (error) {
rejectResult(error);
}
});
return result;
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import {
BRIDGE_MAX_MESSAGE_BYTES, BRIDGE_PROTOCOL_VERSION, parseBridgeEnvelope, parseBridgePairingEnvelope, parseCapabilityParams,
} from './bridge';
describe('Bridge v3 protocol', () => {
it('accepts an identified hello_ack', () => {
expect(parseBridgeEnvelope({
type: 'hello_ack', protocolVersion: BRIDGE_PROTOCOL_VERSION, version: 'test', capabilities: [],
sessionId: 'session-1', engineIdentityId: 'engine-identity-1', engineInstanceId: 'engine-1', connectionId: 'connection-1', resumed: true,
})).toMatchObject({ type: 'hello_ack', resumed: true });
});
it('rejects mismatched versions and missing identities', () => {
expect(() => parseBridgeEnvelope({ type: 'hello_ack', protocolVersion: 1, capabilities: [] })).toThrow('不兼容');
expect(() => parseBridgeEnvelope({ type: 'hello_ack', protocolVersion: BRIDGE_PROTOCOL_VERSION, capabilities: [] })).toThrow('engineIdentityId');
});
it('validates engine challenges and pairing responses', () => {
const publicKey = { kty: 'EC', crv: 'P-256', x: 'x-coordinate', y: 'y-coordinate' } as const;
expect(parseBridgeEnvelope({
type: 'challenge', protocolVersion: BRIDGE_PROTOCOL_VERSION, engineIdentityId: 'identity-1', engineInstanceId: 'instance-1',
challenge: 'challenge-1', signature: 'signature-1', timestamp: Date.now(), publicKey,
})).toMatchObject({ type: 'challenge', engineIdentityId: 'identity-1' });
expect(parseBridgePairingEnvelope({
type: 'pair_pending', protocolVersion: BRIDGE_PROTOCOL_VERSION, requestId: 'request-1', serverNonce: 'server-nonce',
engineIdentityId: 'identity-1', code: '123456', expiresAt: Date.now() + 60_000, publicKey,
})).toMatchObject({ type: 'pair_pending', code: '123456' });
});
it('validates heartbeat and chunk boundaries', () => {
expect(parseBridgeEnvelope({ type: 'pong', id: 'p1', sequence: 3, timestamp: 100 })).toMatchObject({ sequence: 3 });
expect(() => parseBridgeEnvelope({ type: 'ping' })).toThrow('心跳');
expect(parseBridgeEnvelope({
type: 'chunk', transferId: 't1', index: 0, total: 2, data: 'eA==', originalBytes: 2,
})).toMatchObject({ transferId: 't1' });
expect(() => parseBridgeEnvelope({
type: 'chunk', transferId: 't1', index: 2, total: 2, data: 'eA==', originalBytes: 2,
})).toThrow('序号');
});
it('requires explicit Eval mode and caps raw payloads', () => {
expect(parseCapabilityParams('browser.eval', { mode: 'expression', code: 'document.title' })).toMatchObject({ mode: 'expression' });
expect(() => parseCapabilityParams('browser.eval', { code: 'document.title' })).toThrow('mode');
expect(() => parseBridgeEnvelope('x'.repeat(BRIDGE_MAX_MESSAGE_BYTES + 1))).toThrow('16 MiB');
});
});
+232
View File
@@ -0,0 +1,232 @@
import * as v from 'valibot';
import type { BridgeEnvelope } from '@/types/messages';
import type { BridgePublicKey } from '@/types/models';
export const BRIDGE_PROTOCOL_VERSION = 3;
export const BRIDGE_MAX_MESSAGE_BYTES = 16 * 1024 * 1024;
export const BRIDGE_CHUNK_THRESHOLD_BYTES = 512 * 1024;
export const BRIDGE_CHUNK_BYTES = 256 * 1024;
export const BRIDGE_MAX_CHUNK_TRANSFERS = 8;
export const BRIDGE_CHUNK_TIMEOUT_MS = 30_000;
export interface BridgePairingEnvelope {
type: 'pair_request' | 'pair_pending' | 'pair_approved' | 'pair_rejected' | 'pair_expired' | 'pair_error';
protocolVersion?: number;
requestId?: string;
installationId?: string;
client?: string;
version?: string;
nonce?: string;
serverNonce?: string;
publicKey?: BridgePublicKey;
engineIdentityId?: string;
code?: string;
expiresAt?: number;
deviceId?: string;
message?: string;
}
const id = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160));
const tabId = v.pipe(v.number(), v.safeInteger(), v.minValue(1));
const optionalTabId = v.optional(tabId);
const optionalFrameId = v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(0)));
const optionalDocumentId = v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160)));
const targetFields = { tabId: optionalTabId, frameId: optionalFrameId, documentId: optionalDocumentId };
const captureId = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160));
const nodeId = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(80));
const capabilityParams = {
'system.ping': v.optional(v.strictObject({})),
'browser.tabs': v.optional(v.strictObject({})),
'browser.frames': v.optional(v.strictObject({ tabId: optionalTabId })),
'browser.context': v.optional(v.strictObject({
...targetFields,
includeDom: v.optional(v.boolean()),
includeStorage: v.optional(v.boolean()),
includeCookies: v.optional(v.boolean()),
})),
'browser.node.inspect': v.strictObject({ ...targetFields, captureId, nodeId }),
'browser.node.action': v.pipe(v.strictObject({
...targetFields,
captureId,
nodeId,
action: v.picklist(['click', 'focus', 'scroll', 'setValue']),
value: v.optional(v.pipe(v.string(), v.maxLength(100_000))),
}), v.check((input) => input.action !== 'setValue' || typeof input.value === 'string', 'setValue 操作必须提供 value')),
'browser.cookies': v.optional(v.strictObject(targetFields)),
'browser.takeover': v.optional(v.strictObject(targetFields)),
'browser.handoff.request': v.strictObject({
...targetFields,
reason: v.picklist(['qr_code', 'mfa', 'captcha', 'device_confirmation', 'other']),
message: v.optional(v.pipe(v.string(), v.trim(), v.maxLength(500)), ''),
}),
'browser.handoff.status': v.optional(v.strictObject({})),
'browser.network.start': v.optional(v.strictObject({
...targetFields,
captureHeaders: v.optional(v.boolean()),
captureBody: v.optional(v.boolean()),
maxEntries: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(10), v.maxValue(200))),
maxBodyBytes: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1_024), v.maxValue(65_536))),
})),
'browser.network.status': v.optional(v.strictObject(targetFields)),
'browser.network.list': v.optional(v.strictObject({
...targetFields,
limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(200))),
})),
'browser.network.clear': v.optional(v.strictObject(targetFields)),
'browser.network.stop': v.optional(v.strictObject(targetFields)),
'browser.network.export': v.strictObject({ ...targetFields, id }),
'browser.network.poc': v.strictObject({ ...targetFields, id }),
'browser.network.analysis': v.strictObject({ ...targetFields, id }),
'browser.observe.start': v.optional(v.strictObject({
...targetFields,
captureValues: v.optional(v.boolean()),
maxEntries: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(10), v.maxValue(200))),
maxValueBytes: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(256), v.maxValue(8_192))),
})),
'browser.observe.status': v.optional(v.strictObject(targetFields)),
'browser.observe.list': v.optional(v.strictObject({
...targetFields,
limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(200))),
})),
'browser.observe.clear': v.optional(v.strictObject(targetFields)),
'browser.observe.stop': v.optional(v.strictObject(targetFields)),
'browser.invoke': v.strictObject({
...targetFields,
path: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(2_048)),
args: v.optional(v.pipe(v.array(v.unknown()), v.maxLength(1_000)), []),
timeoutMs: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(250), v.maxValue(60_000))),
}),
'browser.eval': v.strictObject({
...targetFields,
mode: v.picklist(['expression', 'program']),
code: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(1_000_000)),
timeoutMs: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(250), v.maxValue(60_000))),
}),
'proxy.list': v.optional(v.strictObject({})),
'proxy.switch': v.strictObject({ id }),
} satisfies Record<string, v.GenericSchema>;
function issueMessage(issues: readonly v.BaseIssue<unknown>[]): string {
return issues.map((issue) => {
const path = v.getDotPath(issue);
return `${path ? `${path}: ` : ''}${issue.message}`;
}).join('; ');
}
export function parseCapabilityParams(method: string, input: unknown): Record<string, unknown> {
const schema = capabilityParams[method as keyof typeof capabilityParams];
if (!schema) throw new Error(`不支持的 Bridge 方法: ${method}`);
const result = v.safeParse(schema, input);
if (!result.success) throw new Error(`Bridge 方法 ${method} 的参数无效: ${issueMessage(result.issues)}`);
return (result.output || {}) as Record<string, unknown>;
}
export function parseBridgeEnvelope(raw: unknown): BridgeEnvelope {
let input = raw;
if (typeof raw === 'string') {
if (new TextEncoder().encode(raw).byteLength > BRIDGE_MAX_MESSAGE_BYTES) throw new Error('Bridge 消息超过 16 MiB 限制');
input = JSON.parse(raw) as unknown;
} else {
const encoded = JSON.stringify(raw);
if (new TextEncoder().encode(encoded).byteLength > BRIDGE_MAX_MESSAGE_BYTES) throw new Error('Bridge 消息超过 16 MiB 限制');
}
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Bridge 消息必须是对象');
const message = input as Record<string, unknown>;
if (typeof message.type !== 'string') throw new Error('Bridge 消息缺少 type');
if (message.type === 'challenge') {
if (message.protocolVersion !== BRIDGE_PROTOCOL_VERSION) throw new Error(`Bridge 协议版本不兼容: ${String(message.protocolVersion)}`);
for (const key of ['engineIdentityId', 'engineInstanceId', 'challenge', 'signature'] as const) {
if (typeof message[key] !== 'string' || !message[key] || message[key].length > 512) throw new Error(`Bridge ${key} 无效`);
}
if (!Number.isSafeInteger(message.timestamp) || Number(message.timestamp) <= 0) throw new Error('Bridge challenge 时间无效');
parseBridgePublicKey(message.publicKey);
return message as unknown as BridgeEnvelope;
}
if (message.type === 'hello_ack') {
if (!Number.isSafeInteger(message.protocolVersion) || message.protocolVersion !== BRIDGE_PROTOCOL_VERSION) {
throw new Error(`Bridge 协议版本不兼容: ${String(message.protocolVersion)}`);
}
if (message.version !== undefined && typeof message.version !== 'string') throw new Error('Bridge 引擎版本无效');
if (!Array.isArray(message.capabilities) || message.capabilities.some((item) => typeof item !== 'string')) {
throw new Error('Bridge 能力列表无效');
}
for (const key of ['engineIdentityId', 'engineInstanceId', 'connectionId', 'sessionId'] as const) {
if (typeof message[key] !== 'string' || !message[key] || message[key].length > 160) throw new Error(`Bridge ${key} 无效`);
}
if (message.resumed !== undefined && typeof message.resumed !== 'boolean') throw new Error('Bridge resumed 状态无效');
return message as unknown as BridgeEnvelope;
}
if (message.type === 'request') {
if (typeof message.id !== 'string' || !message.id || message.id.length > 160) throw new Error('Bridge 请求 ID 无效');
if (typeof message.method !== 'string' || !message.method || message.method.length > 160) throw new Error('Bridge 请求方法无效');
return message as unknown as BridgeEnvelope;
}
if (message.type === 'ping' || message.type === 'pong' || message.type === 'cancel') {
if (message.id !== undefined && typeof message.id !== 'string') throw new Error('Bridge 心跳 ID 无效');
if (message.type === 'cancel' && !message.id) throw new Error('Bridge cancel 缺少请求 ID');
if ((message.type === 'ping' || message.type === 'pong') && (!Number.isSafeInteger(message.sequence) || typeof message.timestamp !== 'number')) throw new Error('Bridge 心跳序号或时间无效');
return message as unknown as BridgeEnvelope;
}
if (message.type === 'chunk') {
if (typeof message.transferId !== 'string' || !message.transferId || message.transferId.length > 160) throw new Error('Bridge chunk transferId 无效');
if (!Number.isSafeInteger(message.index) || !Number.isSafeInteger(message.total) || Number(message.index) < 0 || Number(message.total) < 1 || Number(message.total) > 128 || Number(message.index) >= Number(message.total)) throw new Error('Bridge chunk 序号无效');
if (typeof message.data !== 'string' || message.data.length > 384 * 1024) throw new Error('Bridge chunk 数据无效');
if (!Number.isSafeInteger(message.originalBytes) || Number(message.originalBytes) < 1 || Number(message.originalBytes) > BRIDGE_MAX_MESSAGE_BYTES) throw new Error('Bridge chunk 原始大小无效');
return message as unknown as BridgeEnvelope;
}
if (message.type === 'response') {
if (message.id !== undefined && (typeof message.id !== 'string' || !message.id || message.id.length > 160)) {
throw new Error('Bridge 响应 ID 无效');
}
if (!message.id && !message.error) throw new Error('Bridge 响应缺少 ID');
if (message.error !== undefined) {
if (!message.error || typeof message.error !== 'object') throw new Error('Bridge 响应错误对象无效');
const responseError = message.error as Record<string, unknown>;
if (typeof responseError.code !== 'string' || typeof responseError.message !== 'string') throw new Error('Bridge 响应错误格式无效');
}
return message as unknown as BridgeEnvelope;
}
throw new Error(`不支持的 Bridge 消息类型: ${message.type}`);
}
function parseBridgePublicKey(input: unknown): BridgePublicKey {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Bridge 公钥无效');
const key = input as Record<string, unknown>;
if (key.kty !== 'EC' || key.crv !== 'P-256' || typeof key.x !== 'string' || typeof key.y !== 'string') {
throw new Error('Bridge 公钥必须使用 ECDSA P-256');
}
if (!key.x || !key.y || key.x.length > 128 || key.y.length > 128) throw new Error('Bridge 公钥坐标无效');
return key as unknown as BridgePublicKey;
}
export function parseBridgePairingEnvelope(raw: unknown): BridgePairingEnvelope {
let input = raw;
if (typeof raw === 'string') {
if (new TextEncoder().encode(raw).byteLength > 32 * 1024) throw new Error('Bridge 配对消息超过 32 KiB 限制');
input = JSON.parse(raw) as unknown;
}
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Bridge 配对消息必须是对象');
const message = input as Record<string, unknown>;
const allowed = ['pair_pending', 'pair_approved', 'pair_rejected', 'pair_expired', 'pair_error'];
if (typeof message.type !== 'string' || !allowed.includes(message.type)) throw new Error('Bridge 配对消息类型无效');
if (message.message !== undefined && (typeof message.message !== 'string' || message.message.length > 1_024)) throw new Error('Bridge 配对消息文本无效');
if (message.type === 'pair_pending') {
if (message.protocolVersion !== BRIDGE_PROTOCOL_VERSION) throw new Error('Bridge 配对协议版本不兼容');
for (const key of ['requestId', 'serverNonce', 'engineIdentityId', 'code'] as const) {
if (typeof message[key] !== 'string' || !message[key] || message[key].length > 512) throw new Error(`Bridge 配对 ${key} 无效`);
}
if (!/^\d{6}$/.test(String(message.code))) throw new Error('Bridge 配对验证码无效');
if (!Number.isSafeInteger(message.expiresAt) || Number(message.expiresAt) <= Date.now()) throw new Error('Bridge 配对申请已经过期');
parseBridgePublicKey(message.publicKey);
}
if (message.type === 'pair_approved') {
for (const key of ['requestId', 'deviceId', 'engineIdentityId'] as const) {
if (typeof message[key] !== 'string' || !message[key] || message[key].length > 512) throw new Error(`Bridge 配对 ${key} 无效`);
}
parseBridgePublicKey(message.publicKey);
}
return message as unknown as BridgePairingEnvelope;
}
+80
View File
@@ -0,0 +1,80 @@
import type { CapabilityScope } from '@/types/models';
export const BRIDGE_CAPABILITIES = [
'system.ping',
'browser.tabs',
'browser.frames',
'browser.context',
'browser.node.inspect',
'browser.node.action',
'browser.cookies',
'browser.takeover',
'browser.handoff.request',
'browser.handoff.status',
'browser.network.start',
'browser.network.status',
'browser.network.list',
'browser.network.clear',
'browser.network.stop',
'browser.network.export',
'browser.network.poc',
'browser.network.analysis',
'browser.observe.start',
'browser.observe.status',
'browser.observe.list',
'browser.observe.clear',
'browser.observe.stop',
...(!(import.meta.env.FIREFOX && import.meta.env.MODE === 'store') ? ['browser.invoke', 'browser.eval'] : []),
'proxy.list',
'proxy.switch',
] as const;
export const READ_CAPABILITY_SCOPES: CapabilityScope[] = [
'browser.tabs.read',
'browser.dom.read',
'browser.storage.read',
'browser.cookies.read',
'browser.network.read',
'browser.observation.read',
];
export const CONTROL_CAPABILITY_SCOPES: CapabilityScope[] = [
...READ_CAPABILITY_SCOPES,
'browser.dom.write',
'browser.tab.activate',
...(!(import.meta.env.FIREFOX && import.meta.env.MODE === 'store')
? ['browser.page.invoke' as const, 'browser.page.eval.expression' as const]
: []),
'browser.human.takeover',
'browser.network.capture',
'browser.network.sensitive.read',
'browser.observation.control',
'browser.observation.sensitive.read',
'browser.proxy.read',
'browser.proxy.write',
];
export const CAPABILITY_LABELS: Record<CapabilityScope, string> = {
'browser.tabs.read': '标签页列表',
'browser.dom.read': '页面 DOM',
'browser.dom.write': '操作页面元素',
'browser.storage.read': '页面 Storage',
'browser.cookies.read': 'Cookie',
'browser.tab.activate': '切到前台',
'browser.page.invoke': '调用页面函数',
'browser.page.eval.expression': '执行页面表达式',
'browser.page.eval.program': '执行页面程序',
'browser.human.takeover': '人工接管',
'browser.network.read': '读取网络摘要',
'browser.network.capture': '控制网络捕获',
'browser.network.sensitive.read': '读取请求头与请求体',
'browser.observation.read': '读取页面行为观测',
'browser.observation.control': '控制页面行为观测',
'browser.observation.sensitive.read': '读取观测值预览',
'browser.proxy.read': '读取代理',
'browser.proxy.write': '切换代理',
};
export function isControlScopeSet(scopes: readonly CapabilityScope[]): boolean {
return scopes.some((scope) => !READ_CAPABILITY_SCOPES.includes(scope));
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { parseExtensionRequest } from './extension';
describe('extension request schemas', () => {
it('rejects unknown fields', () => {
expect(() => parseExtensionRequest({ action: 'panel.update', payload: { enabled: true, unexpected: true } })).toThrow('unexpected');
});
it('accepts split panel policy and explicit Eval mode', () => {
expect(parseExtensionRequest({
action: 'panel.update',
payload: { displayMode: 'active-task', siteMode: 'denylist', siteOrigins: ['https://example.test'] },
}).action).toBe('panel.update');
expect(parseExtensionRequest({
action: 'context.eval', payload: { mode: 'program', code: '1 + 1', timeoutMs: 500 },
}).action).toBe('context.eval');
});
});
+292
View File
@@ -0,0 +1,292 @@
import * as v from 'valibot';
import type { ExtensionAction, ExtensionRequest } from '@/types/messages';
import type { CapabilityScope } from '@/types/models';
const id = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160));
const shortText = v.pipe(v.string(), v.trim(), v.maxLength(240));
const url = v.pipe(v.string(), v.trim(), v.url(), v.maxLength(8_192));
const tabId = v.pipe(v.number(), v.safeInteger(), v.minValue(1));
const frameId = v.pipe(v.number(), v.safeInteger(), v.minValue(0));
const documentId = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160));
const captureId = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160));
const nodeId = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(80));
const targetFields = { tabId: v.optional(tabId), frameId: v.optional(frameId), documentId: v.optional(documentId) };
const port = v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(65_535));
const proxyHost = v.pipe(
v.string(),
v.trim(),
v.minLength(1),
v.maxLength(253),
v.regex(/^[a-zA-Z0-9._:[\]-]+$/, '代理主机只能包含主机名或 IP 地址字符'),
);
const httpUrl = v.pipe(
url,
v.check((value) => ['http:', 'https:'].includes(new URL(value).protocol), '只允许 HTTP(S) URL'),
);
const noPayload = v.optional(v.undefined_());
const stringList = (maxItems = 200, maxLength = 2_048) => v.pipe(
v.array(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(maxLength))),
v.maxLength(maxItems),
);
const proxyProfile = v.pipe(v.strictObject({
id,
name: v.pipe(shortText, v.minLength(1)),
kind: v.picklist(['direct', 'system', 'fixed_servers', 'pac_script']),
host: v.optional(proxyHost),
port: v.optional(port),
scheme: v.optional(v.picklist(['http', 'https', 'socks4', 'socks5'])),
pacUrl: v.optional(httpUrl),
pacScript: v.optional(v.pipe(v.string(), v.maxLength(1_000_000))),
bypass: stringList(500, 2_048),
builtin: v.optional(v.boolean()),
authEnabled: v.optional(v.boolean()),
authUsername: v.optional(v.pipe(v.string(), v.maxLength(1_024))),
}), v.check((profile) => {
if (profile.kind === 'fixed_servers') return Boolean(profile.host && profile.port && profile.scheme);
if (profile.kind === 'pac_script') return Boolean(profile.pacUrl || profile.pacScript?.trim());
return true;
}, '代理配置缺少当前类型所需的主机、端口或 PAC 内容'));
const proxyRule = v.strictObject({
id,
name: v.pipe(shortText, v.minLength(1)),
enabled: v.boolean(),
patterns: stringList(500, 2_048),
proxyProfileId: id,
priority: v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(1_000_000)),
});
const proxyRouting = v.strictObject({
defaultProfileId: id,
failMode: v.picklist(['open', 'closed']),
});
const proxyConfiguration = v.strictObject({
version: v.literal(1),
profiles: v.pipe(v.array(proxyProfile), v.minLength(1), v.maxLength(500)),
rules: v.pipe(v.array(proxyRule), v.maxLength(5_000)),
routing: proxyRouting,
});
const userAgentRule = v.strictObject({
id,
name: v.pipe(shortText, v.minLength(1)),
enabled: v.boolean(),
userAgent: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(4_096)),
domains: stringList(500, 253),
});
const bridgeConfig = v.strictObject({
transport: v.picklist(['native', 'websocket']),
nativeHost: v.pipe(v.string(), v.trim(), v.maxLength(253)),
endpoint: v.pipe(v.string(), v.trim(), v.maxLength(2_048)),
autoConnect: v.boolean(),
installationId: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160)),
pairedEngine: v.optional(v.strictObject({
engineIdentityId: id,
deviceId: id,
publicKey: v.strictObject({
kty: v.literal('EC'),
crv: v.literal('P-256'),
x: v.pipe(v.string(), v.minLength(1), v.maxLength(128)),
y: v.pipe(v.string(), v.minLength(1), v.maxLength(128)),
}),
pairedAt: v.pipe(v.number(), v.safeInteger(), v.minValue(1)),
})),
});
const partitionKey = v.strictObject({
topLevelSite: v.optional(httpUrl),
hasCrossSiteAncestor: v.optional(v.boolean()),
});
const cookieInput = v.strictObject({
url,
name: v.pipe(v.string(), v.maxLength(4_096)),
value: v.pipe(v.string(), v.maxLength(64 * 1_024)),
domain: v.optional(v.pipe(v.string(), v.trim(), v.maxLength(253))),
path: v.optional(v.pipe(v.string(), v.maxLength(4_096))),
secure: v.optional(v.boolean()),
httpOnly: v.optional(v.boolean()),
sameSite: v.optional(v.picklist(['no_restriction', 'lax', 'strict', 'unspecified'])),
expirationDate: v.optional(v.pipe(v.number(), v.finite(), v.minValue(0))),
storeId: v.optional(shortText),
firstPartyDomain: v.optional(v.pipe(v.string(), v.maxLength(253))),
partitionKey: v.optional(partitionKey),
});
const cookieRemoveInput = v.strictObject({
url,
name: v.pipe(v.string(), v.maxLength(4_096)),
storeId: v.optional(shortText),
firstPartyDomain: v.optional(v.pipe(v.string(), v.maxLength(253))),
partitionKey: v.optional(partitionKey),
});
const contextOptions = {
includeStorage: v.optional(v.boolean()),
includeCookies: v.optional(v.boolean()),
includeDom: v.optional(v.boolean()),
tabId: v.optional(tabId),
frameId: v.optional(frameId),
documentId: v.optional(documentId),
};
const capabilityScopes: readonly CapabilityScope[] = [
'browser.tabs.read',
'browser.dom.read',
'browser.dom.write',
'browser.storage.read',
'browser.cookies.read',
'browser.tab.activate',
'browser.page.invoke',
'browser.page.eval.expression',
'browser.page.eval.program',
'browser.human.takeover',
'browser.network.read',
'browser.network.capture',
'browser.network.sensitive.read',
'browser.observation.read',
'browser.observation.control',
'browser.observation.sensitive.read',
'browser.proxy.read',
'browser.proxy.write',
];
const payloadSchemas = {
'state.get': noPayload,
'tab.active': noPayload,
'tab.get': v.strictObject({ tabId }),
'tab.list': noPayload,
'frame.list': v.strictObject({ tabId }),
'proxy.save': proxyProfile,
'proxy.delete': v.strictObject({ id }),
'proxy.switch': v.strictObject({ id }),
'proxy.rule.save': proxyRule,
'proxy.rule.delete': v.strictObject({ id }),
'proxy.rules.apply': noPayload,
'proxy.rules.preview': v.strictObject({ url: httpUrl }),
'proxy.rules.compile': noPayload,
'proxy.rules.reorder': v.strictObject({ ids: v.pipe(v.array(id), v.maxLength(5_000)) }),
'proxy.rules.settings': proxyRouting,
'proxy.rules.stats': noPayload,
'proxy.rules.stats.clear': noPayload,
'proxy.auth.set': v.strictObject({ profileId: id, password: v.pipe(v.string(), v.maxLength(4_096)) }),
'proxy.auth.status': v.strictObject({ profileId: id }),
'proxy.config.export': noPayload,
'proxy.config.import': v.strictObject({ configuration: proxyConfiguration }),
'cookie.list': v.strictObject({ url }),
'cookie.set': cookieInput,
'cookie.remove': cookieRemoveInput,
'cookie.removeMany': v.strictObject({ cookies: v.pipe(v.array(cookieRemoveInput), v.minLength(1), v.maxLength(1_000)) }),
'cookie.import': v.strictObject({ url, format: v.picklist(['json', 'netscape', 'set-cookie']), text: v.pipe(v.string(), v.maxLength(2 * 1024 * 1024)) }),
'cookie.export': v.strictObject({ url, format: v.picklist(['json', 'netscape', 'set-cookie']), includeValues: v.boolean() }),
'ua.save': userAgentRule,
'ua.delete': v.strictObject({ id }),
'ua.apply': noPayload,
'context.capture': v.strictObject(contextOptions),
'context.node.inspect': v.strictObject({ ...targetFields, captureId, nodeId }),
'context.node.action': v.pipe(v.strictObject({
...targetFields,
captureId,
nodeId,
action: v.picklist(['click', 'focus', 'scroll', 'setValue']),
value: v.optional(v.pipe(v.string(), v.maxLength(100_000))),
}), v.check((input) => input.action !== 'setValue' || typeof input.value === 'string', 'setValue 操作必须提供 value')),
'context.invoke': v.strictObject({
path: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(2_048)),
args: v.pipe(v.array(v.unknown()), v.maxLength(1_000)),
tabId: v.optional(tabId),
frameId: v.optional(frameId),
documentId: v.optional(documentId),
timeoutMs: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(250), v.maxValue(60_000))),
}),
'context.eval': v.strictObject({
mode: v.picklist(['expression', 'program']),
code: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(1_000_000)),
tabId: v.optional(tabId),
frameId: v.optional(frameId),
documentId: v.optional(documentId),
timeoutMs: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(250), v.maxValue(60_000))),
}),
'panel.update': v.strictObject({
enabled: v.optional(v.boolean()),
side: v.optional(v.picklist(['left', 'right'])),
y: v.optional(v.pipe(v.number(), v.finite(), v.minValue(0), v.maxValue(1))),
displayMode: v.optional(v.picklist(['always', 'active-task'])),
siteMode: v.optional(v.picklist(['all', 'allowlist', 'denylist'])),
siteOrigins: v.optional(v.pipe(v.array(v.pipe(v.string(), v.trim(), v.url(), v.maxLength(2_048))), v.maxLength(500))),
shortcutEnabled: v.optional(v.boolean()),
autoCollapseFullscreen: v.optional(v.boolean()),
}),
'grant.create': v.strictObject({
targets: v.pipe(v.array(v.strictObject({ tabId, frameId })), v.minLength(1), v.maxLength(256)),
scopes: v.pipe(v.array(v.picklist(capabilityScopes)), v.minLength(1), v.maxLength(capabilityScopes.length)),
durationMinutes: v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(24 * 60)),
taskId: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160))),
}),
'grant.revoke': noPayload,
'handoff.resolve': v.strictObject({ id, outcome: v.picklist(['completed', 'cancelled']) }),
'network.capture.start': v.strictObject({
...targetFields,
captureHeaders: v.optional(v.boolean()),
captureBody: v.optional(v.boolean()),
maxEntries: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(10), v.maxValue(200))),
maxBodyBytes: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1_024), v.maxValue(65_536))),
}),
'network.capture.status': v.strictObject(targetFields),
'network.capture.list': v.strictObject({ ...targetFields, limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(200))) }),
'network.capture.clear': v.strictObject(targetFields),
'network.capture.stop': v.strictObject(targetFields),
'network.capture.export': v.strictObject({ ...targetFields, id }),
'network.capture.send': v.strictObject({ ...targetFields, id }),
'network.capture.poc': v.strictObject({ ...targetFields, id }),
'network.capture.analysis': v.strictObject({ ...targetFields, id }),
'observation.start': v.strictObject({
...targetFields,
captureValues: v.optional(v.boolean()),
maxEntries: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(10), v.maxValue(200))),
maxValueBytes: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(256), v.maxValue(8_192))),
}),
'observation.status': v.strictObject(targetFields),
'observation.list': v.strictObject({ ...targetFields, limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(200))) }),
'observation.clear': v.strictObject(targetFields),
'observation.stop': v.strictObject(targetFields),
'audit.list': v.strictObject({ limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(500))) }),
'audit.clear': noPayload,
'agent.runtime.get': noPayload,
'agent.pause': noPayload,
'agent.resume': noPayload,
'agent.actions.clear': noPayload,
'policy.status': noPayload,
'diagnostics.export': noPayload,
'metrics.get': noPayload,
'metrics.reset': noPayload,
'bridge.config.save': bridgeConfig,
'bridge.pair': noPayload,
'bridge.pair.cancel': noPayload,
'bridge.pair.status': noPayload,
'bridge.unpair': noPayload,
'bridge.connect': noPayload,
'bridge.disconnect': noPayload,
'bridge.status': noPayload,
} satisfies Record<ExtensionAction, v.GenericSchema>;
function issueMessage(issues: readonly v.BaseIssue<unknown>[]): string {
return issues.map((issue) => {
const path = v.getDotPath(issue);
return `${path ? `${path}: ` : ''}${issue.message}`;
}).join('; ');
}
export function parseExtensionRequest(input: unknown): ExtensionRequest {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('扩展消息必须是对象');
const record = input as Record<string, unknown>;
if (Object.keys(record).some((key) => key !== 'action' && key !== 'payload')) throw new Error('扩展消息包含未知字段');
if (typeof record.action !== 'string' || !(record.action in payloadSchemas)) throw new Error('未知扩展操作');
const action = record.action as ExtensionAction;
const result = v.safeParse(payloadSchemas[action], record.payload);
if (!result.success) throw new Error(`操作 ${action} 的参数无效: ${issueMessage(result.issues)}`);
return { action, payload: result.output } as ExtensionRequest;
}
+27
View File
@@ -0,0 +1,27 @@
export const PROXY_SETTINGS_STORAGE_KEY = 'settings.proxy.v1';
export const USER_AGENT_SETTINGS_STORAGE_KEY = 'settings.user-agent.v1';
export const BRIDGE_SETTINGS_STORAGE_KEY = 'settings.bridge.v2';
export const FLOATING_UI_STORAGE_KEY = 'ui.floating-panel.v1';
export const ACTIVE_SESSION_STORAGE_KEY = 'session.browser-agent.v1';
export const BRIDGE_SESSION_STORAGE_KEY = 'session.bridge.v1';
export const AGENT_RUNTIME_STORAGE_KEY = 'session.agent-runtime.v1';
export const STATE_STORAGE_KEYS = [
PROXY_SETTINGS_STORAGE_KEY,
USER_AGENT_SETTINGS_STORAGE_KEY,
BRIDGE_SETTINGS_STORAGE_KEY,
FLOATING_UI_STORAGE_KEY,
ACTIVE_SESSION_STORAGE_KEY,
BRIDGE_SESSION_STORAGE_KEY,
AGENT_RUNTIME_STORAGE_KEY,
] as const;
export function isStateStorageChange(changes: Record<string, unknown>): boolean {
return STATE_STORAGE_KEYS.some((key) => key in changes);
}
export const AUDIT_STORAGE_KEY = 'yakit-audit-log-v1';
export const NETWORK_CAPTURE_STORAGE_KEY = 'yakit-network-capture-v1';
export const CONTEXT_DIGEST_STORAGE_KEY = 'yakit-context-digests-v1';
export const PAGE_LIFECYCLE_STORAGE_KEY = 'yakit-page-lifecycle-v1';
export const PROXY_AUTH_STORAGE_KEY = 'yakit-proxy-auth-v1';
export const PROXY_STATS_STORAGE_KEY = 'yakit-proxy-stats-v1';
export const RUNTIME_METRICS_STORAGE_KEY = 'runtime.metrics.v1';
+17
View File
@@ -0,0 +1,17 @@
export class ExtensionError extends Error {
constructor(
public readonly code: string,
message: string,
) {
super(message);
this.name = 'ExtensionError';
}
}
export function errorCode(error: unknown): string {
return error instanceof ExtensionError ? error.code : 'request_failed';
}
export function isDeniedErrorCode(code: string): boolean {
return ['permission_denied', 'grant_expired', 'target_denied', 'origin_changed', 'stale_document'].includes(code);
}

Some files were not shown because too many files have changed in this diff Show More