mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-26 13:11:53 +08:00
feat: advance browser agent integration workflows
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
import { browser, type Browser } from 'wxt/browser';
|
||||
import type {
|
||||
ActiveTabInfo,
|
||||
BrowserIsolationContext,
|
||||
BrowserIsolationInspection,
|
||||
} from '@/types/models';
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
|
||||
export interface IsolationCookieStore {
|
||||
id: string;
|
||||
tabIds: number[];
|
||||
}
|
||||
|
||||
export interface IsolationTabDescriptor {
|
||||
id: number;
|
||||
windowId: number;
|
||||
title: string;
|
||||
url: string;
|
||||
incognito: boolean;
|
||||
cookieStoreId?: string;
|
||||
favIconUrl?: string;
|
||||
lastAccessed?: number;
|
||||
}
|
||||
|
||||
export interface BrowserIsolationContainerDescriptor {
|
||||
cookieStoreId: string;
|
||||
name: string;
|
||||
color: string;
|
||||
icon?: string;
|
||||
managed: boolean;
|
||||
}
|
||||
|
||||
export function uniqueTabIds(tabIds: readonly number[]): number[] {
|
||||
return [...new Set(
|
||||
tabIds.filter((tabId) => Number.isSafeInteger(tabId) && tabId > 0),
|
||||
)].sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
export async function listIsolationCookieStores(): Promise<IsolationCookieStore[]> {
|
||||
try {
|
||||
const stores = await browser.cookies.getAllCookieStores();
|
||||
return stores.map((store) => ({
|
||||
id: store.id,
|
||||
tabIds: uniqueTabIds(store.tabIds),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function cookieStoreIdForDescriptor(
|
||||
tab: { id: number; cookieStoreId?: string },
|
||||
cookieStores: readonly IsolationCookieStore[],
|
||||
): string | undefined {
|
||||
if (tab.cookieStoreId) return tab.cookieStoreId;
|
||||
return cookieStores.find((store) => store.tabIds.includes(tab.id))?.id;
|
||||
}
|
||||
|
||||
export async function resolveTabCookieStoreId(tabId: number): Promise<string> {
|
||||
if (!Number.isSafeInteger(tabId) || tabId < 1) {
|
||||
throw new ExtensionError('isolation_unresolved', '目标标签页 ID 无效,不能解析 Cookie Store');
|
||||
}
|
||||
const tab = await browser.tabs.get(tabId);
|
||||
const firefoxTab = tab as Browser.tabs.Tab & { cookieStoreId?: string };
|
||||
const storeId = cookieStoreIdForDescriptor(
|
||||
{ id: tabId, cookieStoreId: firefoxTab.cookieStoreId },
|
||||
await listIsolationCookieStores(),
|
||||
);
|
||||
if (!storeId) {
|
||||
throw new ExtensionError(
|
||||
'isolation_unresolved',
|
||||
'浏览器没有返回目标标签页的 Cookie Store,已拒绝读取或修改认证材料',
|
||||
);
|
||||
}
|
||||
return storeId;
|
||||
}
|
||||
|
||||
function contextId(
|
||||
kind: BrowserIsolationContext['kind'],
|
||||
cookieStoreId: string | undefined,
|
||||
tabId: number,
|
||||
): string {
|
||||
const stablePart = cookieStoreId || `tab-${tabId}`;
|
||||
return `${kind}:${encodeURIComponent(stablePart)}`.slice(0, 320);
|
||||
}
|
||||
|
||||
function firefoxContainer(cookieStoreId: string | undefined): boolean {
|
||||
return Boolean(cookieStoreId && cookieStoreId.startsWith('firefox-container-'));
|
||||
}
|
||||
|
||||
function isolatedSiteDataGuarantees(): BrowserIsolationContext['guarantees'] {
|
||||
return {
|
||||
cookies: 'isolated',
|
||||
localStorage: 'isolated',
|
||||
indexedDB: 'isolated',
|
||||
serviceWorker: 'isolated',
|
||||
httpAuth: 'unknown',
|
||||
clientCertificate: 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
function unresolvedGuarantees(): BrowserIsolationContext['guarantees'] {
|
||||
return {
|
||||
cookies: 'unknown',
|
||||
localStorage: 'unknown',
|
||||
indexedDB: 'unknown',
|
||||
serviceWorker: 'unknown',
|
||||
httpAuth: 'unknown',
|
||||
clientCertificate: 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
export function cookieStoreIdForTab(
|
||||
tab: Pick<IsolationTabDescriptor, 'id' | 'cookieStoreId'>,
|
||||
cookieStores: readonly IsolationCookieStore[],
|
||||
): string | undefined {
|
||||
return cookieStoreIdForDescriptor(tab, cookieStores);
|
||||
}
|
||||
|
||||
export function isolationContextForTab(
|
||||
tab: IsolationTabDescriptor,
|
||||
cookieStores: readonly IsolationCookieStore[],
|
||||
browserKind: BrowserIsolationInspection['browser'],
|
||||
containerDescriptors: readonly BrowserIsolationContainerDescriptor[] = [],
|
||||
): BrowserIsolationContext {
|
||||
const storeId = cookieStoreIdForTab(tab, cookieStores);
|
||||
const store = storeId ? cookieStores.find((candidate) => candidate.id === storeId) : undefined;
|
||||
const isContainer = browserKind === 'firefox' && firefoxContainer(storeId);
|
||||
const container = isContainer
|
||||
? containerDescriptors.find((candidate) => candidate.cookieStoreId === storeId)
|
||||
: undefined;
|
||||
const kind: BrowserIsolationContext['kind'] = isContainer
|
||||
? 'firefox-container'
|
||||
: browserKind === 'chromium' && tab.incognito
|
||||
? 'chrome-incognito-store'
|
||||
: 'browser-profile';
|
||||
if (!storeId) {
|
||||
return {
|
||||
contextId: contextId(kind, undefined, tab.id),
|
||||
kind,
|
||||
incognito: tab.incognito,
|
||||
level: 'none',
|
||||
guarantees: unresolvedGuarantees(),
|
||||
tabIds: [tab.id],
|
||||
reasons: ['浏览器没有返回目标 Tab 对应的 Cookie Store,不能证明认证上下文隔离'],
|
||||
};
|
||||
}
|
||||
const reasons = isContainer
|
||||
? ['Firefox Container 提供独立 Cookie 与站点存储上下文']
|
||||
: tab.incognito
|
||||
? ['无痕 Cookie Store 与普通浏览上下文分离']
|
||||
: ['浏览器 Profile 内的 Cookie Store 已被明确定位'];
|
||||
return {
|
||||
contextId: contextId(kind, storeId, tab.id),
|
||||
kind,
|
||||
cookieStoreId: storeId,
|
||||
incognito: tab.incognito,
|
||||
containerId: isContainer ? storeId : undefined,
|
||||
containerName: container?.name,
|
||||
containerColor: container?.color,
|
||||
managed: container?.managed,
|
||||
level: 'strong',
|
||||
guarantees: isolatedSiteDataGuarantees(),
|
||||
tabIds: uniqueTabIds(store?.tabIds || [tab.id]),
|
||||
reasons,
|
||||
};
|
||||
}
|
||||
|
||||
export function activeTabInfo(
|
||||
tab: IsolationTabDescriptor,
|
||||
context: BrowserIsolationContext,
|
||||
): ActiveTabInfo {
|
||||
return {
|
||||
id: tab.id,
|
||||
windowId: tab.windowId,
|
||||
title: tab.title || '未命名页面',
|
||||
url: tab.url,
|
||||
incognito: tab.incognito,
|
||||
cookieStoreId: context.cookieStoreId,
|
||||
isolationContextId: context.contextId,
|
||||
favIconUrl: tab.favIconUrl,
|
||||
lastAccessed: tab.lastAccessed,
|
||||
};
|
||||
}
|
||||
|
||||
export function browserTabDescriptor(tab: Browser.tabs.Tab): IsolationTabDescriptor | undefined {
|
||||
if (!tab.id || !tab.url || !/^https?:/i.test(tab.url)) return undefined;
|
||||
const firefoxTab = tab as Browser.tabs.Tab & { cookieStoreId?: string };
|
||||
return {
|
||||
id: tab.id,
|
||||
windowId: tab.windowId,
|
||||
title: tab.title || '未命名页面',
|
||||
url: tab.url,
|
||||
incognito: tab.incognito,
|
||||
cookieStoreId: firefoxTab.cookieStoreId,
|
||||
favIconUrl: tab.favIconUrl,
|
||||
lastAccessed: tab.lastAccessed,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveBrowserTabInfo(tab: Browser.tabs.Tab): Promise<ActiveTabInfo> {
|
||||
const descriptor = browserTabDescriptor(tab);
|
||||
if (!descriptor) {
|
||||
throw new ExtensionError('target_unavailable', '目标标签页不是可访问的 HTTP(S) 页面');
|
||||
}
|
||||
const context = isolationContextForTab(
|
||||
descriptor,
|
||||
await listIsolationCookieStores(),
|
||||
import.meta.env.FIREFOX ? 'firefox' : 'chromium',
|
||||
);
|
||||
return activeTabInfo(descriptor, context);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { browser, type Browser } from 'wxt/browser';
|
||||
import type { ActiveTabInfo, BrowserTarget } from '@/types/models';
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
import { resolveBrowserTabInfo } from './isolation';
|
||||
|
||||
type DocumentProbeResult = Browser.scripting.InjectionResult & { documentId?: string };
|
||||
|
||||
@@ -19,6 +20,15 @@ export async function resolveDocumentTarget(input: BrowserTarget | number): Prom
|
||||
const requested: BrowserTarget = typeof input === 'number'
|
||||
? { tabId: input, frameId: 0 }
|
||||
: { ...input, frameId: input.frameId ?? 0 };
|
||||
if (import.meta.env.FIREFOX) {
|
||||
try {
|
||||
const frame = await browser.webNavigation.getFrame({ tabId: requested.tabId, frameId: requested.frameId });
|
||||
if (!frame) throw new Error('目标 frame 不存在');
|
||||
return { tabId: requested.tabId, frameId: requested.frameId };
|
||||
} catch (error) {
|
||||
throw new ExtensionError('target_unavailable', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
let probe: DocumentProbeResult | undefined;
|
||||
try {
|
||||
[probe] = await browser.scripting.executeScript({
|
||||
@@ -47,14 +57,7 @@ async function findRecentHttpTab(): Promise<Browser.tabs.Tab | undefined> {
|
||||
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,
|
||||
};
|
||||
return resolveBrowserTabInfo(tab);
|
||||
}
|
||||
|
||||
export const getActiveTab = () => getTab();
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
import { parseExtensionResponseEnvelope } from './runtime';
|
||||
|
||||
describe('runtime response envelope', () => {
|
||||
it('accepts declared success and structured error fields', () => {
|
||||
expect(parseExtensionResponseEnvelope({ ok: true, data: { value: 1 } }, 'test')).toEqual({
|
||||
ok: true,
|
||||
data: { value: 1 },
|
||||
});
|
||||
expect(parseExtensionResponseEnvelope({
|
||||
ok: false,
|
||||
error: 'failed',
|
||||
errorCode: 'test_failed',
|
||||
errorData: { reason: 'test' },
|
||||
}, 'test')).toMatchObject({ ok: false, errorCode: 'test_failed' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null, '不是对象'],
|
||||
[{ data: 1 }, '$.ok'],
|
||||
[{ ok: true, legacy: true }, '$.legacy'],
|
||||
[{ ok: false, error: 1 }, '$.error'],
|
||||
])('rejects invalid cross-context envelope %#', (value, message) => {
|
||||
expect(() => parseExtensionResponseEnvelope(value, 'test')).toThrow(message);
|
||||
});
|
||||
|
||||
it('returns a stable protocol error code', () => {
|
||||
try {
|
||||
parseExtensionResponseEnvelope(undefined, 'test');
|
||||
throw new Error('expected failure');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ExtensionError);
|
||||
expect((error as ExtensionError).code).toBe('runtime_protocol_mismatch');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,46 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import type { ExtensionAction, ExtensionRequest, ExtensionResponse, RequestInput, RequestOutput } from '@/types/messages';
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
|
||||
export function parseExtensionResponseEnvelope<T>(input: unknown, action: string): ExtensionResponse<T> {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||
throw new ExtensionError('runtime_protocol_mismatch', `扩展操作 ${action} 返回的消息不是对象`);
|
||||
}
|
||||
const value = input as Record<string, unknown>;
|
||||
const unexpected = Object.keys(value).find((key) => !['ok', 'data', 'error', 'errorCode', 'errorData'].includes(key));
|
||||
if (unexpected) {
|
||||
throw new ExtensionError(
|
||||
'runtime_protocol_mismatch',
|
||||
`扩展操作 ${action} 返回了未声明字段 $.${unexpected},请重新加载插件`,
|
||||
);
|
||||
}
|
||||
if (typeof value.ok !== 'boolean') {
|
||||
throw new ExtensionError('runtime_protocol_mismatch', `扩展操作 ${action} 缺少布尔字段 $.ok`);
|
||||
}
|
||||
if (!value.ok && value.error !== undefined && typeof value.error !== 'string') {
|
||||
throw new ExtensionError('runtime_protocol_mismatch', `扩展操作 ${action} 的 $.error 必须是字符串`);
|
||||
}
|
||||
if (value.errorCode !== undefined && typeof value.errorCode !== 'string') {
|
||||
throw new ExtensionError('runtime_protocol_mismatch', `扩展操作 ${action} 的 $.errorCode 必须是字符串`);
|
||||
}
|
||||
return value as unknown as ExtensionResponse<T>;
|
||||
}
|
||||
|
||||
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>>;
|
||||
const response = parseExtensionResponseEnvelope<RequestOutput<A>>(
|
||||
await browser.runtime.sendMessage({ action, payload } as ExtensionRequest),
|
||||
action,
|
||||
);
|
||||
if (!response?.ok) {
|
||||
throw new Error(response?.error || `Extension request failed: ${action}`);
|
||||
throw new ExtensionError(
|
||||
response?.errorCode || 'request_failed',
|
||||
response?.error || `Extension request failed: ${action}`,
|
||||
response?.errorData,
|
||||
);
|
||||
}
|
||||
return response.data as RequestOutput<A>;
|
||||
}
|
||||
|
||||
@@ -23,9 +23,11 @@ vi.mock('wxt/browser', () => ({
|
||||
|
||||
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, PROXY_SETTINGS_STORAGE_KEY, USER_AGENT_SETTINGS_STORAGE_KEY,
|
||||
} from '@/protocol/storage';
|
||||
import { DEFAULT_STATE, getState, setState, updateState } from './state';
|
||||
import {
|
||||
DEFAULT_STATE, getBridgeRuntimeSession, getState, setState, updateState,
|
||||
} from './state';
|
||||
|
||||
describe('split state storage', () => {
|
||||
it('writes durable domains to local and grant/handoff to session', async () => {
|
||||
@@ -35,7 +37,15 @@ describe('split state storage', () => {
|
||||
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' }],
|
||||
targets: [{
|
||||
tabId: 1,
|
||||
frameId: 0,
|
||||
isolationContextId: 'browser-profile:store-1',
|
||||
cookieStoreId: 'store-1',
|
||||
origin: 'https://example.test',
|
||||
grantedUrl: 'https://example.test/',
|
||||
title: 'Example',
|
||||
}],
|
||||
},
|
||||
});
|
||||
expect(Object.keys(stores.local)).toEqual(expect.arrayContaining([
|
||||
@@ -56,4 +66,140 @@ describe('split state storage', () => {
|
||||
expect(state.activeProxyId).toBe('yakit-mitm');
|
||||
expect(state.floatingPanel.side).toBe('left');
|
||||
});
|
||||
|
||||
it('drops a session grant that is not bound to an isolation context', async () => {
|
||||
const now = Date.now();
|
||||
stores.session[ACTIVE_SESSION_STORAGE_KEY] = {
|
||||
activeGrant: {
|
||||
id: 'legacy-grant',
|
||||
taskId: 'legacy-task',
|
||||
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((await getState()).activeGrant).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves an expired but structurally valid grant for lifecycle cleanup', async () => {
|
||||
const now = Date.now();
|
||||
stores.session[ACTIVE_SESSION_STORAGE_KEY] = {
|
||||
activeGrant: {
|
||||
id: 'expired-grant',
|
||||
taskId: 'expired-task',
|
||||
createdAt: now - 120_000,
|
||||
expiresAt: now - 60_000,
|
||||
scopes: ['browser.tabs.read'],
|
||||
targets: [{
|
||||
tabId: 7,
|
||||
frameId: 0,
|
||||
documentId: 'document-7',
|
||||
isolationContextId: 'browser-profile:store-7',
|
||||
cookieStoreId: 'store-7',
|
||||
origin: 'https://expired.example',
|
||||
grantedUrl: 'https://expired.example/',
|
||||
title: 'Expired',
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
expect((await getState()).activeGrant).toMatchObject({
|
||||
id: 'expired-grant',
|
||||
expiresAt: now - 60_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('repairs reserved proxy profiles and malformed proxy collections from durable storage', async () => {
|
||||
stores.local[PROXY_SETTINGS_STORAGE_KEY] = {
|
||||
proxyProfiles: [
|
||||
{ id: 'direct', name: 'Hijacked', kind: 'fixed_servers', scheme: 'http', host: '127.0.0.1', port: 9000, bypass: [] },
|
||||
{ id: 'auto', name: 'Sentinel collision', kind: 'direct', bypass: [] },
|
||||
{ id: 'custom', name: 'Custom', kind: 'fixed_servers', scheme: 'http', host: '127.0.0.1', port: 8080, bypass: [], builtin: true },
|
||||
],
|
||||
proxyRules: null,
|
||||
proxyRuleSources: { invalid: true },
|
||||
proxyRouting: { defaultProfileId: 'missing', failMode: 'invalid' },
|
||||
activeProxyId: 'auto',
|
||||
};
|
||||
|
||||
const state = await getState();
|
||||
expect(state.proxyProfiles.find((profile) => profile.id === 'direct')).toMatchObject({
|
||||
name: '直接连接', kind: 'direct', builtin: true,
|
||||
});
|
||||
expect(state.proxyProfiles.some((profile) => profile.id === 'auto')).toBe(false);
|
||||
expect(state.proxyProfiles.find((profile) => profile.id === 'custom')?.builtin).toBe(false);
|
||||
expect(state.proxyRules).toEqual([]);
|
||||
expect(state.proxyRuleSources).toEqual([]);
|
||||
expect(state.proxyRouting).toEqual({ defaultProfileId: 'direct', failMode: 'closed' });
|
||||
});
|
||||
|
||||
it('repairs malformed User-Agent profiles, duplicate hosts and orphan assignments', async () => {
|
||||
stores.local[USER_AGENT_SETTINGS_STORAGE_KEY] = {
|
||||
customUserAgentProfiles: [
|
||||
{ id: 'custom-valid', name: ' Valid ', userAgent: ' Fixture-UA/1.0 ', category: 'desktop', builtin: true },
|
||||
{ id: 'chrome-windows', name: 'Builtin collision', userAgent: 'Collision/1.0', category: 'custom', builtin: false },
|
||||
{ id: 'custom-invalid', name: 'Invalid', userAgent: 'Bad\r\nHeader: value', category: 'custom', builtin: false },
|
||||
],
|
||||
userAgentAssignments: [
|
||||
{ id: 'old', hostname: 'APP.EXAMPLE.TEST', profileId: 'custom-valid', createdAt: 1, updatedAt: 2 },
|
||||
{ id: 'latest', hostname: 'app.example.test', profileId: 'safari-iphone', createdAt: 1, updatedAt: 3 },
|
||||
{ id: 'orphan', hostname: 'orphan.example.test', profileId: 'missing', createdAt: 1, updatedAt: 2 },
|
||||
],
|
||||
};
|
||||
|
||||
const state = await getState();
|
||||
expect(state.customUserAgentProfiles).toEqual([{
|
||||
id: 'custom-valid',
|
||||
name: 'Valid',
|
||||
userAgent: 'Fixture-UA/1.0',
|
||||
category: 'custom',
|
||||
builtin: false,
|
||||
}]);
|
||||
expect(state.userAgentAssignments).toEqual([{
|
||||
id: 'latest',
|
||||
hostname: 'app.example.test',
|
||||
profileId: 'safari-iphone',
|
||||
createdAt: 1,
|
||||
updatedAt: 3,
|
||||
}]);
|
||||
});
|
||||
|
||||
it('drops a corrupted resumable Bridge session after a Service Worker restart', async () => {
|
||||
stores.session[BRIDGE_SESSION_STORAGE_KEY] = {
|
||||
sessionId: 42,
|
||||
engineInstanceId: 'engine-1',
|
||||
taskId: { invalid: true },
|
||||
updatedAt: Number.NaN,
|
||||
};
|
||||
|
||||
await expect(getBridgeRuntimeSession()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('restores only bounded typed fields from a resumable Bridge session', async () => {
|
||||
stores.session[BRIDGE_SESSION_STORAGE_KEY] = {
|
||||
sessionId: 's'.repeat(700),
|
||||
engineInstanceId: 'engine-1',
|
||||
engineIdentityId: 'identity-1',
|
||||
taskId: 'task-1',
|
||||
grantId: 'grant-1',
|
||||
updatedAt: Date.now(),
|
||||
ignoredLegacyField: true,
|
||||
};
|
||||
|
||||
await expect(getBridgeRuntimeSession()).resolves.toMatchObject({
|
||||
sessionId: 's'.repeat(500),
|
||||
engineInstanceId: 'engine-1',
|
||||
engineIdentityId: 'identity-1',
|
||||
taskId: 'task-1',
|
||||
grantId: 'grant-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+120
-18
@@ -1,9 +1,13 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import type { BridgeRuntimeSession, ExtensionState } from '@/types/models';
|
||||
import type {
|
||||
BridgeGrant, BridgeGrantTarget, BridgeRuntimeSession, CapabilityScope, ExtensionState,
|
||||
ProxyConditionType, ProxyProfile,
|
||||
} 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';
|
||||
import { normalizeStoredUserAgentState } from '@/shared/user-agent-state';
|
||||
|
||||
interface StorageArea {
|
||||
get(keys: string | string[]): Promise<Record<string, unknown>>;
|
||||
@@ -44,16 +48,88 @@ function defaultProfiles() {
|
||||
return DEFAULT_STATE.proxyProfiles.map((profile) => ({ ...profile, bypass: [...profile.bypass] }));
|
||||
}
|
||||
|
||||
const PROXY_KINDS = new Set<ProxyProfile['kind']>(['direct', 'system', 'fixed_servers', 'pac_script']);
|
||||
const PROXY_SCHEMES = new Set(['http', 'https', 'socks4', 'socks5']);
|
||||
const PROXY_CONDITION_TYPES = new Set<ProxyConditionType>([
|
||||
'host_exact', 'host_suffix', 'host_wildcard', 'host_regex',
|
||||
'url_prefix', 'url_wildcard', 'url_regex', 'keyword',
|
||||
]);
|
||||
|
||||
function normalizeGrantTarget(input: unknown): BridgeGrantTarget | undefined {
|
||||
if (!input || typeof input !== 'object') return undefined;
|
||||
const value = input as Partial<BridgeGrantTarget>;
|
||||
if (
|
||||
!Number.isSafeInteger(value.tabId) || value.tabId! < 1
|
||||
|| !Number.isSafeInteger(value.frameId) || value.frameId! < 0
|
||||
|| typeof value.isolationContextId !== 'string' || !value.isolationContextId
|
||||
|| typeof value.origin !== 'string' || !/^https?:\/\//i.test(value.origin)
|
||||
|| typeof value.grantedUrl !== 'string'
|
||||
|| typeof value.title !== 'string'
|
||||
|| (value.documentId !== undefined && typeof value.documentId !== 'string')
|
||||
|| (value.cookieStoreId !== undefined && typeof value.cookieStoreId !== 'string')
|
||||
) return undefined;
|
||||
return value as BridgeGrantTarget;
|
||||
}
|
||||
|
||||
function normalizeActiveGrant(input: unknown): BridgeGrant | undefined {
|
||||
if (!input || typeof input !== 'object') return undefined;
|
||||
const value = input as Partial<BridgeGrant>;
|
||||
if (
|
||||
typeof value.id !== 'string' || !value.id
|
||||
|| typeof value.taskId !== 'string' || !value.taskId
|
||||
|| !Number.isFinite(value.createdAt)
|
||||
|| !Number.isFinite(value.expiresAt)
|
||||
|| value.expiresAt! <= value.createdAt!
|
||||
|| !Array.isArray(value.targets) || value.targets.length === 0 || value.targets.length > 64
|
||||
|| !Array.isArray(value.scopes) || value.scopes.length === 0 || value.scopes.length > 128
|
||||
|| value.scopes.some((scope) => typeof scope !== 'string' || !scope)
|
||||
) return undefined;
|
||||
const targets = value.targets.map(normalizeGrantTarget);
|
||||
if (targets.some((target) => !target)) return undefined;
|
||||
return {
|
||||
id: value.id,
|
||||
taskId: value.taskId,
|
||||
createdAt: value.createdAt!,
|
||||
expiresAt: value.expiresAt!,
|
||||
targets: targets as BridgeGrantTarget[],
|
||||
scopes: [...new Set(value.scopes)] as CapabilityScope[],
|
||||
};
|
||||
}
|
||||
|
||||
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 storedProfiles = Array.isArray(value.proxyProfiles) ? value.proxyProfiles.slice(0, 500) : [];
|
||||
for (const profile of storedProfiles) {
|
||||
if (!profile || typeof profile.id !== 'string' || !profile.id || profile.id === 'auto'
|
||||
|| typeof profile.name !== 'string' || !profile.name.trim() || !PROXY_KINDS.has(profile.kind)) continue;
|
||||
if (profile.id === 'direct' || profile.id === 'system') continue;
|
||||
if (profile.id === 'yakit-mitm' && profile.kind !== 'fixed_servers') continue;
|
||||
if (profile.kind === 'fixed_servers' && (
|
||||
typeof profile.host !== 'string' || !profile.host.trim()
|
||||
|| !Number.isSafeInteger(profile.port) || profile.port! < 1 || profile.port! > 65_535
|
||||
|| !profile.scheme || !PROXY_SCHEMES.has(profile.scheme)
|
||||
)) continue;
|
||||
if (profile.kind === 'pac_script' && !profile.pacUrl?.trim() && !profile.pacScript?.trim()) continue;
|
||||
profileMap.set(profile.id, {
|
||||
...profile,
|
||||
name: profile.name.trim(),
|
||||
bypass: Array.isArray(profile.bypass) ? profile.bypass.filter((item) => typeof item === 'string').slice(0, 500) : [],
|
||||
builtin: profile.id === 'yakit-mitm',
|
||||
});
|
||||
}
|
||||
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 };
|
||||
const proxyRouting = {
|
||||
...DEFAULT_STATE.proxyRouting,
|
||||
...(value.proxyRouting && typeof value.proxyRouting === 'object' ? value.proxyRouting : {}),
|
||||
};
|
||||
if (!routableIds.has(proxyRouting.defaultProfileId)) proxyRouting.defaultProfileId = 'direct';
|
||||
const proxyRules = (value.proxyRules || []).filter((rule) => (
|
||||
if (proxyRouting.failMode !== 'open' && proxyRouting.failMode !== 'closed') proxyRouting.failMode = 'closed';
|
||||
const storedRules = Array.isArray(value.proxyRules) ? value.proxyRules.slice(0, 5_000) : [];
|
||||
const proxyRules = storedRules.filter((rule) => (
|
||||
rule && typeof rule.id === 'string' && typeof rule.name === 'string' && typeof rule.enabled === 'boolean'
|
||||
&& rule.condition && typeof rule.condition.type === 'string' && typeof rule.condition.value === 'string'
|
||||
&& rule.condition && PROXY_CONDITION_TYPES.has(rule.condition.type) && typeof rule.condition.value === 'string'
|
||||
&& rule.condition.value.trim().length > 0
|
||||
&& routableIds.has(rule.proxyProfileId)
|
||||
)).map((rule, order) => ({
|
||||
...rule,
|
||||
@@ -61,18 +137,26 @@ function normalizeState(value: Partial<ExtensionState>): ExtensionState {
|
||||
createdAt: Number.isFinite(rule.createdAt) ? rule.createdAt : Date.now(),
|
||||
updatedAt: Number.isFinite(rule.updatedAt) ? rule.updatedAt : Date.now(),
|
||||
}));
|
||||
const proxyRuleSources = (value.proxyRuleSources || []).filter((source) => (
|
||||
source && typeof source.id === 'string' && typeof source.url === 'string'
|
||||
const storedSources = Array.isArray(value.proxyRuleSources) ? value.proxyRuleSources.slice(0, 200) : [];
|
||||
const proxyRuleSources = storedSources.filter((source) => (
|
||||
source && typeof source.id === 'string' && typeof source.name === 'string' && typeof source.url === 'string'
|
||||
&& typeof source.enabled === 'boolean' && ['auto', 'autoproxy', 'switchyomega', 'hosts'].includes(source.format)
|
||||
&& Number.isSafeInteger(source.updateIntervalMinutes)
|
||||
&& source.updateIntervalMinutes >= 15 && source.updateIntervalMinutes <= 43_200
|
||||
&& routableIds.has(source.matchProfileId) && routableIds.has(source.bypassProfileId)
|
||||
)).map((source, order) => ({
|
||||
...source,
|
||||
order: Number.isSafeInteger(source.order) ? source.order : order,
|
||||
status: source.status || (source.revision ? 'ready' : 'idle'),
|
||||
totalRuleCount: source.totalRuleCount || 0,
|
||||
supportedRuleCount: source.supportedRuleCount || 0,
|
||||
ignoredRuleCount: source.ignoredRuleCount || 0,
|
||||
invalidRuleCount: source.invalidRuleCount || 0,
|
||||
totalRuleCount: Number.isSafeInteger(source.totalRuleCount) && source.totalRuleCount >= 0 ? source.totalRuleCount : 0,
|
||||
supportedRuleCount: Number.isSafeInteger(source.supportedRuleCount) && source.supportedRuleCount >= 0 ? source.supportedRuleCount : 0,
|
||||
ignoredRuleCount: Number.isSafeInteger(source.ignoredRuleCount) && source.ignoredRuleCount >= 0 ? source.ignoredRuleCount : 0,
|
||||
invalidRuleCount: Number.isSafeInteger(source.invalidRuleCount) && source.invalidRuleCount >= 0 ? source.invalidRuleCount : 0,
|
||||
}));
|
||||
const userAgentState = normalizeStoredUserAgentState(
|
||||
value.customUserAgentProfiles,
|
||||
value.userAgentAssignments,
|
||||
);
|
||||
return {
|
||||
...DEFAULT_STATE,
|
||||
...value,
|
||||
@@ -81,18 +165,24 @@ function normalizeState(value: Partial<ExtensionState>): ExtensionState {
|
||||
proxyRules,
|
||||
proxyRuleSources,
|
||||
proxyRouting,
|
||||
proxyRuntime: { ...DEFAULT_STATE.proxyRuntime, ...value.proxyRuntime, warnings: value.proxyRuntime?.warnings || [] },
|
||||
proxyRuntime: {
|
||||
...DEFAULT_STATE.proxyRuntime,
|
||||
...(value.proxyRuntime && typeof value.proxyRuntime === 'object' ? value.proxyRuntime : {}),
|
||||
warnings: Array.isArray(value.proxyRuntime?.warnings) ? value.proxyRuntime.warnings.slice(0, 100) : [],
|
||||
},
|
||||
activeProxyId: value.activeProxyId === 'auto' || proxyProfiles.some((profile) => profile.id === value.activeProxyId)
|
||||
? value.activeProxyId!
|
||||
: 'direct',
|
||||
customUserAgentProfiles: value.customUserAgentProfiles || [],
|
||||
userAgentAssignments: value.userAgentAssignments || [],
|
||||
customUserAgentProfiles: userAgentState.customUserAgentProfiles,
|
||||
userAgentAssignments: userAgentState.userAgentAssignments,
|
||||
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,
|
||||
// Expired grants remain visible to the lifecycle manager so it can release
|
||||
// grant-owned debugger, recorder and network resources after a worker restart.
|
||||
activeGrant: normalizeActiveGrant(value.activeGrant),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,10 +212,22 @@ export async function getState(): Promise<ExtensionState> {
|
||||
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;
|
||||
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return undefined;
|
||||
const value = stored as Partial<BridgeRuntimeSession>;
|
||||
if (!value.sessionId || !value.engineInstanceId || typeof value.updatedAt !== 'number') return undefined;
|
||||
return value as BridgeRuntimeSession;
|
||||
if (typeof value.sessionId !== 'string' || !value.sessionId
|
||||
|| typeof value.engineInstanceId !== 'string' || !value.engineInstanceId
|
||||
|| typeof value.updatedAt !== 'number' || !Number.isFinite(value.updatedAt) || value.updatedAt < 0
|
||||
|| (value.engineIdentityId !== undefined && typeof value.engineIdentityId !== 'string')
|
||||
|| (value.taskId !== undefined && typeof value.taskId !== 'string')
|
||||
|| (value.grantId !== undefined && typeof value.grantId !== 'string')) return undefined;
|
||||
return {
|
||||
sessionId: value.sessionId.slice(0, 500),
|
||||
engineInstanceId: value.engineInstanceId.slice(0, 500),
|
||||
engineIdentityId: value.engineIdentityId?.slice(0, 500),
|
||||
taskId: value.taskId?.slice(0, 240),
|
||||
grantId: value.grantId?.slice(0, 240),
|
||||
updatedAt: value.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function setBridgeRuntimeSession(value: BridgeRuntimeSession): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user