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
+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;
@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; }
}
.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%;
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -3,11 +3,11 @@
<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>
<div id="app"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
</html>
+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();
},
});