mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-25 20:51:52 +08:00
feat: advance browser agent integration workflows
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user