mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-26 13:11:53 +08:00
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:
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { BridgeEnvelope } from '@/types/messages';
|
||||
import {
|
||||
clientAuthPayload, engineChallengePayload, pairingVerificationCode, signBridgePayload, verifyBridgePayload,
|
||||
} from './identity';
|
||||
|
||||
describe('Bridge v3 identity transcript', () => {
|
||||
it('keeps the Go-compatible canonical field order', () => {
|
||||
expect(engineChallengePayload({
|
||||
engineIdentityId: 'identity-1', engineInstanceId: 'instance-1', challenge: 'nonce-1', timestamp: 123,
|
||||
})).toBe('yak-browser-bridge-v3\nengine-challenge\nidentity-1\ninstance-1\nnonce-1\n123');
|
||||
const envelope: BridgeEnvelope = {
|
||||
type: 'auth', installationId: 'install-1', client: 'client-1', version: '1.0.0',
|
||||
capabilities: ['z.capability', 'a.capability'], taskId: 'task-1', grantId: 'grant-1', resumeSessionId: 'session-1',
|
||||
};
|
||||
expect(clientAuthPayload({
|
||||
origin: 'chrome-extension://abc', engineIdentityId: 'identity-1', engineInstanceId: 'instance-1',
|
||||
challenge: 'nonce-1', envelope,
|
||||
})).toBe('yak-browser-bridge-v3\nclient-auth\nchrome-extension://abc\nidentity-1\ninstance-1\nnonce-1\ninstall-1\nclient-1\n1.0.0\na.capability,z.capability\ntask-1\ngrant-1\nsession-1');
|
||||
});
|
||||
|
||||
it('matches the shared pairing verification vector', async () => {
|
||||
await expect(pairingVerificationCode({
|
||||
engineIdentityId: 'engine-id', requestId: 'request-1', origin: 'chrome-extension://abc', installationId: 'install-1',
|
||||
clientNonce: 'client-nonce-value', serverNonce: 'server-nonce-value',
|
||||
publicKey: { kty: 'EC', crv: 'P-256', x: 'x-coordinate', y: 'y-coordinate' },
|
||||
})).resolves.toBe('113961');
|
||||
});
|
||||
|
||||
it('signs and verifies ECDSA P-256 payloads', async () => {
|
||||
const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
|
||||
const publicJWK = await crypto.subtle.exportKey('jwk', pair.publicKey);
|
||||
const publicKey = { kty: 'EC' as const, crv: 'P-256' as const, x: publicJWK.x!, y: publicJWK.y! };
|
||||
const signature = await signBridgePayload(pair.privateKey, 'payload');
|
||||
await expect(verifyBridgePayload(publicKey, 'payload', signature)).resolves.toBe(true);
|
||||
await expect(verifyBridgePayload(publicKey, 'tampered', signature)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { BridgeEnvelope } from '@/types/messages';
|
||||
import type { BridgePublicKey } from '@/types/models';
|
||||
|
||||
const DATABASE_NAME = 'yakit-browser-bridge-identity-v1';
|
||||
const STORE_NAME = 'identities';
|
||||
|
||||
interface StoredBrowserIdentity {
|
||||
installationId: string;
|
||||
privateKey: CryptoKey;
|
||||
publicKey: BridgePublicKey;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
function openIdentityDatabase(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DATABASE_NAME, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
if (!request.result.objectStoreNames.contains(STORE_NAME)) request.result.createObjectStore(STORE_NAME, { keyPath: 'installationId' });
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('无法打开浏览器配对身份数据库'));
|
||||
});
|
||||
}
|
||||
|
||||
function requestResult<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('浏览器配对身份数据库操作失败'));
|
||||
});
|
||||
}
|
||||
|
||||
async function readIdentity(installationId: string): Promise<StoredBrowserIdentity | undefined> {
|
||||
const database = await openIdentityDatabase();
|
||||
try {
|
||||
const transaction = database.transaction(STORE_NAME, 'readonly');
|
||||
return await requestResult(transaction.objectStore(STORE_NAME).get(installationId)) as StoredBrowserIdentity | undefined;
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function writeIdentity(identity: StoredBrowserIdentity): Promise<void> {
|
||||
const database = await openIdentityDatabase();
|
||||
try {
|
||||
const transaction = database.transaction(STORE_NAME, 'readwrite');
|
||||
await requestResult(transaction.objectStore(STORE_NAME).put(identity));
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearBrowserBridgeIdentity(installationId: string): Promise<void> {
|
||||
const database = await openIdentityDatabase();
|
||||
try {
|
||||
const transaction = database.transaction(STORE_NAME, 'readwrite');
|
||||
await requestResult(transaction.objectStore(STORE_NAME).delete(installationId));
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePublicJWK(value: JsonWebKey): BridgePublicKey {
|
||||
if (value.kty !== 'EC' || value.crv !== 'P-256' || !value.x || !value.y) throw new Error('浏览器配对公钥不是 ECDSA P-256');
|
||||
return { kty: 'EC', crv: 'P-256', x: value.x, y: value.y };
|
||||
}
|
||||
|
||||
export async function getOrCreateBrowserBridgeIdentity(installationId: string): Promise<StoredBrowserIdentity> {
|
||||
const existing = await readIdentity(installationId);
|
||||
if (existing?.privateKey && existing.publicKey) return existing;
|
||||
const generated = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
|
||||
const [publicJWK, privatePKCS8] = await Promise.all([
|
||||
crypto.subtle.exportKey('jwk', generated.publicKey),
|
||||
crypto.subtle.exportKey('pkcs8', generated.privateKey),
|
||||
]);
|
||||
const privateKey = await crypto.subtle.importKey('pkcs8', privatePKCS8, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']);
|
||||
const identity: StoredBrowserIdentity = {
|
||||
installationId,
|
||||
privateKey,
|
||||
publicKey: normalizePublicJWK(publicJWK),
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
await writeIdentity(identity);
|
||||
return identity;
|
||||
}
|
||||
|
||||
function bytesToBase64URL(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, Math.min(offset + 0x8000, bytes.length)));
|
||||
}
|
||||
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function base64URLToBytes(value: string): Uint8Array {
|
||||
const padded = value.replaceAll('-', '+').replaceAll('_', '/') + '='.repeat((4 - (value.length % 4)) % 4);
|
||||
const binary = atob(padded);
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
export function randomBridgeNonce(): string {
|
||||
return bytesToBase64URL(crypto.getRandomValues(new Uint8Array(32)));
|
||||
}
|
||||
|
||||
export async function signBridgePayload(privateKey: CryptoKey, payload: string): Promise<string> {
|
||||
const signature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, privateKey, new TextEncoder().encode(payload));
|
||||
return bytesToBase64URL(new Uint8Array(signature));
|
||||
}
|
||||
|
||||
export async function verifyBridgePayload(publicKey: BridgePublicKey, payload: string, signature: string): Promise<boolean> {
|
||||
const key = await crypto.subtle.importKey('jwk', publicKey, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
|
||||
return crypto.subtle.verify(
|
||||
{ name: 'ECDSA', hash: 'SHA-256' }, key,
|
||||
base64URLToBytes(signature).buffer as ArrayBuffer,
|
||||
new TextEncoder().encode(payload),
|
||||
);
|
||||
}
|
||||
|
||||
export function engineChallengePayload(input: {
|
||||
engineIdentityId: string;
|
||||
engineInstanceId: string;
|
||||
challenge: string;
|
||||
timestamp: number;
|
||||
}): string {
|
||||
return [
|
||||
'yak-browser-bridge-v3', 'engine-challenge', input.engineIdentityId, input.engineInstanceId,
|
||||
input.challenge, String(input.timestamp),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function clientAuthPayload(input: {
|
||||
origin: string;
|
||||
engineIdentityId: string;
|
||||
engineInstanceId: string;
|
||||
challenge: string;
|
||||
envelope: BridgeEnvelope;
|
||||
}): string {
|
||||
return [
|
||||
'yak-browser-bridge-v3', 'client-auth', input.origin, input.engineIdentityId, input.engineInstanceId,
|
||||
input.challenge, input.envelope.installationId || '', input.envelope.client || '', input.envelope.version || '',
|
||||
[...(input.envelope.capabilities || [])].sort().join(','), input.envelope.taskId || '', input.envelope.grantId || '',
|
||||
input.envelope.resumeSessionId || '',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export async function pairingVerificationCode(input: {
|
||||
engineIdentityId: string;
|
||||
requestId: string;
|
||||
origin: string;
|
||||
installationId: string;
|
||||
clientNonce: string;
|
||||
serverNonce: string;
|
||||
publicKey: BridgePublicKey;
|
||||
}): Promise<string> {
|
||||
const payload = [
|
||||
'yak-browser-pairing-v1', input.engineIdentityId, input.requestId, input.origin, input.installationId,
|
||||
input.clientNonce, input.serverNonce, input.publicKey.kty, input.publicKey.crv, input.publicKey.x, input.publicKey.y,
|
||||
].join('\n');
|
||||
const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload)));
|
||||
let value = 0n;
|
||||
for (const byte of hash.subarray(0, 8)) value = (value << 8n) | BigInt(byte);
|
||||
return String(value % 1_000_000n).padStart(6, '0');
|
||||
}
|
||||
|
||||
export function publicKeysEqual(left: BridgePublicKey, right: BridgePublicKey): boolean {
|
||||
return left.kty === right.kty && left.crv === right.crv && left.x === right.x && left.y === right.y;
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import type { BridgeEnvelope } from '@/types/messages';
|
||||
import type { BridgeConfig, BridgePairingStatus, BridgePublicKey, BridgeStatus } from '@/types/models';
|
||||
import { BRIDGE_CAPABILITIES } from '@/protocol/capabilities';
|
||||
import {
|
||||
BRIDGE_CHUNK_BYTES, BRIDGE_CHUNK_THRESHOLD_BYTES, BRIDGE_CHUNK_TIMEOUT_MS,
|
||||
BRIDGE_MAX_CHUNK_TRANSFERS, BRIDGE_MAX_MESSAGE_BYTES, BRIDGE_PROTOCOL_VERSION, parseBridgeEnvelope,
|
||||
parseBridgePairingEnvelope, type BridgePairingEnvelope,
|
||||
} from '@/protocol/bridge';
|
||||
import { getBridgeRuntimeSession, getState, setBridgeRuntimeSession, updateState } from '@/platform/storage/state';
|
||||
import { routeCapability } from '@/features/grants/service';
|
||||
import { errorCode, ExtensionError, isDeniedErrorCode } from '@/shared/errors';
|
||||
import { appendAuditEvent } from '@/features/diagnostics/audit';
|
||||
import { beginAgentAction, finishAgentAction } from '@/features/agent-runtime/service';
|
||||
import { recordBridgeState, recordCapabilityMetric, recordHeartbeat } from '@/features/diagnostics/metrics';
|
||||
import {
|
||||
clearBrowserBridgeIdentity, clientAuthPayload, engineChallengePayload, getOrCreateBrowserBridgeIdentity,
|
||||
pairingVerificationCode, publicKeysEqual, randomBridgeNonce, signBridgePayload, verifyBridgePayload,
|
||||
} from './identity';
|
||||
|
||||
const STATUS_EVENT = 'bridge.status.changed';
|
||||
const PAIRING_STATUS_EVENT = 'bridge.pairing.status.changed';
|
||||
const RECONNECT_DELAY = 3_000;
|
||||
const HEARTBEAT_INTERVAL = 20_000;
|
||||
const HANDSHAKE_TIMEOUT = 5_000;
|
||||
const MAX_CONCURRENT_REQUESTS = 8;
|
||||
const ENGINE_REQUEST_TIMEOUT = 10_000;
|
||||
const MAX_OUTGOING_REQUESTS = 4;
|
||||
|
||||
interface OutgoingRequest {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: ReturnType<typeof globalThis.setTimeout>;
|
||||
}
|
||||
|
||||
interface ChunkAssembly {
|
||||
createdAt: number;
|
||||
total: number;
|
||||
originalBytes: number;
|
||||
parts: Array<Uint8Array | undefined>;
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, Math.min(offset + 0x8000, bytes.length)));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToBytes(value: string): Uint8Array {
|
||||
const binary = atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function isLoopbackEndpoint(endpoint: string): boolean {
|
||||
try {
|
||||
const url = new URL(endpoint);
|
||||
return (url.protocol === 'ws:' || url.protocol === 'wss:')
|
||||
&& ['127.0.0.1', 'localhost', '[::1]', '::1'].includes(url.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class EngineBridge {
|
||||
private socket?: WebSocket;
|
||||
private nativePort?: Browser.runtime.Port;
|
||||
private reconnectTimer?: ReturnType<typeof globalThis.setTimeout>;
|
||||
private heartbeatTimer?: ReturnType<typeof globalThis.setInterval>;
|
||||
private handshakeTimer?: ReturnType<typeof globalThis.setTimeout>;
|
||||
private handshakeResolve?: () => void;
|
||||
private handshakeReject?: (error: Error) => void;
|
||||
private connectPromise?: Promise<void>;
|
||||
private pairingSocket?: WebSocket;
|
||||
private pairingTimer?: ReturnType<typeof globalThis.setTimeout>;
|
||||
private pairingResolve?: (status: BridgePairingStatus) => void;
|
||||
private pairingReject?: (error: Error) => void;
|
||||
private pairingContext?: {
|
||||
config: BridgeConfig;
|
||||
clientNonce: string;
|
||||
publicKey: BridgePublicKey;
|
||||
privateKey: CryptoKey;
|
||||
requestId?: string;
|
||||
engineIdentityId?: string;
|
||||
enginePublicKey?: BridgePublicKey;
|
||||
};
|
||||
private readonly inFlight = new Map<string, AbortController>();
|
||||
private readonly outgoing = new Map<string, OutgoingRequest>();
|
||||
private readonly chunks = new Map<string, ChunkAssembly>();
|
||||
private heartbeatSequence = 0;
|
||||
private manuallyClosed = false;
|
||||
private status: BridgeStatus = { state: 'disconnected', message: '未连接引擎' };
|
||||
private pairingStatus: BridgePairingStatus = { state: 'idle', message: '尚未配对' };
|
||||
|
||||
getStatus(): BridgeStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
getPairingStatus(): BridgePairingStatus {
|
||||
return this.pairingStatus;
|
||||
}
|
||||
|
||||
emitEvent(method: string, params: unknown): void {
|
||||
if (this.status.state === 'connected') this.send({ type: 'event', method, params });
|
||||
}
|
||||
|
||||
requestEngine<T>(method: string, params: unknown, timeoutMs = ENGINE_REQUEST_TIMEOUT): Promise<T> {
|
||||
if (this.status.state !== 'connected') return Promise.reject(new ExtensionError('bridge_disconnected', 'Yak 引擎未连接'));
|
||||
if (!this.nativePort && this.socket?.readyState !== WebSocket.OPEN) {
|
||||
return Promise.reject(new ExtensionError('bridge_disconnected', 'Yak 引擎连接不可用'));
|
||||
}
|
||||
if (this.outgoing.size >= MAX_OUTGOING_REQUESTS) {
|
||||
return Promise.reject(new ExtensionError('server_busy', `插件到 Yak 的并行请求已达到 ${MAX_OUTGOING_REQUESTS} 个上限`));
|
||||
}
|
||||
if (this.status.capabilities && !this.status.capabilities.includes(method)) {
|
||||
return Promise.reject(new ExtensionError('engine_capability_unavailable', `Yak 引擎不支持能力: ${method}`));
|
||||
}
|
||||
const id = `extension-${crypto.randomUUID()}`;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
this.outgoing.delete(id);
|
||||
this.send({ type: 'cancel', id });
|
||||
reject(new ExtensionError('engine_timeout', `Yak 引擎请求超过 ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
this.outgoing.set(id, { resolve: (value) => resolve(value as T), reject, timer });
|
||||
try {
|
||||
this.send({ type: 'request', id, method, params });
|
||||
} catch (error) {
|
||||
globalThis.clearTimeout(timer);
|
||||
this.outgoing.delete(id);
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async connect(config?: BridgeConfig): Promise<void> {
|
||||
if (this.status.state === 'connected') return;
|
||||
if (this.connectPromise) return this.connectPromise;
|
||||
const effectiveConfig = config || (await getState()).bridge;
|
||||
if (!effectiveConfig.pairedEngine) throw new Error('浏览器插件尚未与 Yak 引擎配对');
|
||||
const attempt = (effectiveConfig.transport === 'native'
|
||||
? this.connectNative(effectiveConfig)
|
||||
: this.connectWebSocket(effectiveConfig)).catch((error) => {
|
||||
const failure = error instanceof Error ? error : new Error(String(error));
|
||||
if (!this.manuallyClosed && this.status.state !== 'error') this.setStatus({ state: 'error', message: failure.message });
|
||||
throw failure;
|
||||
});
|
||||
const tracked = attempt.finally(() => {
|
||||
if (this.connectPromise === tracked) this.connectPromise = undefined;
|
||||
});
|
||||
this.connectPromise = tracked;
|
||||
return tracked;
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.manuallyClosed = true;
|
||||
if (this.reconnectTimer) globalThis.clearTimeout(this.reconnectTimer);
|
||||
this.failHandshake(new Error('Bridge 连接已取消'));
|
||||
this.stopHeartbeat();
|
||||
this.abortInFlight();
|
||||
this.rejectOutgoing(new ExtensionError('bridge_disconnected', 'Bridge 已断开'));
|
||||
this.socket?.close(1000, 'user disconnected');
|
||||
this.socket = undefined;
|
||||
this.nativePort?.disconnect();
|
||||
this.nativePort = undefined;
|
||||
this.setStatus({ state: 'disconnected', message: '已手动断开' });
|
||||
}
|
||||
|
||||
cancelActiveRequests(): void {
|
||||
this.abortInFlight();
|
||||
}
|
||||
|
||||
private async connectWebSocket(config: BridgeConfig): Promise<void> {
|
||||
if (!isLoopbackEndpoint(config.endpoint)) {
|
||||
throw new Error('Bridge 仅允许连接本机 ws://127.0.0.1、localhost 或 ::1');
|
||||
}
|
||||
this.manuallyClosed = false;
|
||||
this.setStatus({ state: 'connecting', message: '正在连接本地 Yak 引擎' });
|
||||
const socket = new WebSocket(config.endpoint);
|
||||
this.socket = socket;
|
||||
const negotiated = this.createHandshakePromise();
|
||||
|
||||
socket.addEventListener('open', () => this.setStatus({ state: 'negotiating', message: '正在验证 Yak 引擎与浏览器身份' }));
|
||||
socket.addEventListener('message', (event) => void this.onMessage(String(event.data)));
|
||||
socket.addEventListener('error', () => {
|
||||
const error = new Error('Bridge 连接失败');
|
||||
this.failHandshake(error);
|
||||
this.setStatus({ state: 'error', message: error.message });
|
||||
});
|
||||
socket.addEventListener('close', () => {
|
||||
this.stopHeartbeat();
|
||||
if (this.socket === socket) {
|
||||
this.socket = undefined;
|
||||
this.abortInFlight();
|
||||
this.rejectOutgoing(new ExtensionError('bridge_disconnected', '与 Yak 引擎的连接已断开'));
|
||||
}
|
||||
this.failHandshake(new Error('Bridge 在协议协商完成前断开'));
|
||||
if (!this.manuallyClosed) this.setStatus({ state: 'disconnected', message: '与 Yak 引擎的连接已断开' });
|
||||
if (!this.manuallyClosed && config.autoConnect) this.scheduleReconnect(config);
|
||||
});
|
||||
return negotiated;
|
||||
}
|
||||
|
||||
private async connectNative(config: BridgeConfig): Promise<void> {
|
||||
if (!config.nativeHost.trim()) throw new Error('Native Messaging Host 名称不能为空');
|
||||
this.manuallyClosed = false;
|
||||
this.setStatus({ state: 'connecting', message: '正在连接 Yakit Native Host' });
|
||||
const port = browser.runtime.connectNative(config.nativeHost.trim());
|
||||
this.nativePort = port;
|
||||
const negotiated = this.createHandshakePromise();
|
||||
port.onMessage.addListener((message) => void this.onMessage(message));
|
||||
port.onDisconnect.addListener(() => {
|
||||
const lastError = browser.runtime.lastError?.message;
|
||||
if (this.nativePort === port) {
|
||||
this.nativePort = undefined;
|
||||
this.abortInFlight();
|
||||
this.rejectOutgoing(new ExtensionError('bridge_disconnected', 'Native Host 已断开'));
|
||||
}
|
||||
this.failHandshake(new Error(lastError || 'Native Host 在协议协商完成前断开'));
|
||||
this.stopHeartbeat();
|
||||
this.setStatus({ state: lastError ? 'error' : 'disconnected', message: lastError || 'Native Host 已断开' });
|
||||
if (!this.manuallyClosed && config.autoConnect) this.scheduleReconnect(config);
|
||||
});
|
||||
this.setStatus({ state: 'negotiating', message: '正在验证 Yak 引擎与浏览器身份' });
|
||||
return negotiated;
|
||||
}
|
||||
|
||||
private async answerChallenge(config: BridgeConfig, challenge: BridgeEnvelope): Promise<void> {
|
||||
const paired = config.pairedEngine;
|
||||
if (!paired || !challenge.publicKey || !challenge.engineIdentityId || !challenge.engineInstanceId || !challenge.challenge || !challenge.signature || !challenge.timestamp) {
|
||||
throw new Error('Yak 引擎返回了不完整的身份挑战');
|
||||
}
|
||||
if (Math.abs(Date.now() - challenge.timestamp) > 60_000) throw new Error('Yak 引擎身份挑战已经过期');
|
||||
if (paired.engineIdentityId !== challenge.engineIdentityId || !publicKeysEqual(paired.publicKey, challenge.publicKey)) {
|
||||
throw new Error('Yak 引擎身份与首次配对记录不一致');
|
||||
}
|
||||
const verified = await verifyBridgePayload(challenge.publicKey, engineChallengePayload({
|
||||
engineIdentityId: challenge.engineIdentityId,
|
||||
engineInstanceId: challenge.engineInstanceId,
|
||||
challenge: challenge.challenge,
|
||||
timestamp: challenge.timestamp,
|
||||
}), challenge.signature);
|
||||
if (!verified) throw new Error('Yak 引擎身份签名验证失败');
|
||||
const [state, previousSession] = await Promise.all([getState(), getBridgeRuntimeSession()]);
|
||||
const identity = await getOrCreateBrowserBridgeIdentity(config.installationId);
|
||||
const auth: BridgeEnvelope = {
|
||||
type: 'auth',
|
||||
client: 'yakit-browser-extension',
|
||||
version: browser.runtime.getManifest().version,
|
||||
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
||||
capabilities: [...BRIDGE_CAPABILITIES],
|
||||
installationId: config.installationId,
|
||||
taskId: state.activeGrant?.taskId,
|
||||
grantId: state.activeGrant?.id,
|
||||
resumeSessionId: previousSession?.sessionId,
|
||||
challenge: challenge.challenge,
|
||||
};
|
||||
auth.signature = await signBridgePayload(identity.privateKey, clientAuthPayload({
|
||||
origin: browser.runtime.getURL('').replace(/\/$/, ''),
|
||||
engineIdentityId: challenge.engineIdentityId,
|
||||
engineInstanceId: challenge.engineInstanceId,
|
||||
challenge: challenge.challenge,
|
||||
envelope: auth,
|
||||
}));
|
||||
this.send(auth);
|
||||
}
|
||||
|
||||
private createHandshakePromise(): Promise<void> {
|
||||
this.failHandshake(new Error('Bridge 协议协商已被新连接替代'));
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.handshakeResolve = resolve;
|
||||
this.handshakeReject = reject;
|
||||
this.handshakeTimer = globalThis.setTimeout(() => {
|
||||
const error = new Error(`Bridge 协议协商超过 ${HANDSHAKE_TIMEOUT / 1_000} 秒`);
|
||||
this.failHandshake(error);
|
||||
this.setStatus({ state: 'error', message: error.message });
|
||||
this.socket?.close(1002, 'handshake timeout');
|
||||
this.nativePort?.disconnect();
|
||||
}, HANDSHAKE_TIMEOUT);
|
||||
});
|
||||
}
|
||||
|
||||
private async completeHandshake(message: BridgeEnvelope): Promise<void> {
|
||||
if (!this.handshakeResolve) return;
|
||||
if (this.handshakeTimer) globalThis.clearTimeout(this.handshakeTimer);
|
||||
const resolve = this.handshakeResolve;
|
||||
this.handshakeTimer = undefined;
|
||||
this.handshakeResolve = undefined;
|
||||
this.handshakeReject = undefined;
|
||||
this.setStatus({
|
||||
state: 'connected',
|
||||
message: '已连接 Yak 引擎',
|
||||
connectedAt: Date.now(),
|
||||
engineVersion: message.version,
|
||||
protocolVersion: message.protocolVersion,
|
||||
capabilities: message.capabilities,
|
||||
sessionId: message.sessionId,
|
||||
engineInstanceId: message.engineInstanceId,
|
||||
engineIdentityId: message.engineIdentityId,
|
||||
connectionId: message.connectionId,
|
||||
taskId: message.taskId,
|
||||
grantId: message.grantId,
|
||||
resumed: message.resumed,
|
||||
});
|
||||
await setBridgeRuntimeSession({
|
||||
sessionId: message.sessionId!,
|
||||
engineInstanceId: message.engineInstanceId!,
|
||||
engineIdentityId: message.engineIdentityId,
|
||||
taskId: message.taskId,
|
||||
grantId: message.grantId,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
this.startHeartbeat();
|
||||
resolve();
|
||||
}
|
||||
|
||||
private failHandshake(error: Error): void {
|
||||
if (this.handshakeTimer) globalThis.clearTimeout(this.handshakeTimer);
|
||||
const reject = this.handshakeReject;
|
||||
this.handshakeTimer = undefined;
|
||||
this.handshakeResolve = undefined;
|
||||
this.handshakeReject = undefined;
|
||||
reject?.(error);
|
||||
}
|
||||
|
||||
private async onMessage(raw: unknown): Promise<void> {
|
||||
let message: BridgeEnvelope;
|
||||
try {
|
||||
message = parseBridgeEnvelope(raw);
|
||||
} catch (error) {
|
||||
const failure = error instanceof Error ? error : new Error(String(error));
|
||||
if (this.status.state === 'negotiating') {
|
||||
this.failHandshake(failure);
|
||||
this.setStatus({ state: 'error', message: failure.message });
|
||||
this.socket?.close(1002, 'invalid handshake');
|
||||
this.nativePort?.disconnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === 'chunk') {
|
||||
try {
|
||||
const assembled = this.acceptChunk(message);
|
||||
if (assembled !== undefined) await this.onMessage(assembled);
|
||||
} catch (error) {
|
||||
this.setStatus({ ...this.status, message: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === 'challenge') {
|
||||
try {
|
||||
await this.answerChallenge((await getState()).bridge, message);
|
||||
} catch (error) {
|
||||
const failure = error instanceof Error ? error : new Error(String(error));
|
||||
this.failHandshake(failure);
|
||||
this.setStatus({ state: 'error', message: failure.message });
|
||||
this.socket?.close(1008, 'identity verification failed');
|
||||
this.nativePort?.disconnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === 'hello_ack') {
|
||||
await this.completeHandshake(message);
|
||||
return;
|
||||
}
|
||||
if (message.type === 'response' && message.error && this.status.state === 'negotiating') {
|
||||
const error = new Error(message.error.message || 'Bridge 拒绝连接');
|
||||
this.failHandshake(error);
|
||||
this.setStatus({ state: 'error', message: error.message });
|
||||
return;
|
||||
}
|
||||
if (message.type === 'response' && message.id) {
|
||||
const pending = this.outgoing.get(message.id);
|
||||
if (!pending) return;
|
||||
globalThis.clearTimeout(pending.timer);
|
||||
this.outgoing.delete(message.id);
|
||||
if (message.error) pending.reject(new ExtensionError(message.error.code, message.error.message));
|
||||
else pending.resolve(message.result);
|
||||
return;
|
||||
}
|
||||
if (message.type === 'ping') {
|
||||
this.send({
|
||||
type: 'pong', id: message.id, sequence: message.sequence,
|
||||
timestamp: message.timestamp, replyTimestamp: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.type === 'pong') {
|
||||
const now = Date.now();
|
||||
const latencyMs = Math.max(0, now - Number(message.timestamp));
|
||||
recordHeartbeat(latencyMs);
|
||||
this.setStatus({
|
||||
...this.status,
|
||||
heartbeatSequence: message.sequence,
|
||||
latencyMs,
|
||||
lastHeartbeatAt: now,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.type === 'cancel' && message.id) {
|
||||
this.inFlight.get(message.id)?.abort();
|
||||
return;
|
||||
}
|
||||
if (this.status.state !== 'connected' || message.type !== 'request' || !message.id || !message.method) return;
|
||||
|
||||
if (this.inFlight.size >= MAX_CONCURRENT_REQUESTS) {
|
||||
this.send({
|
||||
type: 'response',
|
||||
id: message.id,
|
||||
error: { code: 'server_busy', message: `Bridge 并行请求已达到 ${MAX_CONCURRENT_REQUESTS} 个上限` },
|
||||
});
|
||||
void appendAuditEvent({
|
||||
category: 'capability', action: message.method, outcome: 'denied', errorCode: 'server_busy',
|
||||
targetTabId: typeof (message.params as { tabId?: unknown } | undefined)?.tabId === 'number'
|
||||
? (message.params as { tabId: number }).tabId
|
||||
: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.inFlight.has(message.id)) {
|
||||
this.send({
|
||||
type: 'response', id: message.id,
|
||||
error: { code: 'duplicate_request_id', message: 'Bridge 请求 ID 正在使用中' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
this.inFlight.set(message.id, controller);
|
||||
const cancelled = new Promise<never>((_, reject) => {
|
||||
controller.signal.addEventListener('abort', () => reject(new ExtensionError('cancelled', 'Bridge 请求已取消')), { once: true });
|
||||
});
|
||||
const startedAt = performance.now();
|
||||
let taskId: string | undefined;
|
||||
let actionId: string | undefined;
|
||||
let targetTabId = typeof (message.params as { tabId?: unknown } | undefined)?.tabId === 'number'
|
||||
? (message.params as { tabId: number }).tabId
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const grant = (await getState()).activeGrant;
|
||||
taskId = grant?.taskId;
|
||||
targetTabId ??= grant?.targets[0]?.tabId;
|
||||
if (grant) {
|
||||
actionId = (await beginAgentAction(grant, {
|
||||
requestId: message.id, method: message.method, targetTabId,
|
||||
})).id;
|
||||
}
|
||||
const operation = routeCapability(message.method, message.params, (method, params) => this.requestEngine(method, params));
|
||||
const result = await Promise.race([operation, cancelled]);
|
||||
const durationMs = performance.now() - startedAt;
|
||||
this.send({ type: 'response', id: message.id, result });
|
||||
if (actionId) void finishAgentAction(actionId, 'success');
|
||||
recordCapabilityMetric(message.method, durationMs, false);
|
||||
void appendAuditEvent({
|
||||
category: 'capability', action: message.method, outcome: 'success', taskId,
|
||||
targetTabId, durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
||||
});
|
||||
} catch (error) {
|
||||
const code = errorCode(error);
|
||||
recordCapabilityMetric(message.method, performance.now() - startedAt, true);
|
||||
this.send({
|
||||
type: 'response',
|
||||
id: message.id,
|
||||
error: { code, message: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
if (actionId) {
|
||||
void finishAgentAction(
|
||||
actionId,
|
||||
code === 'cancelled' ? 'cancelled' : isDeniedErrorCode(code) ? 'denied' : 'error',
|
||||
code,
|
||||
);
|
||||
}
|
||||
void appendAuditEvent({
|
||||
category: 'capability', action: message.method,
|
||||
outcome: code === 'cancelled' ? 'cancelled' : isDeniedErrorCode(code) ? 'denied' : 'error',
|
||||
taskId, targetTabId, errorCode: code,
|
||||
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
||||
});
|
||||
} finally {
|
||||
this.inFlight.delete(message.id);
|
||||
}
|
||||
}
|
||||
|
||||
private send(message: BridgeEnvelope): void {
|
||||
let encoded = JSON.stringify(message);
|
||||
if (new TextEncoder().encode(encoded).byteLength > BRIDGE_MAX_MESSAGE_BYTES) {
|
||||
if (message.type !== 'response' || !message.id) throw new Error('Bridge 出站消息超过 16 MiB 限制');
|
||||
encoded = JSON.stringify({
|
||||
type: 'response',
|
||||
id: message.id,
|
||||
error: { code: 'payload_too_large', message: 'Bridge 响应超过 16 MiB 限制' },
|
||||
} satisfies BridgeEnvelope);
|
||||
}
|
||||
const bytes = new TextEncoder().encode(encoded);
|
||||
if (bytes.byteLength > BRIDGE_CHUNK_THRESHOLD_BYTES) {
|
||||
const transferId = `chunk-${crypto.randomUUID()}`;
|
||||
const total = Math.ceil(bytes.byteLength / BRIDGE_CHUNK_BYTES);
|
||||
for (let index = 0; index < total; index += 1) {
|
||||
const start = index * BRIDGE_CHUNK_BYTES;
|
||||
this.sendRaw(JSON.stringify({
|
||||
type: 'chunk', transferId, index, total, originalBytes: bytes.byteLength,
|
||||
data: bytesToBase64(bytes.subarray(start, Math.min(start + BRIDGE_CHUNK_BYTES, bytes.byteLength))),
|
||||
} satisfies BridgeEnvelope));
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.sendRaw(encoded);
|
||||
}
|
||||
|
||||
private sendRaw(encoded: string): void {
|
||||
if (this.nativePort) {
|
||||
this.nativePort.postMessage(JSON.parse(encoded) as BridgeEnvelope);
|
||||
return;
|
||||
}
|
||||
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(encoded);
|
||||
}
|
||||
|
||||
private acceptChunk(message: BridgeEnvelope): string | undefined {
|
||||
const now = Date.now();
|
||||
for (const [id, assembly] of this.chunks) {
|
||||
if (now - assembly.createdAt > BRIDGE_CHUNK_TIMEOUT_MS) this.chunks.delete(id);
|
||||
}
|
||||
const transferId = message.transferId!;
|
||||
let assembly = this.chunks.get(transferId);
|
||||
if (!assembly) {
|
||||
if (this.chunks.size >= BRIDGE_MAX_CHUNK_TRANSFERS) throw new Error('Bridge 并行分片传输超过上限');
|
||||
assembly = {
|
||||
createdAt: now, total: message.total!, originalBytes: message.originalBytes!,
|
||||
parts: new Array<Uint8Array | undefined>(message.total!),
|
||||
};
|
||||
this.chunks.set(transferId, assembly);
|
||||
}
|
||||
if (assembly.total !== message.total || assembly.originalBytes !== message.originalBytes) {
|
||||
this.chunks.delete(transferId);
|
||||
throw new Error('Bridge 分片元数据不一致');
|
||||
}
|
||||
const part = base64ToBytes(message.data!);
|
||||
if (part.byteLength > BRIDGE_CHUNK_BYTES || (message.index! < assembly.total - 1 && part.byteLength !== BRIDGE_CHUNK_BYTES)) {
|
||||
this.chunks.delete(transferId);
|
||||
throw new Error('Bridge 分片大小无效');
|
||||
}
|
||||
assembly.parts[message.index!] = part;
|
||||
if (assembly.parts.some((item) => item === undefined)) return undefined;
|
||||
const bytes = new Uint8Array(assembly.originalBytes);
|
||||
let offset = 0;
|
||||
for (const item of assembly.parts) {
|
||||
bytes.set(item!, offset);
|
||||
offset += item!.byteLength;
|
||||
}
|
||||
this.chunks.delete(transferId);
|
||||
if (offset !== assembly.originalBytes) throw new Error('Bridge 分片重组大小不匹配');
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
private scheduleReconnect(config: BridgeConfig): void {
|
||||
if (this.reconnectTimer) globalThis.clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = globalThis.setTimeout(() => void this.connect(config).catch(() => undefined), RECONNECT_DELAY);
|
||||
}
|
||||
|
||||
private abortInFlight(): void {
|
||||
for (const controller of this.inFlight.values()) controller.abort();
|
||||
this.inFlight.clear();
|
||||
this.chunks.clear();
|
||||
}
|
||||
|
||||
private rejectOutgoing(error: Error): void {
|
||||
for (const pending of this.outgoing.values()) {
|
||||
globalThis.clearTimeout(pending.timer);
|
||||
pending.reject(error);
|
||||
}
|
||||
this.outgoing.clear();
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat();
|
||||
const ping = () => {
|
||||
const sequence = ++this.heartbeatSequence;
|
||||
this.send({ type: 'ping', id: `heartbeat-${sequence}`, sequence, timestamp: Date.now() });
|
||||
};
|
||||
ping();
|
||||
this.heartbeatTimer = globalThis.setInterval(ping, HEARTBEAT_INTERVAL);
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) globalThis.clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = undefined;
|
||||
}
|
||||
|
||||
private setStatus(status: BridgeStatus): void {
|
||||
if (status.state !== this.status.state && ['connecting', 'connected', 'disconnected', 'error'].includes(status.state)) {
|
||||
recordBridgeState(status.state as 'connecting' | 'connected' | 'disconnected' | 'error');
|
||||
}
|
||||
this.status = status;
|
||||
void browser.runtime.sendMessage({ action: STATUS_EVENT, payload: status }).catch(() => undefined);
|
||||
}
|
||||
|
||||
async startPairing(): Promise<BridgePairingStatus> {
|
||||
const config = (await getState()).bridge;
|
||||
if (config.pairedEngine) return { state: 'approved', message: '当前浏览器已经完成配对', engineIdentityId: config.pairedEngine.engineIdentityId };
|
||||
if (!isLoopbackEndpoint(config.endpoint)) throw new Error('配对仅允许访问本机 Yak Bridge');
|
||||
if (this.pairingSocket && ['requesting', 'pending'].includes(this.pairingStatus.state)) return this.pairingStatus;
|
||||
this.cancelPairing(false);
|
||||
const identity = await getOrCreateBrowserBridgeIdentity(config.installationId);
|
||||
const clientNonce = randomBridgeNonce();
|
||||
const pairingURL = new URL(config.endpoint);
|
||||
pairingURL.pathname = '/pairing';
|
||||
pairingURL.search = '';
|
||||
pairingURL.hash = '';
|
||||
const socket = new WebSocket(pairingURL.toString());
|
||||
this.pairingSocket = socket;
|
||||
this.pairingContext = { config, clientNonce, publicKey: identity.publicKey, privateKey: identity.privateKey };
|
||||
this.setPairingStatus({ state: 'requesting', message: '正在向本机 Yak 引擎申请配对' });
|
||||
const pending = new Promise<BridgePairingStatus>((resolve, reject) => {
|
||||
this.pairingResolve = resolve;
|
||||
this.pairingReject = reject;
|
||||
this.pairingTimer = globalThis.setTimeout(() => {
|
||||
const error = new Error('Yak 引擎配对请求超过 5 秒未响应');
|
||||
this.failPairing(error);
|
||||
socket.close(1000, 'pairing timeout');
|
||||
}, HANDSHAKE_TIMEOUT);
|
||||
});
|
||||
socket.addEventListener('open', () => {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'pair_request', protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
||||
installationId: config.installationId,
|
||||
client: 'yakit-browser-extension', version: browser.runtime.getManifest().version,
|
||||
nonce: clientNonce, publicKey: identity.publicKey,
|
||||
} satisfies BridgePairingEnvelope));
|
||||
});
|
||||
socket.addEventListener('message', (event) => void this.onPairingMessage(String(event.data)));
|
||||
socket.addEventListener('error', () => this.failPairing(new Error('无法连接本机 Yak 配对服务')));
|
||||
socket.addEventListener('close', () => {
|
||||
if (this.pairingSocket === socket) this.pairingSocket = undefined;
|
||||
if (['requesting', 'pending'].includes(this.pairingStatus.state)) this.failPairing(new Error('Yak 配对连接已断开'));
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
|
||||
cancelPairing(notify = true): BridgePairingStatus {
|
||||
if (this.pairingTimer) globalThis.clearTimeout(this.pairingTimer);
|
||||
this.pairingTimer = undefined;
|
||||
this.pairingResolve = undefined;
|
||||
this.pairingReject = undefined;
|
||||
this.pairingContext = undefined;
|
||||
this.pairingSocket?.close(1000, 'pairing cancelled');
|
||||
this.pairingSocket = undefined;
|
||||
const status: BridgePairingStatus = { state: 'idle', message: '配对已取消' };
|
||||
if (notify) this.setPairingStatus(status);
|
||||
return status;
|
||||
}
|
||||
|
||||
async unpair(): Promise<void> {
|
||||
const state = await getState();
|
||||
this.disconnect();
|
||||
this.cancelPairing(false);
|
||||
await clearBrowserBridgeIdentity(state.bridge.installationId);
|
||||
await updateState((current) => ({
|
||||
...current,
|
||||
bridge: {
|
||||
...current.bridge,
|
||||
pairedEngine: undefined,
|
||||
autoConnect: false,
|
||||
},
|
||||
}));
|
||||
this.setPairingStatus({ state: 'idle', message: '本地配对凭据已清除,浏览器安装身份保持不变' });
|
||||
}
|
||||
|
||||
private async onPairingMessage(raw: unknown): Promise<void> {
|
||||
let message: BridgePairingEnvelope;
|
||||
try {
|
||||
message = parseBridgePairingEnvelope(raw);
|
||||
} catch (error) {
|
||||
this.failPairing(error instanceof Error ? error : new Error(String(error)));
|
||||
return;
|
||||
}
|
||||
const context = this.pairingContext;
|
||||
if (!context) return;
|
||||
if (message.type === 'pair_pending') {
|
||||
const code = await pairingVerificationCode({
|
||||
engineIdentityId: message.engineIdentityId!, requestId: message.requestId!,
|
||||
origin: browser.runtime.getURL('').replace(/\/$/, ''), installationId: context.config.installationId,
|
||||
clientNonce: context.clientNonce, serverNonce: message.serverNonce!, publicKey: context.publicKey,
|
||||
});
|
||||
if (code !== message.code) {
|
||||
this.failPairing(new Error('Yak 配对验证码校验失败'));
|
||||
this.pairingSocket?.close(1008, 'pairing transcript mismatch');
|
||||
return;
|
||||
}
|
||||
context.requestId = message.requestId;
|
||||
context.engineIdentityId = message.engineIdentityId;
|
||||
context.enginePublicKey = message.publicKey;
|
||||
if (this.pairingTimer) globalThis.clearTimeout(this.pairingTimer);
|
||||
this.pairingTimer = undefined;
|
||||
const status: BridgePairingStatus = {
|
||||
state: 'pending', message: '请在 Yakit 中确认相同的验证码',
|
||||
requestId: message.requestId, code, engineIdentityId: message.engineIdentityId, expiresAt: message.expiresAt,
|
||||
};
|
||||
this.setPairingStatus(status);
|
||||
this.pairingResolve?.(status);
|
||||
this.pairingResolve = undefined;
|
||||
this.pairingReject = undefined;
|
||||
return;
|
||||
}
|
||||
if (message.type === 'pair_approved') {
|
||||
if (!context.requestId || message.requestId !== context.requestId || !context.engineIdentityId || !context.enginePublicKey
|
||||
|| message.engineIdentityId !== context.engineIdentityId || !message.publicKey || !publicKeysEqual(message.publicKey, context.enginePublicKey)) {
|
||||
this.failPairing(new Error('Yak 配对批准信息与当前申请不一致'));
|
||||
return;
|
||||
}
|
||||
const next = await updateState((current) => ({
|
||||
...current,
|
||||
bridge: {
|
||||
...current.bridge,
|
||||
autoConnect: true,
|
||||
pairedEngine: {
|
||||
engineIdentityId: message.engineIdentityId!, deviceId: message.deviceId!,
|
||||
publicKey: message.publicKey!, pairedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
this.setPairingStatus({ state: 'approved', message: '已与 Yak 引擎安全配对', engineIdentityId: message.engineIdentityId });
|
||||
this.pairingContext = undefined;
|
||||
this.pairingSocket?.close(1000, 'pairing approved');
|
||||
this.pairingSocket = undefined;
|
||||
await this.connect(next.bridge);
|
||||
return;
|
||||
}
|
||||
const state = message.type === 'pair_rejected' ? 'rejected' : message.type === 'pair_expired' ? 'expired' : 'error';
|
||||
const status: BridgePairingStatus = { state, message: message.message || 'Yak 引擎拒绝了配对申请', requestId: message.requestId };
|
||||
this.setPairingStatus(status);
|
||||
this.pairingContext = undefined;
|
||||
this.pairingSocket?.close(1000, state);
|
||||
this.pairingSocket = undefined;
|
||||
}
|
||||
|
||||
private failPairing(error: Error): void {
|
||||
if (this.pairingTimer) globalThis.clearTimeout(this.pairingTimer);
|
||||
this.pairingTimer = undefined;
|
||||
this.pairingReject?.(error);
|
||||
this.pairingResolve = undefined;
|
||||
this.pairingReject = undefined;
|
||||
this.pairingContext = undefined;
|
||||
this.setPairingStatus({ state: 'error', message: error.message });
|
||||
}
|
||||
|
||||
private setPairingStatus(status: BridgePairingStatus): void {
|
||||
this.pairingStatus = status;
|
||||
void browser.runtime.sendMessage({ action: PAIRING_STATUS_EVENT, payload: status }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export const engineBridge = new EngineBridge();
|
||||
Reference in New Issue
Block a user