feat: advance browser agent integration workflows

This commit is contained in:
go0p
2026-08-06 15:11:35 +08:00
committed by go0p
parent af5a4db694
commit 0c8e1c7b69
215 changed files with 35137 additions and 5442 deletions
+212
View File
@@ -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);
}
+11 -8
View File
@@ -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();
+37
View File
@@ -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');
}
});
});
+34 -2
View File
@@ -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>;
}
+149 -3
View File
@@ -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
View File
@@ -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> {