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
+66
View File
@@ -0,0 +1,66 @@
import { browser, type Browser } from 'wxt/browser';
import type { ActiveTabInfo, BrowserTarget } from '@/types/models';
import { ExtensionError } from '@/shared/errors';
type DocumentProbeResult = Browser.scripting.InjectionResult & { documentId?: string };
function probeDocument() {
return { url: location.href };
}
export function scriptingTarget(target: BrowserTarget): Browser.scripting.InjectionTarget {
if (target.documentId && !import.meta.env.FIREFOX) {
return { tabId: target.tabId, documentIds: [target.documentId] } as unknown as Browser.scripting.InjectionTarget;
}
return { tabId: target.tabId, frameIds: [target.frameId] };
}
export async function resolveDocumentTarget(input: BrowserTarget | number): Promise<BrowserTarget> {
const requested: BrowserTarget = typeof input === 'number'
? { tabId: input, frameId: 0 }
: { ...input, frameId: input.frameId ?? 0 };
let probe: DocumentProbeResult | undefined;
try {
[probe] = await browser.scripting.executeScript({
target: { tabId: requested.tabId, frameIds: [requested.frameId] },
world: 'MAIN',
func: probeDocument,
}) as DocumentProbeResult[];
} catch (error) {
throw new ExtensionError('target_unavailable', error instanceof Error ? error.message : String(error));
}
if (!probe) throw new ExtensionError('target_unavailable', '无法定位目标页面文档');
if (requested.documentId && probe.documentId && requested.documentId !== probe.documentId) {
throw new ExtensionError('stale_document', '目标页面已经刷新或导航,请重新授权');
}
return { tabId: requested.tabId, frameId: probe.frameId, documentId: probe.documentId || requested.documentId };
}
async function findRecentHttpTab(): Promise<Browser.tabs.Tab | undefined> {
const active = (await browser.tabs.query({ active: true, currentWindow: true }))[0];
if (active?.url && /^https?:/i.test(active.url)) return active;
const tabs = await browser.tabs.query({ currentWindow: true });
return tabs.filter((tab) => tab.url && /^https?:/i.test(tab.url))
.sort((left, right) => (right.lastAccessed || 0) - (left.lastAccessed || 0))[0];
}
export async function getTab(tabId?: number): Promise<ActiveTabInfo> {
const tab = tabId ? await browser.tabs.get(tabId) : await findRecentHttpTab();
if (!tab?.id || !tab.url) throw new Error('无法读取当前标签页');
return {
id: tab.id,
windowId: tab.windowId,
title: tab.title || '未命名页面',
url: tab.url,
favIconUrl: tab.favIconUrl,
lastAccessed: tab.lastAccessed,
};
}
export const getActiveTab = () => getTab();
export async function activateTab(tabId?: number): Promise<void> {
const tab = tabId ? await browser.tabs.get(tabId) : await browser.tabs.get((await getActiveTab()).id);
await browser.windows.update(tab.windowId, { focused: true });
await browser.tabs.update(tab.id, { active: true });
}
+18
View File
@@ -0,0 +1,18 @@
import { browser } from 'wxt/browser';
import type { ExtensionAction, ExtensionRequest, ExtensionResponse, RequestInput, RequestOutput } from '@/types/messages';
export async function request<A extends ExtensionAction>(
action: A,
...args: undefined extends RequestInput<A> ? [payload?: RequestInput<A>] : [payload: RequestInput<A>]
): Promise<RequestOutput<A>> {
const payload = args[0];
const response = (await browser.runtime.sendMessage({ action, payload } as ExtensionRequest)) as ExtensionResponse<RequestOutput<A>>;
if (!response?.ok) {
throw new Error(response?.error || `Extension request failed: ${action}`);
}
return response.data as RequestOutput<A>;
}
export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+24
View File
@@ -0,0 +1,24 @@
import { vi, describe, expect, it } from 'vitest';
vi.mock('wxt/browser', () => ({ browser: { storage: {} } }));
import type { BridgeConfig } from '@/types/models';
import { applyPolicyToBridge, assertGrantPolicy } from './managed';
const bridge: BridgeConfig = {
transport: 'websocket', endpoint: 'ws://127.0.0.1:64333/extension', nativeHost: 'default.host',
autoConnect: false, installationId: 'install-1',
};
describe('managed policy enforcement', () => {
it('forces Native Messaging without replacing the paired device identity', () => {
expect(applyPolicyToBridge(bridge, { disableWebSocket: true, nativeHost: 'managed.host', autoConnect: true }))
.toEqual({ ...bridge, transport: 'native', nativeHost: 'managed.host', autoConnect: true });
});
it('caps grants and rejects origins/program Eval', () => {
expect(assertGrantPolicy({ maxGrantMinutes: 30 }, { durationMinutes: 120, origins: ['https://a.test'], programEval: false })).toBe(30);
expect(() => assertGrantPolicy({ allowProgramEval: false }, { durationMinutes: 5, origins: [], programEval: true })).toThrow('禁止');
expect(() => assertGrantPolicy({ grantAllowedOrigins: ['https://a.test'] }, { durationMinutes: 5, origins: ['https://b.test'], programEval: false })).toThrow('不允许');
});
});
+90
View File
@@ -0,0 +1,90 @@
import { browser } from 'wxt/browser';
import type { BridgeConfig, EnterprisePolicy, EnterprisePolicyStatus, ExtensionState } from '@/types/models';
import { ExtensionError } from '@/shared/errors';
interface ManagedStorageArea {
get(keys?: null): Promise<Record<string, unknown>>;
}
function stringValue(input: unknown, maxLength: number): string | undefined {
return typeof input === 'string' && input.trim() && input.length <= maxLength ? input.trim() : undefined;
}
export async function getEnterprisePolicy(): Promise<EnterprisePolicyStatus> {
const area = (browser.storage as unknown as { managed?: ManagedStorageArea }).managed;
if (!area) return { managed: false, policy: {}, warnings: [] };
let input: Record<string, unknown>;
try {
input = await area.get(null);
} catch {
return { managed: false, policy: {}, warnings: [] };
}
const warnings: string[] = [];
const policy: EnterprisePolicy = {};
if (input.bridgeTransport === 'native' || input.bridgeTransport === 'websocket') policy.bridgeTransport = input.bridgeTransport;
if (input.bridgeEndpoint !== undefined) {
const value = stringValue(input.bridgeEndpoint, 2_048);
if (value) policy.bridgeEndpoint = value; else warnings.push('bridgeEndpoint 无效');
}
if (input.nativeHost !== undefined) {
const value = stringValue(input.nativeHost, 253);
if (value) policy.nativeHost = value; else warnings.push('nativeHost 无效');
}
for (const key of ['autoConnect', 'disableWebSocket', 'floatingPanelEnabled', 'allowProgramEval'] as const) {
if (typeof input[key] === 'boolean') policy[key] = input[key];
}
if (Number.isSafeInteger(input.maxGrantMinutes) && Number(input.maxGrantMinutes) >= 5 && Number(input.maxGrantMinutes) <= 1_440) {
policy.maxGrantMinutes = Number(input.maxGrantMinutes);
} else if (input.maxGrantMinutes !== undefined) warnings.push('maxGrantMinutes 无效');
if (Array.isArray(input.grantAllowedOrigins)) {
const origins: string[] = [];
for (const item of input.grantAllowedOrigins.slice(0, 500)) {
try {
if (typeof item !== 'string') throw new Error('not a string');
const origin = new URL(item).origin;
if (origin === 'null' || !/^https?:/.test(origin)) throw new Error('not HTTP(S)');
origins.push(origin);
} catch {
warnings.push('grantAllowedOrigins 包含无效 origin');
}
}
policy.grantAllowedOrigins = [...new Set(origins)];
}
return { managed: Object.keys(input).length > 0, policy, warnings: [...new Set(warnings)] };
}
export function applyPolicyToBridge(config: BridgeConfig, policy: EnterprisePolicy): BridgeConfig {
const transport = policy.disableWebSocket ? 'native' : policy.bridgeTransport || config.transport;
return {
...config,
transport,
endpoint: policy.bridgeEndpoint || config.endpoint,
nativeHost: policy.nativeHost || config.nativeHost,
autoConnect: policy.autoConnect ?? config.autoConnect,
};
}
export function applyPolicyToState(state: ExtensionState, policy: EnterprisePolicy): ExtensionState {
return {
...state,
bridge: applyPolicyToBridge(state.bridge, policy),
floatingPanel: {
...state.floatingPanel,
enabled: policy.floatingPanelEnabled ?? state.floatingPanel.enabled,
},
};
}
export function assertGrantPolicy(
policy: EnterprisePolicy,
input: { durationMinutes: number; origins: string[]; programEval: boolean },
): number {
if (input.programEval && policy.allowProgramEval === false) {
throw new ExtensionError('policy_denied', '企业策略禁止 browser.page.eval.program');
}
if (policy.grantAllowedOrigins?.length) {
const denied = input.origins.find((origin) => !policy.grantAllowedOrigins!.includes(origin));
if (denied) throw new ExtensionError('policy_denied', `企业策略不允许授权 origin: ${denied}`);
}
return Math.min(input.durationMinutes, policy.maxGrantMinutes || input.durationMinutes);
}
+56
View File
@@ -0,0 +1,56 @@
import { browser } from 'wxt/browser';
export type ThemePreference = 'system' | 'light' | 'dark';
export const APPEARANCE_STORAGE_KEY = 'settings.appearance.v1';
interface AppearanceSettings {
theme: ThemePreference;
}
const DEFAULT_APPEARANCE: AppearanceSettings = { theme: 'system' };
export async function getAppearance(): Promise<AppearanceSettings> {
const stored = await browser.storage.local.get(APPEARANCE_STORAGE_KEY);
const value = stored[APPEARANCE_STORAGE_KEY] as AppearanceSettings | undefined;
return value && ['system', 'light', 'dark'].includes(value.theme) ? value : DEFAULT_APPEARANCE;
}
export async function setThemePreference(theme: ThemePreference): Promise<void> {
await browser.storage.local.set({ [APPEARANCE_STORAGE_KEY]: { theme } satisfies AppearanceSettings });
}
export function resolveTheme(theme: ThemePreference): 'light' | 'dark' {
if (theme !== 'system') return theme;
return globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
/**
* Applies the stored theme to <html data-theme> and keeps it in sync with
* both the storage key and the OS color scheme. Returns a cleanup function.
*/
export function watchTheme(root: HTMLElement = document.documentElement): () => void {
const media = globalThis.matchMedia?.('(prefers-color-scheme: dark)');
let current: ThemePreference = 'system';
const apply = () => {
root.dataset.theme = resolveTheme(current);
};
void getAppearance().then((appearance) => {
current = appearance.theme;
apply();
});
const onStorageChange = (changes: Record<string, unknown>, area: string) => {
if (area !== 'local' || !(APPEARANCE_STORAGE_KEY in changes)) return;
const next = (changes[APPEARANCE_STORAGE_KEY] as { newValue?: AppearanceSettings })?.newValue;
current = next && ['system', 'light', 'dark'].includes(next.theme) ? next.theme : 'system';
apply();
};
const onMediaChange = () => apply();
browser.storage.onChanged.addListener(onStorageChange);
media?.addEventListener('change', onMediaChange);
apply();
return () => {
browser.storage.onChanged.removeListener(onStorageChange);
media?.removeEventListener('change', onMediaChange);
};
}
+59
View File
@@ -0,0 +1,59 @@
import { vi, describe, expect, it } from 'vitest';
const stores = vi.hoisted(() => ({
local: {} as Record<string, unknown>,
session: {} as Record<string, unknown>,
}));
function area(data: Record<string, unknown>) {
return {
async get(keys: string | string[]) {
const list = Array.isArray(keys) ? keys : [keys];
return Object.fromEntries(list.filter((key) => key in data).map((key) => [key, data[key]]));
},
async set(items: Record<string, unknown>) { Object.assign(data, structuredClone(items)); },
};
}
vi.mock('wxt/browser', () => ({
browser: {
storage: { local: area(stores.local), session: area(stores.session) },
},
}));
import {
ACTIVE_SESSION_STORAGE_KEY, BRIDGE_SETTINGS_STORAGE_KEY, FLOATING_UI_STORAGE_KEY,
PROXY_SETTINGS_STORAGE_KEY, USER_AGENT_SETTINGS_STORAGE_KEY,
} from '@/protocol/storage';
import { DEFAULT_STATE, getState, setState, updateState } from './state';
describe('split state storage', () => {
it('writes durable domains to local and grant/handoff to session', async () => {
const now = Date.now();
await setState({
...structuredClone(DEFAULT_STATE),
activeGrant: {
id: 'grant-1', taskId: 'task-1', createdAt: now, expiresAt: now + 60_000,
scopes: ['browser.tabs.read'],
targets: [{ tabId: 1, frameId: 0, origin: 'https://example.test', grantedUrl: 'https://example.test/', title: 'Example' }],
},
});
expect(Object.keys(stores.local)).toEqual(expect.arrayContaining([
PROXY_SETTINGS_STORAGE_KEY, USER_AGENT_SETTINGS_STORAGE_KEY, BRIDGE_SETTINGS_STORAGE_KEY, FLOATING_UI_STORAGE_KEY,
]));
expect(stores.local).not.toHaveProperty('yakit-extension-state-v5');
expect(stores.session).toHaveProperty(ACTIVE_SESSION_STORAGE_KEY);
expect((await getState()).activeGrant?.taskId).toBe('task-1');
});
it('serializes concurrent cross-domain updates without losing either write', async () => {
await setState(structuredClone(DEFAULT_STATE));
await Promise.all([
updateState((state) => ({ ...state, activeProxyId: 'yakit-mitm' })),
updateState((state) => ({ ...state, floatingPanel: { ...state.floatingPanel, side: 'left' } })),
]);
const state = await getState();
expect(state.activeProxyId).toBe('yakit-mitm');
expect(state.floatingPanel.side).toBe('left');
});
});
+140
View File
@@ -0,0 +1,140 @@
import { browser } from 'wxt/browser';
import type { BridgeRuntimeSession, ExtensionState } from '@/types/models';
import {
ACTIVE_SESSION_STORAGE_KEY, BRIDGE_SETTINGS_STORAGE_KEY, FLOATING_UI_STORAGE_KEY,
PROXY_SETTINGS_STORAGE_KEY, USER_AGENT_SETTINGS_STORAGE_KEY, BRIDGE_SESSION_STORAGE_KEY,
} from '@/protocol/storage';
interface StorageArea {
get(keys: string | string[]): Promise<Record<string, unknown>>;
set(items: Record<string, unknown>): Promise<void>;
}
let mutationQueue: Promise<void> = Promise.resolve();
const sessionStorage = (browser.storage as unknown as { session?: StorageArea }).session;
export const DEFAULT_STATE: ExtensionState = {
version: 7,
proxyProfiles: [
{ id: 'direct', name: '直接连接', kind: 'direct', bypass: [], builtin: true },
{ id: 'system', name: '系统代理', kind: 'system', bypass: [], builtin: true },
{
id: 'yakit-mitm', name: 'Yakit MITM', kind: 'fixed_servers', scheme: 'http', host: '127.0.0.1', port: 8083,
bypass: ['localhost', '127.0.0.1', '<local>'], builtin: true,
},
],
proxyRules: [],
proxyRouting: { defaultProfileId: 'direct', failMode: 'closed' },
activeProxyId: 'direct',
userAgentRules: [],
bridge: {
transport: 'websocket', nativeHost: 'com.yaklang.browser_agent', endpoint: 'ws://127.0.0.1:64333/extension',
autoConnect: false, installationId: crypto.randomUUID(),
},
floatingPanel: {
enabled: true, side: 'right', y: 0.46, displayMode: 'always', siteMode: 'all', siteOrigins: [],
shortcutEnabled: true, autoCollapseFullscreen: true,
},
};
function defaultProfiles() {
return DEFAULT_STATE.proxyProfiles.map((profile) => ({ ...profile, bypass: [...profile.bypass] }));
}
function normalizeState(value: Partial<ExtensionState>): ExtensionState {
const profileMap = new Map(defaultProfiles().map((profile) => [profile.id, profile]));
for (const profile of value.proxyProfiles || []) profileMap.set(profile.id, { ...profile, bypass: profile.bypass || [] });
const proxyProfiles = [...profileMap.values()];
const routableIds = new Set(proxyProfiles.filter((profile) => ['direct', 'fixed_servers'].includes(profile.kind)).map((profile) => profile.id));
const proxyRouting = { ...DEFAULT_STATE.proxyRouting, ...value.proxyRouting };
if (!routableIds.has(proxyRouting.defaultProfileId)) proxyRouting.defaultProfileId = 'direct';
return {
...DEFAULT_STATE,
...value,
version: 7,
proxyProfiles,
proxyRules: (value.proxyRules || []).filter((rule) => routableIds.has(rule.proxyProfileId)).map((rule, index) => ({ ...rule, priority: rule.priority || 1_000 - index })),
proxyRouting,
userAgentRules: value.userAgentRules || [],
bridge: { ...DEFAULT_STATE.bridge, ...value.bridge },
floatingPanel: {
...DEFAULT_STATE.floatingPanel, ...value.floatingPanel,
siteOrigins: [...new Set(value.floatingPanel?.siteOrigins || [])].slice(0, 500),
},
activeGrant: value.activeGrant?.expiresAt && value.activeGrant.expiresAt > Date.now() ? value.activeGrant : undefined,
};
}
export async function getState(): Promise<ExtensionState> {
const localKeys = [PROXY_SETTINGS_STORAGE_KEY, USER_AGENT_SETTINGS_STORAGE_KEY, BRIDGE_SETTINGS_STORAGE_KEY, FLOATING_UI_STORAGE_KEY];
const sessionPromise: Promise<Record<string, unknown>> = sessionStorage?.get(ACTIVE_SESSION_STORAGE_KEY) || Promise.resolve({});
const [local, session] = await Promise.all([
browser.storage.local.get(localKeys),
sessionPromise,
]);
const state = normalizeState({
...(local[PROXY_SETTINGS_STORAGE_KEY] as Partial<ExtensionState> | undefined),
...(local[USER_AGENT_SETTINGS_STORAGE_KEY] as Partial<ExtensionState> | undefined),
...(local[BRIDGE_SETTINGS_STORAGE_KEY] as Partial<ExtensionState> | undefined),
...(local[FLOATING_UI_STORAGE_KEY] as Partial<ExtensionState> | undefined),
...(session[ACTIVE_SESSION_STORAGE_KEY] as Partial<ExtensionState> | undefined),
});
const storedBridge = local[BRIDGE_SETTINGS_STORAGE_KEY] as { bridge?: Partial<ExtensionState['bridge']> } | undefined;
if (!storedBridge?.bridge?.installationId) {
await browser.storage.local.set({
[BRIDGE_SETTINGS_STORAGE_KEY]: { ...storedBridge, bridge: state.bridge },
});
}
return state;
}
export async function getBridgeRuntimeSession(): Promise<BridgeRuntimeSession | undefined> {
if (!sessionStorage) return undefined;
const stored = (await sessionStorage.get(BRIDGE_SESSION_STORAGE_KEY))[BRIDGE_SESSION_STORAGE_KEY];
if (!stored || typeof stored !== 'object') return undefined;
const value = stored as Partial<BridgeRuntimeSession>;
if (!value.sessionId || !value.engineInstanceId || typeof value.updatedAt !== 'number') return undefined;
return value as BridgeRuntimeSession;
}
export async function setBridgeRuntimeSession(value: BridgeRuntimeSession): Promise<void> {
await sessionStorage?.set({ [BRIDGE_SESSION_STORAGE_KEY]: value });
}
export async function setState(input: ExtensionState): Promise<ExtensionState> {
const state = normalizeState(input);
await Promise.all([
browser.storage.local.set({
[PROXY_SETTINGS_STORAGE_KEY]: {
proxyProfiles: state.proxyProfiles, proxyRules: state.proxyRules,
proxyRouting: state.proxyRouting, activeProxyId: state.activeProxyId,
},
[USER_AGENT_SETTINGS_STORAGE_KEY]: { userAgentRules: state.userAgentRules },
[BRIDGE_SETTINGS_STORAGE_KEY]: { bridge: state.bridge },
[FLOATING_UI_STORAGE_KEY]: { floatingPanel: state.floatingPanel },
}),
sessionStorage?.set({
[ACTIVE_SESSION_STORAGE_KEY]: { activeGrant: state.activeGrant, handoff: state.handoff },
}) || Promise.resolve(),
]);
return state;
}
export async function updateState(
updater: (current: ExtensionState) => ExtensionState | Promise<ExtensionState>,
): Promise<ExtensionState> {
let resolveResult!: (state: ExtensionState) => void;
let rejectResult!: (error: unknown) => void;
const result = new Promise<ExtensionState>((resolve, reject) => {
resolveResult = resolve;
rejectResult = reject;
});
mutationQueue = mutationQueue.then(async () => {
try {
resolveResult(await setState(await updater(await getState())));
} catch (error) {
rejectResult(error);
}
});
return result;
}