feat: advance browser agent integration workflows

This commit is contained in:
go0p
2026-08-06 15:08:07 +08:00
parent c7891a6a9d
commit b11efcc79c
215 changed files with 35137 additions and 5442 deletions
+163
View File
@@ -0,0 +1,163 @@
import type {
ExtensionState, UserAgentProfile, UserAgentProfileInput,
} from '@/types/models';
import { getState, updateState } from '@/platform/storage/state';
import { ExtensionError } from '@/shared/errors';
import {
assertUserAgentState, MAX_CUSTOM_USER_AGENT_PROFILES, type UserAgentStateSlice,
userAgentStateFingerprint,
} from '@/shared/user-agent-state';
import { applyUserAgentAssignments, userAgentHostname, validateUserAgent } from './user-agent';
import { BUILTIN_USER_AGENT_PROFILES, getUserAgentProfiles } from './user-agent-profiles';
let userAgentMutationQueue: Promise<void> = Promise.resolve();
function enqueueUserAgentMutation<T>(operation: () => Promise<T>): Promise<T> {
const next = userAgentMutationQueue.then(operation);
userAgentMutationQueue = next.then(() => undefined, () => undefined);
return next;
}
function stateSlice(state: ExtensionState): UserAgentStateSlice {
return {
customUserAgentProfiles: state.customUserAgentProfiles,
userAgentAssignments: state.userAgentAssignments,
};
}
function errorMessage(error: unknown): string {
return (error instanceof Error ? error.message : String(error)).slice(0, 1_024);
}
async function reconcileDnrWithAuthoritativeState(cause: unknown): Promise<never> {
try {
const authoritative = await getState();
await applyUserAgentAssignments(
authoritative.userAgentAssignments,
authoritative.customUserAgentProfiles,
);
} catch (restoreError) {
throw new ExtensionError(
'ua_consistency_restore_failed',
`User-Agent 设置提交失败,且无法恢复网络规则一致性: ${errorMessage(restoreError)}`,
);
}
if (cause instanceof ExtensionError) throw cause;
throw new ExtensionError('ua_state_commit_failed', `User-Agent 网络规则已恢复,但设置未能提交: ${errorMessage(cause)}`);
}
function mutateUserAgentState(
updater: (current: ExtensionState) => UserAgentStateSlice,
): Promise<ExtensionState> {
return enqueueUserAgentMutation(async () => {
const before = await getState();
const next = updater(before);
assertUserAgentState(next);
if (userAgentStateFingerprint(stateSlice(before)) === userAgentStateFingerprint(next)) return before;
try {
await applyUserAgentAssignments(next.userAgentAssignments, next.customUserAgentProfiles);
} catch (error) {
throw new ExtensionError('ua_rules_apply_failed', `无法应用 User-Agent 网络规则: ${errorMessage(error)}`);
}
try {
return await updateState((current) => {
if (userAgentStateFingerprint(stateSlice(current)) !== userAgentStateFingerprint(stateSlice(before))) {
throw new ExtensionError('ua_state_changed', 'User-Agent 设置已被另一个操作更新,请重试');
}
return {
...current,
customUserAgentProfiles: next.customUserAgentProfiles,
userAgentAssignments: next.userAgentAssignments,
};
});
} catch (error) {
return reconcileDnrWithAuthoritativeState(error);
}
});
}
export function reconcileUserAgentRuntime(): Promise<ExtensionState> {
return enqueueUserAgentMutation(async () => {
const state = await getState();
assertUserAgentState(stateSlice(state));
try {
await applyUserAgentAssignments(state.userAgentAssignments, state.customUserAgentProfiles);
return state;
} catch (error) {
throw new ExtensionError('ua_rules_apply_failed', `无法恢复 User-Agent 网络规则: ${errorMessage(error)}`);
}
});
}
export async function saveUserAgentProfile(input: UserAgentProfileInput): Promise<{
profile: UserAgentProfile;
state: ExtensionState;
}> {
const profile: UserAgentProfile = {
id: input.id || crypto.randomUUID(),
name: input.name.trim(),
userAgent: validateUserAgent(input.userAgent),
category: 'custom',
builtin: false,
};
if (BUILTIN_USER_AGENT_PROFILES.some((item) => item.id === profile.id)) {
throw new Error('不能覆盖内置 User-Agent 预设');
}
const state = await mutateUserAgentState((current) => {
const exists = current.customUserAgentProfiles.some((item) => item.id === profile.id);
if (!exists && current.customUserAgentProfiles.length >= MAX_CUSTOM_USER_AGENT_PROFILES) {
throw new Error(`自定义 User-Agent 预设不能超过 ${MAX_CUSTOM_USER_AGENT_PROFILES} 个`);
}
return {
customUserAgentProfiles: [
...current.customUserAgentProfiles.filter((item) => item.id !== profile.id),
profile,
],
userAgentAssignments: current.userAgentAssignments,
};
});
return { profile, state };
}
export function deleteUserAgentProfile(id: string): Promise<ExtensionState> {
if (BUILTIN_USER_AGENT_PROFILES.some((profile) => profile.id === id)) {
throw new Error('不能删除内置 User-Agent 预设');
}
return mutateUserAgentState((current) => ({
customUserAgentProfiles: current.customUserAgentProfiles.filter((item) => item.id !== id),
userAgentAssignments: current.userAgentAssignments.filter((item) => item.profileId !== id),
}));
}
export function applyUserAgentToSite(url: string, profileId: string): Promise<ExtensionState> {
const hostname = userAgentHostname(url);
const now = Date.now();
return mutateUserAgentState((current) => {
const profile = getUserAgentProfiles(current.customUserAgentProfiles).find((item) => item.id === profileId);
if (!profile) throw new Error('User-Agent 预设不存在');
const existing = current.userAgentAssignments.find((item) => item.hostname === hostname);
return {
customUserAgentProfiles: current.customUserAgentProfiles,
userAgentAssignments: [
...current.userAgentAssignments.filter((item) => item.hostname !== hostname),
{
id: existing?.id || crypto.randomUUID(),
hostname,
profileId: profile.id,
createdAt: existing?.createdAt || now,
updatedAt: now,
},
],
};
});
}
export function resetUserAgentForSite(url: string): Promise<ExtensionState> {
const hostname = userAgentHostname(url);
return mutateUserAgentState((current) => ({
customUserAgentProfiles: current.customUserAgentProfiles,
userAgentAssignments: current.userAgentAssignments.filter((item) => item.hostname !== hostname),
}));
}
@@ -0,0 +1,196 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { UserAgentAssignment } from '@/types/models';
const fixture = vi.hoisted(() => ({
local: {} as Record<string, unknown>,
session: {} as Record<string, unknown>,
rules: [] as Array<{ id: number; [key: string]: unknown }>,
failLocalSet: false,
failDnrUpdate: false,
updateDynamicRules: vi.fn(async (_input: { removeRuleIds?: number[]; addRules?: Array<{ id: number; [key: string]: unknown }> }) => undefined),
}));
function area(data: Record<string, unknown>, local = false) {
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, structuredClone(data[key])]));
},
async set(items: Record<string, unknown>) {
if (local && fixture.failLocalSet) {
fixture.failLocalSet = false;
throw new Error('local storage unavailable');
}
Object.assign(data, structuredClone(items));
},
};
}
vi.mock('wxt/browser', () => ({
browser: {
storage: {
local: area(fixture.local, true),
session: area(fixture.session),
},
declarativeNetRequest: {
async getDynamicRules() {
return structuredClone(fixture.rules);
},
updateDynamicRules: fixture.updateDynamicRules,
},
},
}));
import { DEFAULT_STATE, getState, setState } from '@/platform/storage/state';
import {
applyUserAgentToSite,
deleteUserAgentProfile,
reconcileUserAgentRuntime,
saveUserAgentProfile,
} from './user-agent-service';
import {
BUILTIN_USER_AGENT_PROFILE_IDS,
MAX_USER_AGENT_ASSIGNMENTS,
} from '@/shared/user-agent-state';
import { BUILTIN_USER_AGENT_PROFILES } from './user-agent-profiles';
function assignment(index: number): UserAgentAssignment {
return {
id: `assignment-${index}`,
hostname: `host-${index}.example.test`,
profileId: 'chrome-windows',
createdAt: index + 1,
updatedAt: index + 1,
};
}
describe('atomic User-Agent settings', () => {
beforeEach(async () => {
for (const key of Object.keys(fixture.local)) delete fixture.local[key];
for (const key of Object.keys(fixture.session)) delete fixture.session[key];
fixture.rules.length = 0;
fixture.failLocalSet = false;
fixture.failDnrUpdate = false;
vi.clearAllMocks();
fixture.updateDynamicRules.mockImplementation(async ({ removeRuleIds = [], addRules = [] }) => {
if (fixture.failDnrUpdate) {
fixture.failDnrUpdate = false;
throw new Error('DNR rejected the ruleset');
}
const removed = new Set(removeRuleIds);
fixture.rules = [
...fixture.rules.filter((rule) => !removed.has(rule.id)),
...structuredClone(addRules),
];
});
await setState(structuredClone(DEFAULT_STATE));
});
it('keeps the shared builtin-id contract synchronized with the actual catalog', () => {
expect(BUILTIN_USER_AGENT_PROFILES.map((profile) => profile.id)).toEqual(BUILTIN_USER_AGENT_PROFILE_IDS);
});
it('commits DNR and persistent state together for a site assignment', async () => {
const state = await applyUserAgentToSite('https://app.example.test/login', 'safari-iphone');
expect(state.userAgentAssignments).toHaveLength(1);
expect((await getState()).userAgentAssignments[0]).toMatchObject({
hostname: 'app.example.test',
profileId: 'safari-iphone',
});
expect(fixture.rules).toHaveLength(1);
expect(fixture.rules[0]).toMatchObject({
action: { requestHeaders: [{ header: 'user-agent', operation: 'set' }] },
condition: { urlFilter: '||app.example.test^' },
});
});
it('does not persist a site assignment when DNR rejects the new rules', async () => {
fixture.failDnrUpdate = true;
await expect(applyUserAgentToSite('https://failed.example.test/', 'chrome-windows'))
.rejects.toMatchObject({ code: 'ua_rules_apply_failed' });
expect((await getState()).userAgentAssignments).toEqual([]);
expect(fixture.rules).toEqual([]);
});
it('reconciles DNR back to authoritative storage when the state write fails', async () => {
fixture.failLocalSet = true;
await expect(applyUserAgentToSite('https://rollback.example.test/', 'chrome-windows'))
.rejects.toMatchObject({ code: 'ua_state_commit_failed' });
expect((await getState()).userAgentAssignments).toEqual([]);
expect(fixture.rules).toEqual([]);
expect(fixture.updateDynamicRules).toHaveBeenCalledTimes(2);
});
it('reports an explicit consistency error when authoritative DNR recovery also fails', async () => {
fixture.failLocalSet = true;
let updateCount = 0;
fixture.updateDynamicRules.mockImplementation(async ({ removeRuleIds = [], addRules = [] }) => {
updateCount += 1;
if (updateCount === 2) throw new Error('DNR recovery unavailable');
const removed = new Set(removeRuleIds);
fixture.rules = [
...fixture.rules.filter((rule) => !removed.has(rule.id)),
...structuredClone(addRules),
];
});
await expect(applyUserAgentToSite('https://degraded.example.test/', 'chrome-windows'))
.rejects.toMatchObject({ code: 'ua_consistency_restore_failed' });
expect((await getState()).userAgentAssignments).toEqual([]);
expect(fixture.rules).toHaveLength(1);
});
it('serializes concurrent site assignments without losing either rule', async () => {
await Promise.all([
applyUserAgentToSite('https://one.example.test/', 'chrome-windows'),
applyUserAgentToSite('https://two.example.test/', 'safari-macos'),
]);
const state = await getState();
expect(state.userAgentAssignments.map((item) => item.hostname)).toEqual([
'one.example.test',
'two.example.test',
]);
expect(fixture.rules).toHaveLength(2);
});
it('deletes a custom profile and all of its assignments in one transaction', async () => {
const { profile } = await saveUserAgentProfile({ name: 'Fixture', userAgent: 'Fixture-UA/1.0' });
await applyUserAgentToSite('https://custom.example.test/', profile.id);
const state = await deleteUserAgentProfile(profile.id);
expect(state.customUserAgentProfiles).toEqual([]);
expect(state.userAgentAssignments).toEqual([]);
expect(fixture.rules).toEqual([]);
});
it('reconciles stale owned rules on Service Worker startup without touching other DNR owners', async () => {
fixture.rules = [
{ id: 20_000, priority: 1, stale: true },
{ id: 100, priority: 1, unrelated: true },
];
await reconcileUserAgentRuntime();
expect(fixture.rules).toEqual([{ id: 100, priority: 1, unrelated: true }]);
});
it('rejects the 5001st assignment before touching DNR or persistent state', async () => {
const assignments = Array.from({ length: MAX_USER_AGENT_ASSIGNMENTS }, (_, index) => assignment(index));
await setState({ ...structuredClone(DEFAULT_STATE), userAgentAssignments: assignments });
fixture.updateDynamicRules.mockClear();
await expect(applyUserAgentToSite('https://overflow.example.test/', 'chrome-windows'))
.rejects.toThrow(`不能超过 ${MAX_USER_AGENT_ASSIGNMENTS} 条`);
expect(fixture.updateDynamicRules).not.toHaveBeenCalled();
expect((await getState()).userAgentAssignments).toHaveLength(MAX_USER_AGENT_ASSIGNMENTS);
});
});
+8 -9
View File
@@ -3,9 +3,12 @@ import type {
UserAgentAssignment, UserAgentProfile, UserAgentResolution,
} from '@/types/models';
import { getUserAgentProfiles } from './user-agent-profiles';
import {
MAX_USER_AGENT_ASSIGNMENTS, normalizeUserAgentHostname, normalizeUserAgentValue,
} from '@/shared/user-agent-state';
const RULE_ID_BASE = 20_000;
const MAX_UA_ASSIGNMENTS = 5_000;
const RULE_ID_LIMIT = RULE_ID_BASE + 10_000;
function domainFilter(hostname: string): string {
return `||${hostname}^`;
@@ -14,15 +17,11 @@ function domainFilter(hostname: string): string {
export function userAgentHostname(url: string): string {
const parsed = new URL(url);
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('User-Agent 只能应用到 HTTP(S) 页面');
return parsed.hostname.toLowerCase();
return normalizeUserAgentHostname(parsed.hostname);
}
export function validateUserAgent(value: string): string {
const normalized = value.trim();
if (!normalized) throw new Error('User-Agent 不能为空');
if (normalized.length > 1_024) throw new Error('User-Agent 不能超过 1024 个字符');
if (/\r|\n/.test(normalized)) throw new Error('User-Agent 不能包含换行符');
return normalized;
return normalizeUserAgentValue(value);
}
export function resolveUserAgent(
@@ -49,7 +48,7 @@ export function buildUserAgentDnrRules(
const active = [...uniqueAssignments.values()]
.filter((assignment) => profiles.has(assignment.profileId))
.sort((left, right) => left.hostname.localeCompare(right.hostname));
if (active.length > MAX_UA_ASSIGNMENTS) throw new Error(`User-Agent 站点绑定超过 ${MAX_UA_ASSIGNMENTS} 条限制`);
if (active.length > MAX_USER_AGENT_ASSIGNMENTS) throw new Error(`User-Agent 站点绑定超过 ${MAX_USER_AGENT_ASSIGNMENTS} 条限制`);
return active.map((assignment, index) => {
const profile = profiles.get(assignment.profileId)!;
return {
@@ -76,7 +75,7 @@ export async function applyUserAgentAssignments(
): Promise<void> {
const oldRuleIds = (await browser.declarativeNetRequest.getDynamicRules())
.map((rule) => rule.id)
.filter((id) => id >= RULE_ID_BASE && id < RULE_ID_BASE + 10_000);
.filter((id) => id >= RULE_ID_BASE && id < RULE_ID_LIMIT);
await browser.declarativeNetRequest.updateDynamicRules({
removeRuleIds: oldRuleIds,
addRules: buildUserAgentDnrRules(assignments, customProfiles),