diff --git a/src/app/service/offscreen/event_page_manager.test.ts b/src/app/service/offscreen/event_page_manager.test.ts index c133baf71..58536cffb 100644 --- a/src/app/service/offscreen/event_page_manager.test.ts +++ b/src/app/service/offscreen/event_page_manager.test.ts @@ -34,6 +34,20 @@ afterEach(() => { } }); +// fast 项目共享模块环境。切换平台时重载了 LoggerCore,也要重建它的单例, +// 否则随后运行的测试无法创建 Server 或发布 MessageQueue 消息。 +const resetPlatformModules = async () => { + vi.resetModules(); + const loggerModule = await import("../../logger/core.js"); + const LoggerCore = loggerModule.default as unknown as typeof LoggerCoreType; + new LoggerCore({ + level: "trace", + consoleLevel: "trace", + writer: new loggerModule.EmptyWriter(), + labels: { env: "test" }, + }); +}; + const loadKeepAliveEnabledManager = async () => { vi.stubGlobal("scheduler", { postTask: vi.fn(), @@ -52,22 +66,13 @@ const loadKeepAliveEnabledManager = async () => { } } ); - vi.resetModules(); - const loggerModule = await import("../../logger/core.js"); - const LoggerCore = loggerModule.default as unknown as typeof LoggerCoreType; - const logger = new LoggerCore({ - level: "trace", - consoleLevel: "trace", - writer: new loggerModule.EmptyWriter(), - labels: { env: "test" }, - }); - logger.logger().debug("test start"); + await resetPlatformModules(); return { ...(await import("./event_page_manager.js")), ...(await import("./keep_alive.js")), probeImages }; }; const loadChromeKeepAlive = async () => { isFirefoxMock.mockReturnValue(false); - vi.resetModules(); + await resetPlatformModules(); return await import("./keep_alive.js"); }; diff --git a/src/app/service/offscreen/external-access-connect.test.ts b/src/app/service/offscreen/external-access-connect.test.ts index 990dc0a72..de956ff5f 100644 --- a/src/app/service/offscreen/external-access-connect.test.ts +++ b/src/app/service/offscreen/external-access-connect.test.ts @@ -18,12 +18,32 @@ import { Server } from "@Packages/message/server"; import EventEmitter from "eventemitter3"; import { createCipheriv, createHmac, hkdfSync, randomBytes } from "node:crypto"; import { CRYPTO, LIMITS } from "../service_worker/external_access/generated/protocol.generated"; +import { ExternalAccessConnectClient } from "./client"; initTestEnv(); -const CTX = CRYPTO.context; +// sctl Go Crypto.ScriptCat{Ext,Daemon}HMAC 生成的固定互操作向量。 +const vector = { + instanceId: "0123456789abcdef0123456789abcdef", + sessionKeyHex: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + nonceD: "aa11111111111111111111111111111111111111111111111111111111111111", + nonceE: "bb22222222222222222222222222222222222222222222222222222222222222", + pairingCode: "ABCD-EFGH", + sessionExtHmac: "044bb6d6f20efdd664e5c86e6c97127e1628784723b119149a93e7d0184ffa5c", + sessionDaemonHmac: "50bfad50a876b61b357ebfd9702feb1717fe7302acb08fbc87c588bf6d2c762d", + pairingExtHmac: "dd6e5d75790912cc8d530ac5cd7fef6d56b026ec48baa24d18a05c1f34a4848e", + pairingDaemonHmac: "f9954343b576e9388ea515635102cf74fb63db503c5a6cf08155ee6aa00fe037", + legacySessionExtHmac: "e522fd7aaa35892f1bbb76081e79946a8f8142d1c83c1dbc8804fe33dae99359", +}; // ──────────────────────────────────────────────── +const CTX = { + sessionExt: CRYPTO.context.scriptcatSessionExt + vector.instanceId, + sessionDaemon: CRYPTO.context.scriptcatSessionDaemon + vector.instanceId, + pairExt: CRYPTO.context.scriptcatPairExt + vector.instanceId, + pairDaemon: CRYPTO.context.scriptcatPairDaemon + vector.instanceId, +}; + // 独立参照实现(node:crypto)——同时充当 daemon 模拟器,钉住线格式与 sctl 互操作 // ──────────────────────────────────────────────── @@ -35,9 +55,9 @@ function nodeHmacHex(keyBytes: Uint8Array, context: string, a: string, b: string function nodePairingKeys(code: string): { mac: Buffer; enc: Buffer } { const ikm = Buffer.from(normalizePairingCode(code), "utf8"); - const salt = Buffer.from(CTX.pairKdfSalt, "utf8"); - const mac = Buffer.from(hkdfSync("sha256", ikm, salt, Buffer.from(CTX.pairKdfInfoMac, "utf8"), 32)); - const enc = Buffer.from(hkdfSync("sha256", ikm, salt, Buffer.from(CTX.pairKdfInfoEnc, "utf8"), 32)); + const salt = Buffer.from(CRYPTO.context.pairKdfSalt, "utf8"); + const mac = Buffer.from(hkdfSync("sha256", ikm, salt, Buffer.from(CRYPTO.context.pairKdfInfoMac, "utf8"), 32)); + const enc = Buffer.from(hkdfSync("sha256", ikm, salt, Buffer.from(CRYPTO.context.pairKdfInfoEnc, "utf8"), 32)); return { mac, enc }; } @@ -92,9 +112,9 @@ class MockWebSocket { this.onmessage?.(new MessageEvent("message", { data: JSON.stringify(data) })); } - simulateClose() { + simulateClose(code = 1000) { this.readyState = MockWebSocket.CLOSED; - this.onclose?.(new CloseEvent("close")); + this.onclose?.(new CloseEvent("close", { code })); } simulateError() { @@ -131,9 +151,10 @@ describe("ExternalAccessConnect", () => { beforeAll(() => vi.setConfig({ testTimeout: 5000 })); let externalAccessConnect: ExternalAccessConnect; + let client: ExternalAccessConnectClient; let relay: { envelope: ReturnType; - paired: ReturnType; + established: ReturnType; disconnected: ReturnType; }; @@ -147,9 +168,10 @@ describe("ExternalAccessConnect", () => { const group = server.group("externalAccessConnect"); externalAccessConnect = new ExternalAccessConnect(group, mockMessage); + client = new ExternalAccessConnectClient(mockMessage); relay = { envelope: vi.fn().mockResolvedValue(undefined), - paired: vi.fn().mockResolvedValue(undefined), + established: vi.fn().mockResolvedValue(undefined), disconnected: vi.fn().mockResolvedValue(undefined), }; (externalAccessConnect as any).relay = relay; @@ -161,16 +183,118 @@ describe("ExternalAccessConnect", () => { vi.useRealTimers(); }); - function triggerConnect(param: ExternalAccessConnectParam): MockWebSocket { - (externalAccessConnect as any).startSession(param); + function triggerConnect(param: Omit): MockWebSocket { + (externalAccessConnect as unknown as { startSession(params: ExternalAccessConnectParam): void }).startSession({ + ...param, + instanceId: vector.instanceId, + }); return wsInstances[wsInstances.length - 1]; } + // SW 经 Group 下发能力声明,与生产路径一致。 + function declareCapabilities() { + return client.send({ jsonrpc: "2.0", id: "cap", method: "$session.capabilities", params: {} }); + } + + // hello 只在认证完成后才转发给 SW(认证前收到会断开),以此作为握手完成的可观察信号。 + async function expectHelloRelayed(ws: MockWebSocket) { + ws.simulateMessage({ jsonrpc: "2.0", method: "$session.hello", params: { daemonVersion: "1" } }); + await vi.waitFor(() => + expect(relay.envelope).toHaveBeenCalledWith(expect.objectContaining({ method: "$session.hello" })) + ); + } + const sessionParam = (key: string): ExternalAccessConnectParam => ({ url: "ws://127.0.0.1:8643", + instanceId: vector.instanceId, auth: { mode: "session", key }, }); + it("打开后认证前的 1008 拒绝停止所有自动重连", async () => { + vi.useFakeTimers(); + const ws = triggerConnect(sessionParam(vector.sessionKeyHex)); + ws.simulateOpen(); + ws.simulateClose(1008); + expect(relay.disconnected).toHaveBeenCalledWith("refused", "session"); + await vi.advanceTimersByTimeAsync(120_000); + expect(wsInstances).toHaveLength(1); + }); + + it.each(["session", "pairing"] as const)("%s 身份握手匹配 Go 向量;名称冲突不提交密钥且不重试", async (mode) => { + vi.spyOn(globalThis.crypto, "getRandomValues").mockImplementation((array) => { + if (!(array instanceof Uint8Array)) throw new Error("Expected nonce bytes"); + array.set(hexToBytes(vector.nonceE)); + return array; + }); + const auth = mode === "session" ? { mode, key: vector.sessionKeyHex } : { mode, code: vector.pairingCode }; + const ws = triggerConnect({ url: "ws://localhost:8643", auth }); + ws.simulateOpen(); + ws.simulateMessage({ + jsonrpc: "2.0", + id: "golden", + method: "$session.authenticate", + params: { nonceD: vector.nonceD }, + }); + await vi.waitFor(() => expect(ws.sentMessages).toHaveLength(1)); + expect(ws.sent(0).result).toEqual({ + mode, + nonceE: vector.nonceE, + hmac: mode === "session" ? vector.sessionExtHmac : vector.pairingExtHmac, + peer: { kind: "scriptcat", instanceId: vector.instanceId }, + }); + expect(ws.sent(0).result.hmac).not.toBe(vector.legacySessionExtHmac); + const key = + mode === "pairing" + ? nodeEncryptKey(nodePairingKeys(vector.pairingCode).enc, Buffer.from(vector.sessionKeyHex, "hex")) + : undefined; + ws.simulateMessage({ + jsonrpc: "2.0", + method: "$session.authenticated", + params: { hmac: mode === "session" ? vector.sessionDaemonHmac : vector.pairingDaemonHmac, key }, + }); + await expectHelloRelayed(ws); + expect(relay.established).not.toHaveBeenCalled(); + await declareCapabilities(); + vi.useFakeTimers(); + ws.simulateMessage({ + jsonrpc: "2.0", + id: "cap", + error: { code: -32000, message: "conflict", data: { code: "CONFLICT" } }, + }); + ws.simulateClose(); + await vi.advanceTimersByTimeAsync(120_000); + expect(relay.disconnected).toHaveBeenCalledWith("name-taken", mode); + expect(relay.established).not.toHaveBeenCalled(); + expect(wsInstances).toHaveLength(1); + }); + + it.each(["session", "pairing"] as const)("%s 拒绝未绑定实例的旧 daemon MAC", async (mode) => { + const auth = mode === "session" ? { mode, key: vector.sessionKeyHex } : { mode, code: vector.pairingCode }; + const ws = triggerConnect({ url: "ws://localhost:8643", auth }); + ws.simulateOpen(); + ws.simulateMessage({ + jsonrpc: "2.0", + id: "legacy", + method: "$session.authenticate", + params: { nonceD: vector.nonceD }, + }); + await vi.waitFor(() => expect(ws.sentMessages).toHaveLength(1)); + const macKey = mode === "session" ? hexToBytes(vector.sessionKeyHex) : nodePairingKeys(vector.pairingCode).mac; + const hmac = nodeHmacHex( + macKey, + mode === "session" ? CRYPTO.context.sessionDaemon : CRYPTO.context.pairDaemon, + ws.sent(0).result.nonceE, + vector.nonceD + ); + const key = + mode === "pairing" + ? nodeEncryptKey(nodePairingKeys(vector.pairingCode).enc, Buffer.from(vector.sessionKeyHex, "hex")) + : undefined; + ws.simulateMessage({ jsonrpc: "2.0", method: "$session.authenticated", params: { hmac, key } }); + await vi.waitFor(() => expect(ws.readyState).toBe(MockWebSocket.CLOSED)); + expect(relay.established).not.toHaveBeenCalled(); + }); + it("按配置原样连接 WebSocket URL,不限定回环地址或 ws 协议", () => { const url = "wss://bridge.example.com/external-access?profile=team"; const ws = triggerConnect({ url, auth: { mode: "session", key: "aa".repeat(32) } }); @@ -238,7 +362,6 @@ describe("ExternalAccessConnect", () => { const resp = ws.sent(0); expect(resp).toMatchObject({ jsonrpc: "2.0", id: "c1" }); expect(resp.result.mode).toBe("session"); - // 扩展侧 HMAC 用 sessionExt || nonceD || nonceE,参照实现逐字节复核 expect(resp.result.hmac).toBe(nodeHmacHex(new Uint8Array(K), CTX.sessionExt, nonceD, resp.result.nonceE)); const okHmac = nodeHmacHex(new Uint8Array(K), CTX.sessionDaemon, resp.result.nonceE, nonceD); @@ -297,7 +420,7 @@ describe("ExternalAccessConnect", () => { // ──────────────────────────────────────────────── describe("配对握手(首次配对)", () => { - it("校验 daemon、解密新长期密钥 K 并上抛 paired,随后切换为会话模式", async () => { + it("校验 daemon、解密新长期密钥 K,能力声明接受后上抛 established 并切换为会话模式", async () => { const code = "MNBV-3456"; const { mac, enc } = nodePairingKeys(code); const K = randomBytes(32); @@ -310,7 +433,6 @@ describe("ExternalAccessConnect", () => { await vi.waitFor(() => expect(ws.sentMessages.length).toBe(1)); const resp = ws.sent(0); expect(resp.result.mode).toBe("pairing"); - // 配对模式用 pairExt 上下文 + Kp_mac;认证响应不含配对码本身 expect(resp.result.hmac).toBe(nodeHmacHex(new Uint8Array(mac), CTX.pairExt, nonceD, resp.result.nonceE)); expect(resp.result.code).toBeUndefined(); @@ -318,14 +440,34 @@ describe("ExternalAccessConnect", () => { const key = nodeEncryptKey(enc, K); ws.simulateMessage({ jsonrpc: "2.0", method: "$session.authenticated", params: { hmac: okHmac, key } }); - await vi.waitFor(() => expect(relay.paired).toHaveBeenCalledTimes(1)); - expect(relay.paired.mock.calls[0][0]).toBe(K.toString("hex")); - expect((externalAccessConnect as any).handshakeComplete).toBe(true); - // 握手后内部凭据切到会话模式,供后续自动重连 - expect((externalAccessConnect as any).currentParams.auth).toEqual({ mode: "session", key: K.toString("hex") }); + await expectHelloRelayed(ws); + await declareCapabilities(); + ws.simulateMessage({ jsonrpc: "2.0", id: "cap", result: {} }); + await vi.waitFor(() => expect(relay.established).toHaveBeenCalledWith(K.toString("hex"))); + + // 之后的自动重连用会话模式与新密钥 K,而不是已被消费的配对码。 + vi.useFakeTimers(); + ws.simulateClose(); + await vi.advanceTimersByTimeAsync(2000); + vi.useRealTimers(); + const next = wsInstances[1]; + next.simulateOpen(); + const nextNonceD = randomBytes(32).toString("hex"); + next.simulateMessage({ + jsonrpc: "2.0", + id: "c2", + method: "$session.authenticate", + params: { nonceD: nextNonceD }, + }); + await vi.waitFor(() => expect(next.sentMessages.length).toBe(1)); + const nextResp = next.sent(0); + expect(nextResp.result.mode).toBe("session"); + expect(nextResp.result.hmac).toBe( + nodeHmacHex(new Uint8Array(K), CTX.sessionExt, nextNonceD, nextResp.result.nonceE) + ); }); - it("配对 $session.authenticated 缺少 key 时断开、不上抛 paired", async () => { + it("配对 $session.authenticated 缺少 key 时断开、不上抛 established", async () => { const code = "MNBV-3456"; const { mac } = nodePairingKeys(code); const ws = triggerConnect({ url: "ws://127.0.0.1:8643", auth: { mode: "pairing", code } }); @@ -339,15 +481,16 @@ describe("ExternalAccessConnect", () => { const closeSpy = vi.spyOn(ws, "close"); ws.simulateMessage({ jsonrpc: "2.0", method: "$session.authenticated", params: { hmac: okHmac } }); await vi.waitFor(() => expect(closeSpy).toHaveBeenCalled()); - expect(relay.paired).not.toHaveBeenCalled(); + expect(relay.established).not.toHaveBeenCalled(); }); }); // ──────────────────────────────────────────────── describe("握手后信封路由与心跳", () => { - async function completeSessionHandshake(): Promise { - const K = randomBytes(32); - const ws = triggerConnect(sessionParam(K.toString("hex"))); + async function completeSessionHandshake( + K = randomBytes(32), + ws = triggerConnect(sessionParam(K.toString("hex"))) + ): Promise { ws.simulateOpen(); const nonceD = randomBytes(32).toString("hex"); ws.simulateMessage({ jsonrpc: "2.0", id: "c1", method: "$session.authenticate", params: { nonceD } }); @@ -359,6 +502,38 @@ describe("ExternalAccessConnect", () => { return ws; } + it("能力声明前不重置退避,接受后普通关闭从 2 秒重试", async () => { + vi.useFakeTimers(); + const key = randomBytes(32); + triggerConnect(sessionParam(key.toString("hex"))).simulateClose(); + await vi.advanceTimersByTimeAsync(2000); + const beforeCapabilities = await completeSessionHandshake(key, wsInstances[1]); + beforeCapabilities.simulateClose(); + await vi.advanceTimersByTimeAsync(3999); + expect(wsInstances).toHaveLength(2); + await vi.advanceTimersByTimeAsync(1); + const ws = await completeSessionHandshake(key, wsInstances[2]); + await declareCapabilities(); + ws.simulateMessage({ jsonrpc: "2.0", id: "cap", result: {} }); + await vi.advanceTimersByTimeAsync(0); + expect(relay.established).toHaveBeenCalledWith(undefined); + ws.simulateClose(1008); + await vi.advanceTimersByTimeAsync(1999); + expect(wsInstances).toHaveLength(3); + await vi.advanceTimersByTimeAsync(1); + expect(wsInstances).toHaveLength(4); + expect(relay.disconnected).toHaveBeenCalledWith(undefined, "session"); + }); + + it("认证后能力声明迟迟不答复也会超时关闭", async () => { + vi.useFakeTimers(); + const ws = await completeSessionHandshake(); + await declareCapabilities(); + await vi.advanceTimersByTimeAsync(LIMITS.authTimeoutMs + 1); + expect(ws.readyState).toBe(MockWebSocket.CLOSED); + expect(relay.established).not.toHaveBeenCalled(); + }); + it("业务请求和 $/cancelRequest 转发给 SW", async () => { const ws = await completeSessionHandshake(); ws.simulateMessage({ diff --git a/src/app/service/offscreen/external-access-connect.ts b/src/app/service/offscreen/external-access-connect.ts index 8203abc17..e4d950ee3 100644 --- a/src/app/service/offscreen/external-access-connect.ts +++ b/src/app/service/offscreen/external-access-connect.ts @@ -10,6 +10,7 @@ import type { AuthMode, AuthOkPayload, AuthResponsePayload, + ExternalAccessRejectionReason, WSEnvelope, } from "../service_worker/external_access/types"; @@ -41,6 +42,7 @@ export type ExternalAccessAuth = { mode: "session"; key: string } | { mode: "pai export interface ExternalAccessConnectParam { url: string; auth: ExternalAccessAuth; + instanceId: string; } // --------------------------------------------------------------------------------------------- @@ -105,7 +107,6 @@ export function normalizePairingCode(code: string): string { .replace(/[IL]/g, "1"); } -// HMAC-SHA-256(key, utf8(context || firstHex || secondHex)) → 小写 hex。 export async function computeHandshakeHmac( keyBytes: Bytes, context: string, @@ -150,18 +151,20 @@ export async function decryptLongTermKey(encKeyBytes: Bytes, ivB64: string, ciph // 收到 $session.authenticate 后,按当前模式计算响应,并带出握手参数以验证 $session.authenticated。 async function buildAuthResponse( auth: ExternalAccessAuth, + instanceId: string, nonceD: string ): Promise<{ payload: AuthResponsePayload; verifyKey: Bytes; nonceE: string; enc?: Bytes }> { const nonceE = randomNonceHex(); const mode: AuthMode = auth.mode; + const peer: AuthResponsePayload["peer"] = { kind: "scriptcat", instanceId }; if (auth.mode === "session") { const keyBytes = hexToBytes(auth.key); - const hmac = await computeHandshakeHmac(keyBytes, CRYPTO.context.sessionExt, nonceD, nonceE); - return { payload: { mode, nonceE, hmac }, verifyKey: keyBytes, nonceE }; + const hmac = await computeHandshakeHmac(keyBytes, CRYPTO.context.scriptcatSessionExt + instanceId, nonceD, nonceE); + return { payload: { mode, nonceE, hmac, peer }, verifyKey: keyBytes, nonceE }; } const { mac, enc } = await derivePairingKeys(auth.code); - const hmac = await computeHandshakeHmac(mac, CRYPTO.context.pairExt, nonceD, nonceE); - return { payload: { mode, nonceE, hmac }, verifyKey: mac, nonceE, enc }; + const hmac = await computeHandshakeHmac(mac, CRYPTO.context.scriptcatPairExt + instanceId, nonceD, nonceE); + return { payload: { mode, nonceE, hmac, peer }, verifyKey: mac, nonceE, enc }; } export class ExternalAccessConnect { @@ -172,6 +175,10 @@ export class ExternalAccessConnect { private epoch = 0; private currentParams: ExternalAccessConnectParam | null = null; private handshakeComplete = false; + private opened = false; + private capabilitiesId?: string; + private pairedKey?: string; + private conflict = false; // 已发出认证响应、等待 $session.authenticated 校验期间保留的握手素材。 private pendingVerify: { verifyKey: Bytes; nonceD: string; nonceE: string; mode: AuthMode; enc?: Bytes } | null = null; @@ -205,7 +212,6 @@ export class ExternalAccessConnect { private startSession(params: ExternalAccessConnectParam): void { this.dispose(); this.currentParams = params; - this.epoch++; this.connect(this.epoch); } @@ -224,22 +230,39 @@ export class ExternalAccessConnect { }, CONFIG.CONNECT_TIMEOUT); this.ws.onopen = () => this.handleOpen(sessionEpoch); - this.ws.onmessage = (ev) => void this.handleMessage(ev, sessionEpoch); - this.ws.onclose = () => this.handleClose(sessionEpoch); + // daemon 可连续发 authenticated 与 hello。按线上的顺序完成异步密码校验,再处理后续帧和关闭事件。 + let frames: Promise | undefined; + this.ws.onmessage = (ev) => { + const processing = (frames ?? Promise.resolve()) + .then(() => this.handleMessage(ev, sessionEpoch)) + .catch((error: unknown) => { + this.logger.warn("Failed to process WebSocket frame", Logger.E(error)); + if (sessionEpoch === this.epoch) this.ws?.close(); + }); + frames = processing; + void processing.then(() => { + if (frames === processing) frames = undefined; + }); + }; + this.ws.onclose = ({ code }) => { + if (frames) void frames.then(() => this.handleClose(sessionEpoch, code)); + else this.handleClose(sessionEpoch, code); + }; this.ws.onerror = (ev) => this.handleError(ev, sessionEpoch); } catch (e) { this.logger.error("WebSocket creation failed", Logger.E(e)); - this.handleError(e, sessionEpoch); + this.handleClose(sessionEpoch, 1006); } } private handleOpen(sessionEpoch: number): void { if (sessionEpoch !== this.epoch) return; + this.opened = true; this.logger.info("WebSocket connected, awaiting $session.authenticate"); this.clearTimer("connectTimeoutTimer"); - // 握手必须在 authTimeoutMs 内完成,否则断开重连(PROTOCOL §3)。 + // 认证与能力声明必须在 authTimeoutMs 内完成,否则接入/改名调用可能永不返回。 this.authTimeoutTimer = setTimeout(() => { - if (sessionEpoch === this.epoch && !this.handshakeComplete) { + if (sessionEpoch === this.epoch) { this.logger.warn("Auth handshake timeout"); this.ws?.close(); } @@ -264,6 +287,22 @@ export class ExternalAccessConnect { return; } + if (!envelope.method && envelope.id && envelope.id === this.capabilitiesId) { + this.capabilitiesId = undefined; + if (envelope.error) { + this.conflict = envelope.error.data?.code === "CONFLICT"; + this.ws?.close(); + } else { + const key = this.pairedKey; + if (key && this.currentParams) this.currentParams = { ...this.currentParams, auth: { mode: "session", key } }; + this.pairedKey = undefined; + this.reconnectDelay = CONFIG.BASE_RECONNECT_DELAY; + this.clearTimer("authTimeoutTimer"); + await this.relay.established(key); + } + return; + } + switch (envelope.method) { case "$session.ping": if (envelope.id) this.rawSend({ jsonrpc: "2.0", id: envelope.id, result: {} }); @@ -281,12 +320,12 @@ export class ExternalAccessConnect { } private async handleHandshakeMessage(envelope: WSEnvelope, sessionEpoch: number): Promise { - const auth = this.currentParams?.auth; - if (!auth) return; + if (!this.currentParams) return; + const { auth, instanceId } = this.currentParams; if (envelope.method === "$session.authenticate" && envelope.id) { const { nonceD } = envelope.params as AuthChallengePayload; - const built = await buildAuthResponse(auth, nonceD); + const built = await buildAuthResponse(auth, instanceId, nonceD); if (sessionEpoch !== this.epoch) return; // 保存验证素材,供随后的 $session.authenticated 校验使用。 this.pendingVerify = { @@ -311,12 +350,18 @@ export class ExternalAccessConnect { private async verifyAuthOk(payload: AuthOkPayload, sessionEpoch: number): Promise { const pending = this.pendingVerify; - if (!pending) { + if (!pending || !this.currentParams) { this.ws?.close(); return; } - const context = pending.mode === "session" ? CRYPTO.context.sessionDaemon : CRYPTO.context.pairDaemon; - const expected = await computeHandshakeHmac(pending.verifyKey, context, pending.nonceE, pending.nonceD); + const context = + pending.mode === "session" ? CRYPTO.context.scriptcatSessionDaemon : CRYPTO.context.scriptcatPairDaemon; + const expected = await computeHandshakeHmac( + pending.verifyKey, + context + this.currentParams.instanceId, + pending.nonceE, + pending.nonceD + ); if (sessionEpoch !== this.epoch) return; if (!constantTimeEqualHex(expected, payload.hmac || "")) { this.logger.warn("$session.authenticated HMAC mismatch, closing"); @@ -324,7 +369,7 @@ export class ExternalAccessConnect { return; } - // 配对模式:解密 daemon 下发的长期密钥 K,持久化交给 SW,并把自身切到会话模式供后续重连。 + // daemon 在接受名称之前已消费配对码并下发密钥;名称冲突时这把密钥不能落盘。 if (pending.mode === "pairing") { if (!pending.enc || !payload.key) { this.logger.warn("pairing $session.authenticated missing key, closing"); @@ -333,14 +378,11 @@ export class ExternalAccessConnect { } const key = await decryptLongTermKey(pending.enc, payload.key.iv, payload.key.ciphertext); if (sessionEpoch !== this.epoch) return; - if (this.currentParams) this.currentParams = { ...this.currentParams, auth: { mode: "session", key } }; - void this.relay.paired(key); + this.pairedKey = key; } this.pendingVerify = null; this.handshakeComplete = true; - this.clearTimer("authTimeoutTimer"); - this.reconnectDelay = CONFIG.BASE_RECONNECT_DELAY; this.logger.info("Auth handshake complete"); } @@ -351,6 +393,7 @@ export class ExternalAccessConnect { this.logger.warn("Dropped outbound JSON-RPC message before handshake", { method: envelope.method }); return; } + if (envelope.method === "$session.capabilities") this.capabilitiesId = envelope.id; this.rawSend(envelope); } @@ -360,16 +403,19 @@ export class ExternalAccessConnect { } } - private handleClose(sessionEpoch: number): void { - if (sessionEpoch !== this.epoch) return; - this.clearTimer("connectTimeoutTimer"); - this.clearTimer("authTimeoutTimer"); + private handleClose(sessionEpoch: number, code: number): void { + if (sessionEpoch !== this.epoch || !this.currentParams) return; + const reason: ExternalAccessRejectionReason | undefined = this.conflict + ? "name-taken" + : this.opened && !this.handshakeComplete && code === 1008 + ? "refused" + : undefined; + const mode = this.currentParams.auth.mode; this.ws = null; - this.handshakeComplete = false; - this.pendingVerify = null; - this.logger.debug("WebSocket connection closed"); - void this.relay.disconnected(); - this.scheduleReconnect(); + this.dispose(); + if (reason) this.currentParams = null; + void this.relay.disconnected(reason, mode); + if (!reason) this.scheduleReconnect(); } private handleError(ev: Event | Error | unknown, sessionEpoch: number): void { @@ -378,7 +424,7 @@ export class ExternalAccessConnect { event: ev instanceof Event ? ev.type : undefined, error: ev instanceof Error ? ev.message : String(ev), }); - this.scheduleReconnect(); + // 浏览器会紧接着发 close,只有 close 才能区分网络错误与 1008 拒绝。 } private scheduleReconnect(): void { @@ -402,6 +448,11 @@ export class ExternalAccessConnect { } private dispose(): void { + this.epoch++; + this.opened = false; + this.capabilitiesId = undefined; + this.pairedKey = undefined; + this.conflict = false; this.clearTimer("reconnectTimer"); this.clearTimer("connectTimeoutTimer"); this.clearTimer("authTimeoutTimer"); diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index 0f6a0aa66..27ca0a303 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -47,7 +47,7 @@ import { encodeRValue, type TKeyValuePair } from "@App/pkg/utils/message_value"; import { type TSetValuesParams } from "./value"; import type { LocalBackupExport } from "./synchronize"; import type { ExternalAccessUIService } from "./external_access/service"; -import type { WSEnvelope } from "./external_access/types"; +import type { AuthMode, ExternalAccessRejectionReason, WSEnvelope } from "./external_access/types"; import type { NetworkRuleMutationResult, NetworkRuleCreateInput, @@ -662,13 +662,17 @@ export class ExternalAccessClient extends Client { super(msgSender, "serviceWorker/externalAccess"); } - getBridgeStatus(): Promise> { + getBridgeStatus(): ReturnType { return this.doThrow("status"); } // Enrollment (接入): dial the daemon with the one-time code the user read from `sctl connect`. - enroll(code: string) { - return this.do("enroll", code); + enroll(code: string, name: string): ReturnType { + return this.doThrow("enroll", { code, name }); + } + + rename(name: string): ReturnType { + return this.doThrow("rename", name); } getOperation(operationId: string): ReturnType { @@ -714,11 +718,11 @@ export class ExternalAccessConnectRelayClient extends Client { return this.do("envelope", envelope); } - paired(key: string): Promise { - return this.do("paired", { key }); + established(key?: string): Promise { + return this.do("established", { key }); } - disconnected(): Promise { - return this.do("disconnected"); + disconnected(reason: ExternalAccessRejectionReason | undefined, mode: AuthMode): Promise { + return this.do("disconnected", { reason, mode }); } } diff --git a/src/app/service/service_worker/external_access/controller.test.ts b/src/app/service/service_worker/external_access/controller.test.ts index ca22731ad..39979bbde 100644 --- a/src/app/service/service_worker/external_access/controller.test.ts +++ b/src/app/service/service_worker/external_access/controller.test.ts @@ -3,6 +3,8 @@ import { ExternalAccessController } from "./controller"; import { SystemConfig } from "@App/pkg/config/config"; import { MessageQueue } from "@Packages/message/message_queue"; import type { WSEnvelope } from "./types"; +import { mockWebLocks } from "@Tests/mockWebLocks"; +import * as browserUtils from "@App/pkg/utils/utils"; // Captures the relay handlers ExternalAccessController registers on its Group so tests can feed decoded // envelopes the way the offscreen ExternalAccessConnect would over the wire. @@ -11,8 +13,9 @@ function makeFakeGroup() { return { group: { on: (name: string, fn: (params: any) => any) => (handlers[name] = fn) } as any, relayEnvelope: (env: WSEnvelope) => handlers["envelope"](env), - relayPaired: (key: string) => handlers["paired"]({ key }), - relayDisconnected: () => handlers["disconnected"](undefined), + established: (key?: string) => handlers["established"]({ key }), + relayDisconnected: (reason?: "refused" | "name-taken", mode = "session") => + handlers["disconnected"]({ reason, mode }), }; } @@ -35,6 +38,8 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { let fake: ReturnType; beforeEach(() => { + mockWebLocks(); + vi.spyOn(chrome.runtime, "getManifest").mockReturnValue({ ...chrome.runtime.getManifest(), version: "1.5.0" }); chrome.storage.local.clear(); chrome.storage.sync.clear(); chrome.storage.session.clear(); @@ -63,8 +68,8 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { ); } - async function initEnrolled(key = "deadbeef", clientId = "c-existing") { - systemConfig.setExternalAccessPairing({ key, clientId }); + async function initEnrolled(key = "deadbeef") { + systemConfig.setExternalAccessPairing({ key }); const controller = makeController(); await controller.initialize(); return controller; @@ -74,22 +79,26 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { const controller = makeController(); await controller.initialize(); expect(connectClient.connect).not.toHaveBeenCalled(); - expect(controller.getStatus().status).toBe("disabled"); + expect((await controller.getStatus()).status).toBe("disabled"); }); it("已启用但未接入时不拨号,状态为 pending_enrollment", async () => { const controller = makeController(); await controller.initialize(); systemConfig.setExternalAccessEnabled(true); - await vi.waitFor(() => expect(controller.getStatus().status).toBe("pending_enrollment")); + await vi.waitFor(async () => expect((await controller.getStatus()).status).toBe("pending_enrollment")); expect(connectClient.connect).not.toHaveBeenCalled(); }); it("已接入且 external_access_enabled 变为 true 时以会话模式拨号,携带 URL 与长期密钥", async () => { - await initEnrolled("abc123", "cid"); + await initEnrolled("abc123"); systemConfig.setExternalAccessEnabled(true); await vi.waitFor(() => expect(connectClient.connect).toHaveBeenCalledTimes(1)); - expect(connectClient.connect).toHaveBeenCalledWith({ url: DEFAULT_URL, auth: { mode: "session", key: "abc123" } }); + expect(connectClient.connect).toHaveBeenCalledWith({ + url: DEFAULT_URL, + auth: { mode: "session", key: "abc123" }, + instanceId: (await systemConfig.getExternalAccessIdentity()).instanceId, + }); }); describe("隐身(split)进程", () => { @@ -106,54 +115,263 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { it("已启用且已接入时启动也不拨号,状态保持 disabled", async () => { systemConfig.setExternalAccessEnabled(true); - const controller = await initEnrolled("abc123", "cid"); + const controller = await initEnrolled("abc123"); expect(connectClient.connect).not.toHaveBeenCalled(); - expect(controller.getStatus().status).toBe("disabled"); + expect((await controller.getStatus()).status).toBe("disabled"); }); it("enroll(code) 不以配对模式拨号", async () => { const controller = makeController(); await controller.initialize(); - await controller.enroll("PAIR-CODE"); + await controller.enroll("PAIR-CODE", "private"); expect(connectClient.connect).not.toHaveBeenCalled(); - expect(controller.getStatus().status).toBe("disabled"); + expect((await controller.getStatus()).status).toBe("disabled"); }); }); + // scriptcat 是 sctl 为旧版 ScriptCat 保留的名称,daemon 必然拒绝;接入时发出去会白白消耗一次性配对码。 + it.each(["", "Bad", " ", "a".repeat(33), "a_b", "工作", "scriptcat"])( + "无效名称 %j 不拨号、不改名", + async (invalid) => { + const controller = makeController(); + await controller.initialize(); + const { name } = await systemConfig.getExternalAccessIdentity(); + expect(await controller.enroll("code", invalid)).toBe("invalid-name"); + expect(await controller.rename(invalid)).toBe("invalid-name"); + expect(connectClient.connect).not.toHaveBeenCalled(); + expect((await controller.getStatus()).name).toBe(name); + expect((await systemConfig.getExternalAccessIdentity()).name).toBe(name); + } + ); + + it("未连接时改名保存到下一次连接", async () => { + const controller = makeController(); + await controller.initialize(); + const identity = await systemConfig.getExternalAccessIdentity(); + expect(await controller.rename("work")).toBe("applies-next-connection"); + expect(await systemConfig.getExternalAccessIdentity()).toEqual({ ...identity, name: "work" }); + expect((await controller.getStatus()).name).toBe("work"); + expect(connectClient.connect).not.toHaveBeenCalled(); + systemConfig.setExternalAccessPairing({ key: "test-key" }); + systemConfig.setExternalAccessEnabled(true); + await vi.waitFor(() => expect(connectClient.connect).toHaveBeenCalledTimes(1)); + await fake.relayEnvelope({ jsonrpc: "2.0", method: "$session.hello", params: { daemonVersion: "1" } }); + expect(connectClient.send.mock.lastCall?.[0].params.peer.name).toBe("work"); + }); + it("enroll(code) 以配对模式拨号", async () => { const controller = makeController(); await controller.initialize(); - await controller.enroll("PAIR-CODE"); + const result = controller.enroll("PAIR-CODE", "work"); + await vi.waitFor(() => expect(connectClient.connect).toHaveBeenCalled()); expect(connectClient.connect).toHaveBeenCalledWith({ url: DEFAULT_URL, auth: { mode: "pairing", code: "PAIR-CODE" }, + instanceId: (await systemConfig.getExternalAccessIdentity()).instanceId, }); - expect(controller.getStatus().status).toBe("connecting"); + expect(await controller.getStatus()).toMatchObject({ status: "connecting", name: "work" }); + await fake.established("newkey"); + expect(await result).toBe("ok"); + expect(await systemConfig.getExternalAccessPairing()).toEqual({ key: "newkey" }); + }); + + it.each([ + ["Chrome", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/129.0.0.0 Safari/537.36", "129"], + ["Edge", "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0", "129"], + ["Firefox", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:130.0) Gecko/20100101 Firefox/130.0", "130.0"], + ["Safari", "Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15", "17.4"], + [undefined, "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/129.0.0.0 Safari/537.36", undefined], + ])("能力声明包含名称与 %s 产品信息及浏览器版本", async (product, userAgent, productVersion) => { + vi.spyOn(browserUtils, "getBrowserType").mockReturnValue({ + firefox: Number(product === "Firefox"), + webkit: Number(product === "Safari"), + chrome: product === "Edge" ? browserUtils.BrowserType.Edge : Number(product === "Chrome"), + unknown: Number(!product), + chromeVersion: 0, + device: 0, + }); + vi.spyOn(navigator, "userAgent", "get").mockReturnValue(userAgent); + await initEnrolled(); + await fake.relayEnvelope({ jsonrpc: "2.0", method: "$session.hello", params: { daemonVersion: "1" } }); + const identity = await systemConfig.getExternalAccessIdentity(); + expect(connectClient.send).toHaveBeenCalledWith( + expect.objectContaining({ + method: "$session.capabilities", + params: expect.objectContaining({ + schemaVersion: expect.any(String), + methods: expect.any(Array), + peer: { + name: identity.name, + ...(product ? { product, productVersion } : {}), + extensionVersion: chrome.runtime.getManifest().version, + }, + }), + }) + ); + }); + + it.each([64, 65])("能力声明自报的浏览器版本截断到 64 字节(UA 中版本为 %i 字节)", async (length) => { + vi.spyOn(browserUtils, "getBrowserType").mockReturnValue({ + firefox: 1, + webkit: 0, + chrome: 0, + unknown: 0, + chromeVersion: 0, + device: 0, + }); + const version = "1." + "0".repeat(length - 2); + vi.spyOn(navigator, "userAgent", "get").mockReturnValue( + `Mozilla/5.0 (X11; rv:130.0) Gecko/20100101 Firefox/${version}` + ); + await initEnrolled(); + await fake.relayEnvelope({ jsonrpc: "2.0", method: "$session.hello", params: { daemonVersion: "1" } }); + expect(connectClient.send.mock.calls[0][0].params.peer.productVersion).toBe(version.slice(0, 64)); + }); + + it("被拒后重新接入时名称冲突要求新配对码,并保留原密钥", async () => { + systemConfig.setExternalAccessEnabled(true); + const controller = await initEnrolled(); + const publish = vi.spyOn(mq, "publish"); + await fake.relayDisconnected("refused"); + const rejected = await controller.getStatus(); + expect(rejected).toMatchObject({ status: "rejected", reason: "refused" }); + await vi.waitFor(() => expect(publish).toHaveBeenCalledWith("mcpStatusChanged", rejected)); + const result = controller.enroll("CODE", "work"); + await vi.waitFor(() => expect(connectClient.connect).toHaveBeenCalledTimes(2)); + expect((await controller.getStatus()).status).toBe("connecting"); + await fake.relayDisconnected("name-taken", "pairing"); + expect(await result).toBe("name-taken"); + expect((await controller.getStatus()).status).toBe("pending_enrollment"); + expect(await systemConfig.getExternalAccessPairing()).toEqual({ key: "deadbeef" }); + const restarted = makeController(); + await restarted.initialize(); + expect((await restarted.getStatus()).status).toBe("pending_enrollment"); + expect(connectClient.connect).toHaveBeenCalledTimes(2); + }); + + it("接入时名称冲突恢复接入前的实例名称", async () => { + systemConfig.setExternalAccessEnabled(true); + const controller = await initEnrolled(); + await fake.relayDisconnected("refused"); + const before = (await systemConfig.getExternalAccessIdentity()).name; + const result = controller.enroll("CODE", "work"); + await vi.waitFor(() => expect(connectClient.connect).toHaveBeenCalledTimes(2)); + await fake.relayDisconnected("name-taken", "pairing"); + expect(await result).toBe("name-taken"); + expect((await systemConfig.getExternalAccessIdentity()).name).toBe(before); + expect((await controller.getStatus()).name).toBe(before); + }); + + it("SW 在配对握手期间重启,配对完成时仍保存新密钥并转为已连接", async () => { + systemConfig.setExternalAccessEnabled(true); + const controller = makeController(); + await controller.initialize(); + void controller.enroll("PAIR-CODE", "work"); + await vi.waitFor(() => expect(connectClient.connect).toHaveBeenCalledTimes(1)); + // 重启丢掉 SW 内存中的控制器,offscreen 里的配对连接仍在继续。 + const restarted = makeController(); + await restarted.initialize(); + expect((await restarted.getStatus()).status).toBe("pending_enrollment"); + await fake.established("newkey"); + expect(await systemConfig.getExternalAccessPairing()).toEqual({ key: "newkey" }); + expect(await restarted.getStatus()).toMatchObject({ status: "connected", name: "work" }); }); - it("接入成功(paired)时持久化长期密钥 K", async () => { + it("停止接入后迟到的 established 不保存密钥", async () => { + systemConfig.setExternalAccessEnabled(true); const controller = makeController(); await controller.initialize(); - fake.relayPaired("newkey"); - await vi.waitFor(async () => expect((await systemConfig.getExternalAccessPairing()).key).toBe("newkey")); + void controller.enroll("PAIR-CODE", "work"); + await vi.waitFor(() => expect(connectClient.connect).toHaveBeenCalledTimes(1)); + controller.stop(); + await fake.established("newkey"); + expect(await systemConfig.getExternalAccessPairing()).toEqual({ key: "" }); + expect((await controller.getStatus()).status).toBe("disabled"); + }); + + it("停止接入优先于尚未落盘的拒绝通知", async () => { + systemConfig.setExternalAccessEnabled(true); + const controller = await initEnrolled(); + const disconnected = fake.relayDisconnected("refused"); + controller.stop(); + await disconnected; + expect((await controller.getStatus()).status).toBe("disabled"); + }); + + it("SW 重新启动不会自动重试已拒绝的实例", async () => { + systemConfig.setExternalAccessEnabled(true); + await initEnrolled(); + await fake.relayDisconnected("refused"); + const restarted = makeController(); + await restarted.initialize(); + expect(await restarted.getStatus()).toMatchObject({ status: "rejected", reason: "refused" }); + expect(connectClient.connect).toHaveBeenCalledTimes(1); + }); + + it.each(["toggle", "rename"])("被拒后 %s 重新拨号", async (action) => { + systemConfig.setExternalAccessEnabled(true); + const controller = await initEnrolled(); + await fake.relayDisconnected("name-taken"); + expect(await controller.getStatus()).toMatchObject({ status: "rejected", reason: "name-taken" }); + if (action === "toggle") { + systemConfig.setExternalAccessEnabled(false); + await vi.waitFor(async () => expect((await controller.getStatus()).status).toBe("disabled")); + systemConfig.setExternalAccessEnabled(true); + } else expect(await controller.rename("work")).toBe("applies-next-connection"); + await vi.waitFor(() => expect(connectClient.connect).toHaveBeenCalledTimes(2)); + expect((await controller.getStatus()).status).toBe("connecting"); + }); + + it.each([false, true])("连接时改名以能力声明确认结果,冲突=%s", async (conflict) => { + systemConfig.setExternalAccessEnabled(true); + const controller = await initEnrolled(); + await fake.established(); + const old = (await controller.getStatus()).name; + const result = controller.rename("work"); + await vi.waitFor(() => expect(connectClient.connect).toHaveBeenCalledTimes(2)); + const hello = { jsonrpc: "2.0", method: "$session.hello", params: { daemonVersion: "1" } } as const; + await fake.relayEnvelope(hello); + expect(connectClient.send.mock.lastCall?.[0].params.peer.name).toBe("work"); + expect((await systemConfig.getExternalAccessIdentity()).name).toBe(old); + if (conflict) { + await fake.relayDisconnected("name-taken"); + await vi.waitFor(() => expect(connectClient.connect).toHaveBeenCalledTimes(3)); + await fake.relayEnvelope(hello); + expect(connectClient.send.mock.lastCall?.[0].params.peer.name).toBe(old); + } + await fake.established(); + expect(await result).toBe(conflict ? "name-taken" : "ok"); + expect(await controller.getStatus()).toMatchObject({ status: "connected", name: conflict ? old : "work" }); + expect((await systemConfig.getExternalAccessIdentity()).name).toBe(conflict ? old : "work"); }); it("hello 只展示 daemon 版本,不按产品版本阻断兼容的 bridge 调用", async () => { + systemConfig.setExternalAccessEnabled(true); const controller = await initEnrolled(); - fake.relayEnvelope({ + await fake.relayEnvelope({ jsonrpc: "2.0", method: "$session.hello", params: { daemonVersion: "0.1.0" }, }); // hello 携带 daemonVersion,状态条据此显示「sctl v{daemonVersion}」。 - expect(controller.getStatus()).toEqual({ status: "connected", daemonVersion: "0.1.0" }); + await fake.established(); + expect(await controller.getStatus()).toEqual({ + status: "connected", + daemonVersion: "0.1.0", + name: (await systemConfig.getExternalAccessIdentity()).name, + }); - fake.relayEnvelope({ + await fake.relayEnvelope({ jsonrpc: "2.0", method: "$session.hello", params: { daemonVersion: "0.0.1" }, }); - expect(controller.getStatus()).toEqual({ status: "connected", daemonVersion: "0.0.1" }); + await fake.established(); + expect(await controller.getStatus()).toEqual({ + status: "connected", + daemonVersion: "0.0.1", + name: (await systemConfig.getExternalAccessIdentity()).name, + }); fake.relayEnvelope({ jsonrpc: "2.0", id: "req", @@ -165,7 +383,7 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { it("JSON-RPC request uses id to correlate the response", async () => { await initEnrolled(); - fake.relayEnvelope({ + await fake.relayEnvelope({ jsonrpc: "2.0", method: "$session.hello", params: { daemonVersion: "0.1.0" }, @@ -188,7 +406,7 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { it("a suspended JSON-RPC request does not respond before the decision", async () => { bridgeHandle.mockResolvedValue(null); await initEnrolled(); - fake.relayEnvelope({ + await fake.relayEnvelope({ jsonrpc: "2.0", method: "$session.hello", params: { daemonVersion: "0.1.0" }, @@ -210,16 +428,22 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { }); it("socket 断开时状态转为 host_unreachable 并清除 sctl 版本", async () => { + systemConfig.setExternalAccessEnabled(true); const controller = await initEnrolled(); - fake.relayEnvelope({ + await fake.relayEnvelope({ jsonrpc: "2.0", method: "$session.hello", params: { daemonVersion: "0.1.0" }, }); - fake.relayDisconnected(); + await fake.established(); + await fake.relayDisconnected(); expect(bridgeCancel).toHaveBeenCalledWith(); - // 断开后不再连接,之前 hello 报告的版本不应残留。 - expect(controller.getStatus()).toEqual({ status: "host_unreachable", daemonVersion: undefined }); + // 重试期间之前 hello 报告的版本不应残留。 + expect(await controller.getStatus()).toEqual({ + status: "host_unreachable", + daemonVersion: undefined, + name: (await systemConfig.getExternalAccessIdentity()).name, + }); }); it("stop() 发送 shutdown、断开并置为 disabled", async () => { @@ -230,6 +454,6 @@ describe("ExternalAccessController(外部接入 · 扁平信任)", () => { expect.objectContaining({ jsonrpc: "2.0", method: "$session.shutdown" }) ); expect(connectClient.disconnect).toHaveBeenCalled(); - expect(controller.getStatus().status).toBe("disabled"); + expect((await controller.getStatus()).status).toBe("disabled"); }); }); diff --git a/src/app/service/service_worker/external_access/controller.ts b/src/app/service/service_worker/external_access/controller.ts index 250afc9a0..a10ab1f54 100644 --- a/src/app/service/service_worker/external_access/controller.ts +++ b/src/app/service/service_worker/external_access/controller.ts @@ -1,10 +1,19 @@ import { uuidv4 } from "@App/pkg/utils/uuid"; -import type { SystemConfig } from "@App/pkg/config/config"; +import { + externalAccessProduct, + externalAccessProductVersion, + isValidExternalAccessName, + type SystemConfig, +} from "@App/pkg/config/config"; import type { IMessageQueue } from "@Packages/message/message_queue"; import type { ExternalAccessConnectClient } from "../../offscreen/client"; import type { ExternalAccessBridge } from "./bridge"; import { type HelloPayload, + type AuthMode, + type ExternalAccessEnrollResult, + type ExternalAccessRenameResult, + type ExternalAccessRejectionReason, type ExternalAccessBridgeRequest, type ExternalAccessBridgeResponse, type ExternalAccessBridgeStatus, @@ -16,12 +25,21 @@ import { RPC_METHODS, SCHEMA_VERSION } from "./generated/protocol.generated"; // Broadcast on every status transition so the Tools settings page updates live. export const ExternalAccessStatusChanged = "mcpStatusChanged"; +const REJECTION_KEY = "externalAccessRejection"; // The subset of the offscreen WS driver ExternalAccessController needs: open/close the socket and push // outbound envelopes onto the wire. The socket itself, plus the auth handshake and reconnect // backoff, live in offscreen (src/app/service/offscreen/external-access-connect.ts). type ConnectDriver = Pick; +type PendingOperation = { + resolve: (result: ExternalAccessEnrollResult) => void; + renameTo?: string; + rollback?: boolean; + // 接入前的名称:配对时名称冲突要恢复它,否则下次接入会预填一个已知被占用的名称。 + restoreName?: string; +}; + /** * SW-side coordinator for 外部接入 (External Access). Owns the status machine and drives the * offscreen WS client for transport. Trust is flat (design §2.3): a single enrollment establishes @@ -31,14 +49,17 @@ type ConnectDriver = Pick; + private generation = 0; + private reason?: ExternalAccessRejectionReason; + private pending?: PendingOperation; constructor( private readonly systemConfig: SystemConfig, @@ -52,8 +73,10 @@ export class ExternalAccessController { // Offscreen relays every decoded business envelope (plus the newly enrolled key and socket // disconnects) back here; register before anything can arrive. this.group.on("envelope", (envelope: WSEnvelope) => this.onEnvelope(envelope)); - this.group.on("paired", (payload: { key: string }) => this.onEnrolled(payload.key)); - this.group.on("disconnected", () => this.onDisconnected()); + this.group.on("established", ({ key }: { key?: string }) => this.onEstablished(key)); + this.group.on("disconnected", ({ reason, mode }: { reason?: ExternalAccessRejectionReason; mode: AuthMode }) => + this.onDisconnected(reason, mode) + ); // split 模式下隐身进程与普通进程共用 chrome.storage.local 里的密钥和脚本,由普通进程代表这份安装连接 sctl; // 隐身进程再连只会与普通进程互相顶替(sctl#14)。 @@ -67,6 +90,17 @@ export class ExternalAccessController { } }); if (await this.systemConfig.getExternalAccessEnabled()) { + // WS 关闭后 SW 可能休眠;拒绝结论需跨 SW 唤醒保留,但不跨浏览器会话。 + const saved = await chrome.storage.session.get(REJECTION_KEY); + const reason = saved[REJECTION_KEY]; + if (reason === "pending_enrollment") { + this.setStatus("pending_enrollment"); + return; + } + if (reason === "refused" || reason === "name-taken") { + this.setStatus("rejected", reason); + return; + } await this.connect(); } } @@ -74,10 +108,19 @@ export class ExternalAccessController { // Session-mode connect using the stored long-term key. No-op if already driving a connection. // `active` is set synchronously before the first await so a re-fired external_access_enabled listener can't // race a second dial through the guard. Enabled-but-unenrolled parks in "pending_enrollment". - private async connect(): Promise { - if (this.active) return; + private connect(): Promise { + if (this.active) return this.connecting ?? Promise.resolve(); this.active = true; + const connecting = this.dialSession(); + this.connecting = connecting; + return connecting; + } + + private async dialSession(): Promise { + const generation = ++this.generation; + await chrome.storage.session.remove(REJECTION_KEY); const pairing = await this.systemConfig.getExternalAccessPairing(); + if (generation !== this.generation) return; if (!pairing.key) { // Enabled but never enrolled — nothing to authenticate with; release so a later enroll() dials. this.active = false; @@ -86,32 +129,94 @@ export class ExternalAccessController { } this.setStatus("connecting"); const url = await this.systemConfig.getExternalAccessUrl(); - void this.connectClient.connect({ url, auth: { mode: "session", key: pairing.key } }); + const { instanceId } = await this.systemConfig.getExternalAccessIdentity(); + if (generation === this.generation) + await this.connectClient.connect({ url, instanceId, auth: { mode: "session", key: pairing.key } }); } // Enrollment (接入): the user ran `sctl connect`, which printed a one-time code C shown only in the // terminal (never over the wire), and typed it into the enrollment dialog. We connect in // pairing mode with C; on success the daemon ships a fresh long-term key K, which offscreen - // decrypts and relays back via `onEnrolled`. - async enroll(code: string): Promise { - if (chrome.extension.inIncognitoContext) return; + // decrypts and relays back after capabilities acceptance. + async enroll(code: string, name: string): Promise { + if (!isValidExternalAccessName(name)) return "invalid-name"; + if (chrome.extension.inIncognitoContext || this.pending) return "not-connected"; this.active = true; - this.setStatus("connecting"); - const url = await this.systemConfig.getExternalAccessUrl(); - void this.connectClient.connect({ url, auth: { mode: "pairing", code } }); + const generation = ++this.generation; + const { result, op } = this.beginOperation(); + try { + await chrome.storage.session.remove(REJECTION_KEY); + op.restoreName = (await this.systemConfig.getExternalAccessIdentity()).name; + const { instanceId } = await this.systemConfig.setExternalAccessName(name); + const url = await this.systemConfig.getExternalAccessUrl(); + if (generation === this.generation) { + this.setStatus("connecting"); + await this.connectClient.connect({ url, instanceId, auth: { mode: "pairing", code } }); + } + } catch (error) { + if (generation === this.generation) await this.onDisconnected(undefined, "pairing"); + throw error; + } + return result; + } + + async rename(name: string): Promise { + if (!isValidExternalAccessName(name)) return "invalid-name"; + if (chrome.extension.inIncognitoContext || this.pending) return "not-connected"; + if (this.status !== "connected") { + await this.systemConfig.setExternalAccessName(name); + if (this.status === "rejected") await this.connect(); + else this.setStatus(this.status); + return "applies-next-connection"; + } + const { result } = this.beginOperation(name); + this.active = false; + void this.bridge.cancel(); + try { + await this.connect(); + } catch (error) { + await this.onDisconnected(undefined, "session"); + throw error; + } + return result; } - private onEnvelope(envelope: WSEnvelope): void { + private beginOperation(renameTo?: string): { result: Promise; op: PendingOperation } { + const op: PendingOperation = { resolve: () => undefined, renameTo }; + const result = new Promise((resolve) => { + op.resolve = resolve; + }); + this.pending = op; + return { result, op }; + } + + private finishOperation(result: ExternalAccessEnrollResult): void { + this.pending?.resolve(result); + this.pending = undefined; + } + + private async onEnvelope(envelope: WSEnvelope): Promise { switch (envelope.method) { case "$session.hello": { const { daemonVersion } = envelope.params as HelloPayload; this.daemonVersion = daemonVersion; - this.setStatus("connected"); + const name = this.pending?.renameTo ?? (await this.systemConfig.getExternalAccessIdentity()).name; + const product = externalAccessProduct(); + const productVersion = externalAccessProductVersion(); void this.connectClient.send({ jsonrpc: "2.0", id: uuidv4(), method: "$session.capabilities", - params: { schemaVersion: SCHEMA_VERSION, methods: Object.keys(RPC_METHODS) }, + params: { + schemaVersion: SCHEMA_VERSION, + methods: Object.keys(RPC_METHODS), + peer: { + name, + ...(product ? { product } : {}), + ...(productVersion ? { productVersion } : {}), + extensionVersion: chrome.runtime.getManifest().version, + }, + }, }); break; } @@ -137,17 +242,51 @@ export class ExternalAccessController { } } - // Enrollment succeeded: persist the daemon-minted long-term key alongside a stable local client - // identity so future reconnects use session-mode auth. - private async onEnrolled(key: string): Promise { - const existing = await this.systemConfig.getExternalAccessPairing(); - this.systemConfig.setExternalAccessPairing({ key, clientId: existing.clientId || uuidv4() }); + private async onEstablished(key?: string): Promise { + // stop() 之后迟到的通知不能让已废弃的接入复活;但 SW 在配对握手期间重启会丢掉内存里的 active, + // 而 daemon 已登记这把新密钥,此时必须落盘并接管这条连接。 + if (this.status === "disabled") return; + this.active = true; + const pending = this.pending; + if (key) this.systemConfig.setExternalAccessPairing({ key }); + if (pending?.renameTo) { + try { + await this.systemConfig.setExternalAccessName(pending.renameTo); + } catch (error) { + this.finishOperation("not-connected"); + throw error; + } + if (this.pending !== pending) return; + } + this.setStatus("connected"); + this.finishOperation(pending?.rollback ? "name-taken" : "ok"); } - private onDisconnected(): void { - if (this.status === "disabled") return; // user-initiated stop(), not a failure to recover from + private async onDisconnected(reason: ExternalAccessRejectionReason | undefined, mode: AuthMode): Promise { + if (!this.active) return; void this.bridge.cancel(); - this.setStatus("host_unreachable"); + if (!reason) { + this.setStatus("host_unreachable"); + this.finishOperation("not-connected"); + return; + } + this.active = false; + if (reason === "name-taken" && this.pending?.renameTo) { + // 新名称尚未落盘;再次声明持久化的原名,恢复成功后再答复调用者。 + this.pending.renameTo = undefined; + this.pending.rollback = true; + await this.connect(); + return; + } + const pairingConflict = mode === "pairing" && reason === "name-taken"; + const generation = this.generation; + if (pairingConflict && this.pending?.restoreName) { + await this.systemConfig.setExternalAccessName(this.pending.restoreName); + } + await chrome.storage.session.set({ [REJECTION_KEY]: pairingConflict ? "pending_enrollment" : reason }); + if (generation !== this.generation) return; + this.setStatus(pairingConflict ? "pending_enrollment" : "rejected", pairingConflict ? undefined : reason); + this.finishOperation(reason === "name-taken" ? reason : "not-connected"); } private async dispatchBridgeRequest(request: ExternalAccessBridgeRequest): Promise { @@ -182,6 +321,9 @@ export class ExternalAccessController { // User disable, or "停止外部接入" kill switch (the caller additionally discards K + clears the // session-allow store, forcing a re-enrollment). stop(): void { + this.generation++; + void chrome.storage.session.remove(REJECTION_KEY); + this.finishOperation("not-connected"); this.active = false; void this.bridge.cancel(); void this.connectClient.send({ jsonrpc: "2.0", method: "$session.shutdown", params: {} }); @@ -189,19 +331,17 @@ export class ExternalAccessController { this.setStatus("disabled"); } - getStatus(): ExternalAccessBridgeStatusInfo { - return this.statusInfo(); - } - - // The daemon version is only meaningful while a connection is live; gating it on the connected - // states keeps a stale version from leaking through the status bar after a disconnect/stop. - private statusInfo(): ExternalAccessBridgeStatusInfo { - const live = this.status === "connected"; - return { status: this.status, daemonVersion: live ? this.daemonVersion : undefined }; + async getStatus(): Promise { + const status = this.status; + const reason = this.reason; + const daemonVersion = status === "connected" ? this.daemonVersion : undefined; + const { name } = await this.systemConfig.getExternalAccessIdentity(); + return { status, reason, daemonVersion, name }; } - private setStatus(status: ExternalAccessBridgeStatus): void { + private setStatus(status: ExternalAccessBridgeStatus, reason?: ExternalAccessRejectionReason): void { this.status = status; - this.mq.publish(ExternalAccessStatusChanged, this.statusInfo()); + this.reason = reason; + void this.getStatus().then((info) => this.mq.publish(ExternalAccessStatusChanged, info)); } } diff --git a/src/app/service/service_worker/external_access/service.test.ts b/src/app/service/service_worker/external_access/service.test.ts index 52807a272..8a86fbfc1 100644 --- a/src/app/service/service_worker/external_access/service.test.ts +++ b/src/app/service/service_worker/external_access/service.test.ts @@ -1,26 +1,28 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mockWebLocks } from "@Tests/mockWebLocks"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { ExternalAccessUIService } from "./service"; import { ExternalAccessOperationDAO } from "@App/app/repo/external_access"; import { ExternalAccessApprovalService } from "./approval"; import { ScriptDAO, ScriptCodeDAO } from "@App/app/repo/scripts"; import { TempStorageDAO } from "@App/app/repo/tempStorage"; import { createMockOPFS } from "@App/app/repo/test-helpers"; -import type { SystemConfig } from "@App/pkg/config/config"; +import { SystemConfig } from "@App/pkg/config/config"; +import { MessageQueue } from "@Packages/message/message_queue"; describe("ExternalAccessUIService(外部接入页端点)", () => { let approval: ExternalAccessApprovalService; let controller: { getStatus: ReturnType; enroll: ReturnType; + rename: ReturnType; stop: ReturnType; }; - let systemConfig: { - setExternalAccessPairing: ReturnType; - setExternalAccessEnabled: ReturnType; - }; + let systemConfig: SystemConfig; let handlers: Record any>; let service: ExternalAccessUIService; + afterEach(() => vi.restoreAllMocks()); + beforeEach(() => { chrome.storage.local.clear(); createMockOPFS(); @@ -33,14 +35,15 @@ describe("ExternalAccessUIService(外部接入页端点)", () => { new TempStorageDAO() ); controller = { - getStatus: vi.fn().mockReturnValue({ status: "connected", daemonVersion: "1.0.0" }), - enroll: vi.fn(), + getStatus: vi.fn().mockResolvedValue({ status: "connected", daemonVersion: "1.0.0" }), + enroll: vi.fn().mockResolvedValue("name-taken"), + rename: vi.fn().mockResolvedValue("applies-next-connection"), stop: vi.fn(), }; - systemConfig = { setExternalAccessPairing: vi.fn(), setExternalAccessEnabled: vi.fn() }; + systemConfig = new SystemConfig(new MessageQueue()); handlers = {}; const group = { on: (name: string, fn: (...args: any[]) => any) => (handlers[name] = fn) } as any; - service = new ExternalAccessUIService(group, controller as any, approval, systemConfig as unknown as SystemConfig); + service = new ExternalAccessUIService(group, controller as any, approval, systemConfig); service.init(); }); @@ -52,19 +55,22 @@ describe("ExternalAccessUIService(外部接入页端点)", () => { "operationDecision", "operationReopen", "pendingOperations", + "rename", "status", "stopExternalAccess", ].sort() ); }); - it("status 返回控制器状态(含 sctl 版本)", () => { - expect(handlers["status"]()).toEqual({ status: "connected", daemonVersion: "1.0.0" }); + it("status 返回控制器状态(含 sctl 版本)", async () => { + expect(await handlers["status"]()).toEqual({ status: "connected", daemonVersion: "1.0.0" }); }); - it("enroll 转发配对码给控制器", () => { - handlers["enroll"]("CODE-1234"); - expect(controller.enroll).toHaveBeenCalledWith("CODE-1234"); + it("接入与改名返回控制器的结果", async () => { + expect(await handlers["enroll"]({ code: "CODE-1234", name: "work" })).toBe("name-taken"); + expect(controller.enroll).toHaveBeenCalledWith("CODE-1234", "work"); + expect(await handlers["rename"]("work")).toBe("applies-next-connection"); + expect(controller.rename).toHaveBeenCalledWith("work"); }); it("operationDecision 透传 enable / rememberSession 给 approval.decide", () => { @@ -75,12 +81,17 @@ describe("ExternalAccessUIService(外部接入页端点)", () => { expect(decide).toHaveBeenCalledWith("op", true, { enable: true, rememberSession: true }); }); - it("stopExternalAccess 废弃密钥 K、清本会话授权、停止并关闭开关", async () => { + it("stopExternalAccess 废弃密钥但保留实例身份、清本会话授权、停止并关闭开关", async () => { + mockWebLocks(); + const identity = await systemConfig.getExternalAccessIdentity(); + systemConfig.setExternalAccessPairing({ key: "test-key" }); + systemConfig.setExternalAccessEnabled(true); const clear = vi.spyOn(approval, "clearSessionAllow").mockResolvedValue(undefined); await handlers["stopExternalAccess"](); - expect(systemConfig.setExternalAccessPairing).toHaveBeenCalledWith(undefined); + expect(await systemConfig.getExternalAccessPairing()).toEqual({ key: "" }); + expect(await new SystemConfig(new MessageQueue()).getExternalAccessIdentity()).toEqual(identity); expect(clear).toHaveBeenCalled(); expect(controller.stop).toHaveBeenCalled(); - expect(systemConfig.setExternalAccessEnabled).toHaveBeenCalledWith(false); + expect(await systemConfig.getExternalAccessEnabled()).toBe(false); }); }); diff --git a/src/app/service/service_worker/external_access/service.ts b/src/app/service/service_worker/external_access/service.ts index 68eb00bdd..bc98dbf47 100644 --- a/src/app/service/service_worker/external_access/service.ts +++ b/src/app/service/service_worker/external_access/service.ts @@ -2,14 +2,21 @@ import type { Group } from "@Packages/message/server"; import type { SystemConfig } from "@App/pkg/config/config"; import type { ExternalAccessOperation } from "@App/app/repo/external_access"; import type { ExternalAccessApprovalService } from "./approval"; -import type { OperationStatusResult, ExternalAccessBridgeStatusInfo, PendingOperationSummary } from "./types"; +import type { + ExternalAccessEnrollResult, + ExternalAccessRenameResult, + OperationStatusResult, + ExternalAccessBridgeStatusInfo, + PendingOperationSummary, +} from "./types"; -// Narrow surface ExternalAccessUIService needs from ExternalAccessController — flat trust leaves only the status read and -// the enrollment dial; the settings page reads/writes the two policies + external_access_enabled directly via -// the generic SystemConfig plumbing. +// Narrow surface ExternalAccessUIService needs from ExternalAccessController — flat trust leaves only the status read, +// the enrollment dial and the instance rename; the settings page reads/writes the two policies + +// external_access_enabled directly via the generic SystemConfig plumbing. export interface ExternalAccessControllerFacade { - getStatus(): ExternalAccessBridgeStatusInfo; - enroll(code: string): void; + getStatus(): Promise; + enroll(code: string, name: string): Promise; + rename(name: string): Promise; stop(): void; } @@ -30,6 +37,7 @@ export class ExternalAccessUIService { init(): void { this.group.on("status", this.getStatus.bind(this)); this.group.on("enroll", this.enroll.bind(this)); + this.group.on("rename", this.rename.bind(this)); this.group.on("operation", this.getOperation.bind(this)); this.group.on("operationDecision", this.decideOperation.bind(this)); this.group.on("operationReopen", this.reopenOperation.bind(this)); @@ -40,11 +48,15 @@ export class ExternalAccessUIService { // Enrollment (接入): the user ran `sctl connect` and typed its one-time terminal code into the // dialog. Dials the daemon in pairing mode; on success the daemon ships the long-term key K and // the controller persists it — CLI + every MCP client then inherit that single enrollment. - enroll(code: string): void { - this.controller.enroll(code); + enroll({ code, name }: { code: string; name: string }): Promise { + return this.controller.enroll(code, name); } - getStatus(): ExternalAccessBridgeStatusInfo { + rename(name: string): Promise { + return this.controller.rename(name); + } + + getStatus(): Promise { return this.controller.getStatus(); } diff --git a/src/app/service/service_worker/external_access/types.ts b/src/app/service/service_worker/external_access/types.ts index 614eddde7..d52aa1c98 100644 --- a/src/app/service/service_worker/external_access/types.ts +++ b/src/app/service/service_worker/external_access/types.ts @@ -48,15 +48,16 @@ export interface AuthChallengePayload { nonceD: string; } -// ext->daemon: the extension's nonceE plus HMAC(key, ctx || nonceD || nonceE). `key` is the +// ext->daemon: the extension's nonceE plus HMAC(key, ctx || instanceId || nonceD || nonceE). `key` is the // long-term K in session mode, or the code-derived Kp_mac in pairing mode. export interface AuthResponsePayload { mode: AuthMode; + peer: { kind: "scriptcat"; instanceId: string }; nonceE: string; hmac: string; } -// daemon->ext: HMAC(key, ctx || nonceE || nonceD) proving the daemon also holds the key. In +// daemon->ext: HMAC(key, ctx || instanceId || nonceE || nonceD) proving the daemon also holds the key. In // pairing mode it additionally ships the freshly minted long-term K, AES-256-GCM encrypted under // the code-derived Kp_enc. export interface AuthOkPayload { @@ -282,12 +283,22 @@ export type ExternalAccessBridgeStatus = | "pending_enrollment" | "connecting" | "connected" - | "host_unreachable"; - -// getStatus / ExternalAccessStatusChanged payload: the bare status plus the daemon version reported -// by the hello handshake. daemonVersion is only carried while connected; the status bar renders it -// as "sctl v{daemonVersion}". + | "host_unreachable" + | "rejected"; + +export type ExternalAccessRejectionReason = "refused" | "name-taken"; +// 接入时 name-taken 表示配对码已被消费,改名后需重新运行 sctl connect。 +// not-connected:连接失败/认证拒绝、操作被停止,或隐身/已有操作进行中而不能开始。 +// ok 在能力声明接受后返回;存储或内部消息错误仍通过 Promise rejection 传递。 +export type ExternalAccessEnrollResult = "ok" | "invalid-name" | "name-taken" | "not-connected"; +// 未连接时只保存名称;已连接时等 daemon 确认,冲突会用原名恢复连接。 +export type ExternalAccessRenameResult = ExternalAccessEnrollResult | "applies-next-connection"; + +// getStatus / ExternalAccessStatusChanged 共用。name 为当前保存的实例名称, +// reason 仅在 rejected 时携带;daemonVersion 仅在 connected 时展示。 export interface ExternalAccessBridgeStatusInfo { status: ExternalAccessBridgeStatus; + name: string; + reason?: ExternalAccessRejectionReason; daemonVersion?: string; } diff --git a/src/locales/de-DE/external_access.json b/src/locales/de-DE/external_access.json index d50871751..d4bc85272 100644 --- a/src/locales/de-DE/external_access.json +++ b/src/locales/de-DE/external_access.json @@ -3,12 +3,27 @@ "section_desc": "Erlaubt auf diesem Rechner laufenden Programmen – der sctl-CLI und KI-Clients über MCP – den Zugriff auf und die Steuerung deiner Skripte.", "enable_switch": "Externen Zugriff aktivieren", "status_off": "Aus", + "status_rejected": "Von sctl abgelehnt", + "rejected_refused_reason": "sctl hat diese ScriptCat-Instanz abgelehnt – sie wurde möglicherweise in sctl vergessen (sctl scriptcats forget), oder sctl ist zu alt. Registriere sie erneut, um wieder eine Verbindung herzustellen.", + "rejected_name_taken_reason": "Dieser Name wird von einer anderen ScriptCat-Instanz verwendet. Ändere den Namen.", "status_pending_enrollment": "Registrierung ausstehend", "status_connecting": "Verbinde …", "status_connected": "Verbunden", "status_host_unreachable": "Verbindung fehlgeschlagen", "retry": "Erneut versuchen", "reenroll": "Neu registrieren", + "instance_name_label": "Instanzname", + "name_edit": "Bearbeiten", + "name_edit_action": "Namen ändern", + "name_save": "Speichern", + "name_invalid_hint": "Der Name muss aus 1–32 Kleinbuchstaben, Ziffern oder - bestehen; scriptcat ist reserviert.", + "name_taken": "Dieser Name wird bereits von einer anderen ScriptCat-Instanz verwendet.", + "rename_success": "Instanzname aktualisiert.", + "rename_next_connection": "Gespeichert – der neue Name gilt ab der nächsten Verbindung.", + "rename_not_connected": "Der Name kann gerade nicht geändert werden. Stelle sicher, dass sctl verbunden ist, und versuche es erneut.", + "rename_failed": "Der Name konnte nicht geändert werden. Versuche es erneut.", + "rename_saving": "Speichern …", + "enroll_name_taken": "Dieser Name wird bereits von einer anderen ScriptCat-Instanz verwendet. Dieser Kopplungscode wurde verbraucht – ändere den Namen, führe sctl connect erneut aus, um einen neuen Code zu erhalten, und sende ihn dann ab.", "address_label": "sctl-Adresse", "policy_title": "Berechtigungsrichtlinie", "policy_write": "Schreibvorgänge", @@ -42,6 +57,8 @@ "enroll_perm_write": "Installation · Aktivierung · Löschung anfragen (gemäß Schreibrichtlinie bestätigt)", "enroll_perm_source": "Skript-Quelltext lesen (gemäß Quelltextrichtlinie bestätigt)", "enroll_submit": "Registrieren", + "enroll_submitting": "Registrierung …", + "enroll_failed": "Die Registrierung konnte nicht gestartet werden. Versuche es erneut.", "cancel": "Abbrechen", "enroll_started": "Kopplungscode gesendet – Verbindung zu sctl wird hergestellt …", "confirm_via_external": "Über externen Zugriff ausgelöst", diff --git a/src/locales/en-US/external_access.json b/src/locales/en-US/external_access.json index b61350483..3d0ecd248 100644 --- a/src/locales/en-US/external_access.json +++ b/src/locales/en-US/external_access.json @@ -3,12 +3,27 @@ "section_desc": "Let programs running on this machine — the sctl CLI and AI clients over MCP — connect to and control your scripts.", "enable_switch": "Enable External Access", "status_off": "Off", + "status_rejected": "Rejected by sctl", + "rejected_refused_reason": "sctl rejected this ScriptCat instance — it may have been forgotten in sctl (sctl scriptcats forget), or sctl may be too old. Re-enroll to connect again.", + "rejected_name_taken_reason": "This name is used by another ScriptCat instance. Change the name.", "status_pending_enrollment": "Pending enrollment", "status_connecting": "Connecting", "status_connected": "Connected", "status_host_unreachable": "Connection failed", "retry": "Retry", "reenroll": "Re-enroll", + "instance_name_label": "Instance name", + "name_edit": "Edit", + "name_edit_action": "Edit name", + "name_save": "Save", + "name_invalid_hint": "Use 1–32 lowercase letters, digits, or hyphens; scriptcat is reserved.", + "name_taken": "This name is already used by another ScriptCat instance.", + "rename_success": "Instance name updated.", + "rename_next_connection": "Saved — the new name takes effect on the next connection.", + "rename_not_connected": "Could not rename right now. Make sure sctl is connected and try again.", + "rename_failed": "Could not rename. Try again.", + "rename_saving": "Saving…", + "enroll_name_taken": "This name is already used by another ScriptCat instance. This pairing code has been used up — change the name, run sctl connect again for a new code, then submit it.", "address_label": "sctl address", "policy_title": "Permission policy", "policy_write": "Write operations", @@ -42,6 +57,8 @@ "enroll_perm_write": "Request install · enable · delete (confirmed per the write policy)", "enroll_perm_source": "Read script source (confirmed per the source policy)", "enroll_submit": "Enroll", + "enroll_submitting": "Enrolling…", + "enroll_failed": "Could not start enrollment. Try again.", "cancel": "Cancel", "enroll_started": "Pairing code submitted — connecting to sctl…", "confirm_via_external": "Triggered via External Access", diff --git a/src/locales/ja-JP/external_access.json b/src/locales/ja-JP/external_access.json index 08af92c23..ea2447ca8 100644 --- a/src/locales/ja-JP/external_access.json +++ b/src/locales/ja-JP/external_access.json @@ -3,12 +3,27 @@ "section_desc": "このマシンで動作するプログラム(sctl CLI と MCP 経由の AI クライアント)がスクリプトに接続して制御できるようにします。", "enable_switch": "外部アクセスを有効化", "status_off": "無効", + "status_rejected": "sctl により拒否されました", + "rejected_refused_reason": "sctl がこの ScriptCat インスタンスを拒否しました。sctl でこのインスタンスが忘れられている(sctl scriptcats forget)か、sctl が古い可能性があります。再登録して接続し直してください。", + "rejected_name_taken_reason": "この名前は別の ScriptCat インスタンスで使用されています。名前を変更してください。", "status_pending_enrollment": "登録待ち", "status_connecting": "接続中", "status_connected": "接続済み", "status_host_unreachable": "接続失敗", "retry": "再試行", "reenroll": "再登録", + "instance_name_label": "インスタンス名", + "name_edit": "変更", + "name_edit_action": "名前を変更", + "name_save": "保存", + "name_invalid_hint": "名前は 1〜32 文字の小文字英数字または - で指定してください(scriptcat は予約済みです)。", + "name_taken": "この名前は別の ScriptCat インスタンスで使用されています。", + "rename_success": "インスタンス名を更新しました。", + "rename_next_connection": "保存しました。新しい名前は次回の接続時に有効になります。", + "rename_not_connected": "現在は名前を変更できません。sctl が接続されていることを確認して再試行してください。", + "rename_failed": "名前を変更できませんでした。もう一度お試しください。", + "rename_saving": "保存中…", + "enroll_name_taken": "この名前は別の ScriptCat インスタンスで使用されています。このペアリングコードは使用済みです。名前を変更し、sctl connect を再実行して新しいコードを取得してから送信してください。", "address_label": "sctl アドレス", "policy_title": "権限ポリシー", "policy_write": "書き込み操作", @@ -42,6 +57,8 @@ "enroll_perm_write": "インストール・有効化・削除の要求(書き込みポリシーに従って確認)", "enroll_perm_source": "スクリプトソースの読み取り(ソースポリシーに従って確認)", "enroll_submit": "登録", + "enroll_submitting": "登録中…", + "enroll_failed": "登録を開始できませんでした。もう一度お試しください。", "cancel": "キャンセル", "enroll_started": "ペアリングコードを送信しました。sctl に接続しています…", "confirm_via_external": "外部アクセスによる要求", diff --git a/src/locales/ko-KR/external_access.json b/src/locales/ko-KR/external_access.json index 531adb908..a7f0208af 100644 --- a/src/locales/ko-KR/external_access.json +++ b/src/locales/ko-KR/external_access.json @@ -3,12 +3,27 @@ "section_desc": "이 컴퓨터에서 실행되는 프로그램(sctl CLI 및 MCP를 통한 AI 클라이언트)이 스크립트에 연결하여 제어할 수 있도록 합니다.", "enable_switch": "외부 접근 사용", "status_off": "꺼짐", + "status_rejected": "sctl에서 거부됨", + "rejected_refused_reason": "sctl이 이 ScriptCat 인스턴스를 거부했습니다. sctl에서 이 인스턴스를 잊었거나(sctl scriptcats forget) sctl 버전이 너무 오래되었을 수 있습니다. 다시 등록하여 연결하세요.", + "rejected_name_taken_reason": "이 이름은 다른 ScriptCat 인스턴스에서 사용 중입니다. 이름을 변경하세요.", "status_pending_enrollment": "등록 대기 중", "status_connecting": "연결 중", "status_connected": "연결됨", "status_host_unreachable": "연결 실패", "retry": "다시 시도", "reenroll": "다시 등록", + "instance_name_label": "인스턴스 이름", + "name_edit": "변경", + "name_edit_action": "이름 변경", + "name_save": "저장", + "name_invalid_hint": "이름은 소문자, 숫자, -로 된 1~32자여야 하며 scriptcat은 사용할 수 없습니다.", + "name_taken": "이 이름은 다른 ScriptCat 인스턴스에서 사용 중입니다.", + "rename_success": "인스턴스 이름을 업데이트했습니다.", + "rename_next_connection": "저장했습니다. 새 이름은 다음 연결부터 적용됩니다.", + "rename_not_connected": "지금은 이름을 변경할 수 없습니다. sctl이 연결되어 있는지 확인한 후 다시 시도하세요.", + "rename_failed": "이름을 변경하지 못했습니다. 다시 시도하세요.", + "rename_saving": "저장 중…", + "enroll_name_taken": "이 이름은 다른 ScriptCat 인스턴스에서 사용 중입니다. 이 페어링 코드는 이미 사용되었습니다. 이름을 변경하고 sctl connect를 다시 실행해 새 코드를 받은 후 제출하세요.", "address_label": "sctl 주소", "policy_title": "권한 정책", "policy_write": "쓰기 작업", @@ -42,6 +57,8 @@ "enroll_perm_write": "설치 · 사용 · 삭제 요청(쓰기 정책에 따라 확인)", "enroll_perm_source": "스크립트 소스 읽기(소스 정책에 따라 확인)", "enroll_submit": "등록", + "enroll_submitting": "등록 중…", + "enroll_failed": "등록을 시작하지 못했습니다. 다시 시도하세요.", "cancel": "취소", "enroll_started": "페어링 코드를 제출했습니다. sctl에 연결하는 중…", "confirm_via_external": "외부 접근으로 요청됨", diff --git a/src/locales/pt-BR/external_access.json b/src/locales/pt-BR/external_access.json index 575d40a2c..b092ce4a9 100644 --- a/src/locales/pt-BR/external_access.json +++ b/src/locales/pt-BR/external_access.json @@ -3,12 +3,27 @@ "section_desc": "Permite que programas em execução nesta máquina — a CLI sctl e clientes de IA via MCP — conectem-se e controlem seus scripts.", "enable_switch": "Ativar acesso externo", "status_off": "Desligado", + "status_rejected": "Rejeitado pelo sctl", + "rejected_refused_reason": "O sctl rejeitou esta instância do ScriptCat — ela pode ter sido esquecida no sctl (sctl scriptcats forget), ou o sctl pode estar desatualizado. Vincule novamente para reconectar.", + "rejected_name_taken_reason": "Este nome é usado por outra instância do ScriptCat. Altere o nome.", "status_pending_enrollment": "Aguardando vinculação", "status_connecting": "Conectando", "status_connected": "Conectado", "status_host_unreachable": "Falha na conexão", "retry": "Tentar novamente", "reenroll": "Vincular novamente", + "instance_name_label": "Nome da instância", + "name_edit": "Editar", + "name_edit_action": "Editar nome", + "name_save": "Salvar", + "name_invalid_hint": "Use 1–32 letras minúsculas, dígitos ou hifens; scriptcat é reservado.", + "name_taken": "Este nome já é usado por outra instância do ScriptCat.", + "rename_success": "Nome da instância atualizado.", + "rename_next_connection": "Salvo — o novo nome entra em vigor na próxima conexão.", + "rename_not_connected": "Não foi possível renomear agora. Verifique se o sctl está conectado e tente novamente.", + "rename_failed": "Não foi possível renomear. Tente novamente.", + "rename_saving": "Salvando…", + "enroll_name_taken": "Este nome já é usado por outra instância do ScriptCat. Este código de emparelhamento foi usado — altere o nome, execute sctl connect novamente para obter um novo código e envie-o.", "address_label": "Endereço do sctl", "policy_title": "Política de permissões", "policy_write": "Operações de escrita", @@ -42,6 +57,8 @@ "enroll_perm_write": "Solicitar instalação · ativação · exclusão (confirmado pela política de escrita)", "enroll_perm_source": "Ler o código-fonte do script (confirmado pela política de código-fonte)", "enroll_submit": "Vincular", + "enroll_submitting": "Vinculando…", + "enroll_failed": "Não foi possível iniciar a vinculação. Tente novamente.", "cancel": "Cancelar", "enroll_started": "Código de emparelhamento enviado — conectando ao sctl…", "confirm_via_external": "Acionado via acesso externo", diff --git a/src/locales/ru-RU/external_access.json b/src/locales/ru-RU/external_access.json index 972ece9ee..082818ee1 100644 --- a/src/locales/ru-RU/external_access.json +++ b/src/locales/ru-RU/external_access.json @@ -3,12 +3,27 @@ "section_desc": "Позволяет программам на этом компьютере — CLI sctl и ИИ-клиентам через MCP — подключаться к вашим скриптам и управлять ими.", "enable_switch": "Включить внешний доступ", "status_off": "Выключено", + "status_rejected": "Отклонено sctl", + "rejected_refused_reason": "sctl отклонил этот экземпляр ScriptCat — возможно, он был забыт в sctl (sctl scriptcats forget) или версия sctl слишком старая. Подключите заново, чтобы восстановить соединение.", + "rejected_name_taken_reason": "Это имя используется другим экземпляром ScriptCat. Измените имя.", "status_pending_enrollment": "Ожидает подключения", "status_connecting": "Подключение…", "status_connected": "Подключено", "status_host_unreachable": "Не удалось подключиться", "retry": "Повторить", "reenroll": "Подключить заново", + "instance_name_label": "Имя экземпляра", + "name_edit": "Изменить", + "name_edit_action": "Изменить имя", + "name_save": "Сохранить", + "name_invalid_hint": "Имя должно содержать от 1 до 32 строчных букв, цифр или дефисов; имя scriptcat зарезервировано.", + "name_taken": "Это имя уже используется другим экземпляром ScriptCat.", + "rename_success": "Имя экземпляра обновлено.", + "rename_next_connection": "Сохранено — новое имя вступит в силу при следующем подключении.", + "rename_not_connected": "Сейчас нельзя изменить имя. Убедитесь, что sctl подключён, и повторите попытку.", + "rename_failed": "Не удалось изменить имя. Повторите попытку.", + "rename_saving": "Сохранение…", + "enroll_name_taken": "Это имя уже используется другим экземпляром ScriptCat. Этот код сопряжения израсходован — измените имя, снова выполните sctl connect, чтобы получить новый код, и отправьте его.", "address_label": "Адрес sctl", "policy_title": "Политика разрешений", "policy_write": "Операции записи", @@ -42,6 +57,8 @@ "enroll_perm_write": "Запрос установки · включения · удаления (подтверждается по политике записи)", "enroll_perm_source": "Чтение исходного кода скрипта (подтверждается по политике исходного кода)", "enroll_submit": "Подключить", + "enroll_submitting": "Подключение…", + "enroll_failed": "Не удалось начать подключение. Повторите попытку.", "cancel": "Отмена", "enroll_started": "Код сопряжения отправлен — подключение к sctl…", "confirm_via_external": "Запрошено через внешний доступ", diff --git a/src/locales/tr-TR/external_access.json b/src/locales/tr-TR/external_access.json index 9026fce72..06c30b431 100644 --- a/src/locales/tr-TR/external_access.json +++ b/src/locales/tr-TR/external_access.json @@ -3,12 +3,27 @@ "section_desc": "Bu makinede çalışan programların — sctl CLI ve MCP üzerinden yapay zekâ istemcileri — betiklerinize bağlanmasına ve onları denetlemesine izin verir.", "enable_switch": "Harici Erişimi Etkinleştir", "status_off": "Kapalı", + "status_rejected": "sctl tarafından reddedildi", + "rejected_refused_reason": "sctl bu ScriptCat örneğini reddetti — örnek sctl'de unutulmuş (sctl scriptcats forget) veya sctl çok eski olabilir. Yeniden bağlanmak için tekrar eşleştirin.", + "rejected_name_taken_reason": "Bu ad başka bir ScriptCat örneği tarafından kullanılıyor. Adı değiştirin.", "status_pending_enrollment": "Eşleştirme bekleniyor", "status_connecting": "Bağlanıyor", "status_connected": "Bağlandı", "status_host_unreachable": "Bağlantı başarısız", "retry": "Yeniden dene", "reenroll": "Yeniden eşleştir", + "instance_name_label": "Örnek adı", + "name_edit": "Düzenle", + "name_edit_action": "Adı düzenle", + "name_save": "Kaydet", + "name_invalid_hint": "Ad 1–32 küçük harf, rakam veya - içermelidir; scriptcat ayrılmıştır.", + "name_taken": "Bu ad zaten başka bir ScriptCat örneği tarafından kullanılıyor.", + "rename_success": "Örnek adı güncellendi.", + "rename_next_connection": "Kaydedildi — yeni ad bir sonraki bağlantıda geçerli olacak.", + "rename_not_connected": "Şu anda ad değiştirilemiyor. sctl'nin bağlı olduğundan emin olup yeniden deneyin.", + "rename_failed": "Ad değiştirilemedi. Yeniden deneyin.", + "rename_saving": "Kaydediliyor…", + "enroll_name_taken": "Bu ad zaten başka bir ScriptCat örneği tarafından kullanılıyor. Bu eşleştirme kodu kullanılmış — adı değiştirin, yeni bir kod almak için sctl connect komutunu yeniden çalıştırın ve ardından gönderin.", "address_label": "sctl adresi", "policy_title": "İzin ilkesi", "policy_write": "Yazma işlemleri", @@ -42,6 +57,8 @@ "enroll_perm_write": "Kurma · etkinleştirme · silme isteği (yazma ilkesine göre onaylanır)", "enroll_perm_source": "Betik kaynak kodunu okuma (kaynak ilkesine göre onaylanır)", "enroll_submit": "Eşleştir", + "enroll_submitting": "Eşleştiriliyor…", + "enroll_failed": "Eşleştirme başlatılamadı. Yeniden deneyin.", "cancel": "İptal", "enroll_started": "Eşleştirme kodu gönderildi — sctl'ye bağlanılıyor…", "confirm_via_external": "Harici Erişim ile tetiklendi", diff --git a/src/locales/vi-VN/external_access.json b/src/locales/vi-VN/external_access.json index fa650861b..05436ab0b 100644 --- a/src/locales/vi-VN/external_access.json +++ b/src/locales/vi-VN/external_access.json @@ -3,12 +3,27 @@ "section_desc": "Cho phép các chương trình chạy trên máy này — CLI sctl và ứng dụng AI qua MCP — kết nối và điều khiển các tập lệnh của bạn.", "enable_switch": "Bật truy cập bên ngoài", "status_off": "Tắt", + "status_rejected": "Bị sctl từ chối", + "rejected_refused_reason": "sctl đã từ chối thực thể ScriptCat này — có thể thực thể đã bị quên trong sctl (sctl scriptcats forget) hoặc sctl quá cũ. Hãy ghép nối lại để kết nối lại.", + "rejected_name_taken_reason": "Tên này đang được một thực thể ScriptCat khác sử dụng. Hãy đổi tên.", "status_pending_enrollment": "Đang chờ ghép nối", "status_connecting": "Đang kết nối", "status_connected": "Đã kết nối", "status_host_unreachable": "Kết nối thất bại", "retry": "Thử lại", "reenroll": "Ghép nối lại", + "instance_name_label": "Tên thực thể", + "name_edit": "Chỉnh sửa", + "name_edit_action": "Đổi tên", + "name_save": "Lưu", + "name_invalid_hint": "Tên phải gồm 1–32 chữ cái thường, chữ số hoặc dấu gạch ngang; không dùng được scriptcat.", + "name_taken": "Tên này đã được một thực thể ScriptCat khác sử dụng.", + "rename_success": "Đã cập nhật tên thực thể.", + "rename_next_connection": "Đã lưu — tên mới sẽ có hiệu lực ở lần kết nối tiếp theo.", + "rename_not_connected": "Hiện không thể đổi tên. Hãy đảm bảo sctl đã kết nối rồi thử lại.", + "rename_failed": "Không thể đổi tên. Hãy thử lại.", + "rename_saving": "Đang lưu…", + "enroll_name_taken": "Tên này đã được một thực thể ScriptCat khác sử dụng. Mã ghép nối này đã được dùng — hãy đổi tên, chạy lại sctl connect để lấy mã mới rồi gửi lại.", "address_label": "Địa chỉ sctl", "policy_title": "Chính sách quyền", "policy_write": "Thao tác ghi", @@ -42,6 +57,8 @@ "enroll_perm_write": "Yêu cầu cài đặt · bật · xóa (xác nhận theo chính sách ghi)", "enroll_perm_source": "Đọc mã nguồn tập lệnh (xác nhận theo chính sách mã nguồn)", "enroll_submit": "Ghép nối", + "enroll_submitting": "Đang ghép nối…", + "enroll_failed": "Không thể bắt đầu ghép nối. Hãy thử lại.", "cancel": "Hủy", "enroll_started": "Đã gửi mã ghép nối — đang kết nối tới sctl…", "confirm_via_external": "Kích hoạt qua truy cập bên ngoài", diff --git a/src/locales/zh-CN/external_access.json b/src/locales/zh-CN/external_access.json index b9a5a58a4..5d535b048 100644 --- a/src/locales/zh-CN/external_access.json +++ b/src/locales/zh-CN/external_access.json @@ -3,12 +3,27 @@ "section_desc": "让本机运行的程序(命令行 sctl 与经 MCP 的 AI 客户端)接入并控制你的脚本。", "enable_switch": "启用外部接入", "status_off": "未启用", + "status_rejected": "已被 sctl 拒绝", + "rejected_refused_reason": "sctl 拒绝了这个 ScriptCat 实例——它可能已在 sctl 中被忘记(sctl scriptcats forget),或 sctl 版本过旧;请重新接入以再次连接。", + "rejected_name_taken_reason": "该名称已被另一个 ScriptCat 实例使用,请修改名称。", "status_pending_enrollment": "待接入", "status_connecting": "连接中", "status_connected": "已接入", "status_host_unreachable": "连接失败", "retry": "重试", "reenroll": "重新接入", + "instance_name_label": "实例名称", + "name_edit": "修改", + "name_edit_action": "修改名称", + "name_save": "保存", + "name_invalid_hint": "名称需为 1–32 位小写字母、数字或 -,且不能是 scriptcat。", + "name_taken": "该名称已被另一个 ScriptCat 实例使用", + "rename_success": "实例名称已更新", + "rename_next_connection": "已保存,新名称将在下次连接时生效", + "rename_not_connected": "当前无法修改名称,请确认 sctl 已连接后重试", + "rename_failed": "修改名称失败,请重试", + "rename_saving": "保存中…", + "enroll_name_taken": "该名称已被另一个 ScriptCat 实例使用;这个配对码已用掉,请改名后重新运行 sctl connect 获取新配对码再提交。", "address_label": "sctl 地址", "policy_title": "权限策略", "policy_write": "写操作", @@ -42,6 +57,8 @@ "enroll_perm_write": "请求安装 · 启用 · 删除(按写操作策略确认)", "enroll_perm_source": "读取脚本源码(按源码策略确认)", "enroll_submit": "接入", + "enroll_submitting": "接入中…", + "enroll_failed": "接入失败,请重试", "cancel": "取消", "enroll_started": "已提交配对码,正在接入 sctl…", "confirm_via_external": "通过外部接入触发", diff --git a/src/locales/zh-TW/external_access.json b/src/locales/zh-TW/external_access.json index 9646ce778..15d024493 100644 --- a/src/locales/zh-TW/external_access.json +++ b/src/locales/zh-TW/external_access.json @@ -3,12 +3,27 @@ "section_desc": "讓本機執行的程式(命令列 sctl 與經 MCP 的 AI 用戶端)接入並控制你的腳本。", "enable_switch": "啟用外部接入", "status_off": "未啟用", + "status_rejected": "已遭 sctl 拒絕", + "rejected_refused_reason": "sctl 拒絕了這個 ScriptCat 執行個體——它可能已在 sctl 中被忘記(sctl scriptcats forget),或 sctl 版本過舊;請重新接入以再次連線。", + "rejected_name_taken_reason": "此名稱已被另一個 ScriptCat 執行個體使用,請修改名稱。", "status_pending_enrollment": "待接入", "status_connecting": "連線中", "status_connected": "已接入", "status_host_unreachable": "連線失敗", "retry": "重試", "reenroll": "重新接入", + "instance_name_label": "執行個體名稱", + "name_edit": "修改", + "name_edit_action": "修改名稱", + "name_save": "儲存", + "name_invalid_hint": "名稱需為 1–32 個小寫字母、數字或 -,且不能是 scriptcat。", + "name_taken": "此名稱已被另一個 ScriptCat 執行個體使用", + "rename_success": "已更新執行個體名稱", + "rename_next_connection": "已儲存,新名稱將於下次連線時生效", + "rename_not_connected": "目前無法修改名稱,請確認 sctl 已連線後再試", + "rename_failed": "修改名稱失敗,請再試一次", + "rename_saving": "儲存中…", + "enroll_name_taken": "此名稱已被另一個 ScriptCat 執行個體使用;這個配對碼已用掉,請修改名稱後重新執行 sctl connect 取得新的配對碼再提交。", "address_label": "sctl 位址", "policy_title": "權限原則", "policy_write": "寫入操作", @@ -42,6 +57,8 @@ "enroll_perm_write": "請求安裝 · 啟用 · 刪除(依寫入操作原則確認)", "enroll_perm_source": "讀取腳本原始碼(依原始碼原則確認)", "enroll_submit": "接入", + "enroll_submitting": "接入中…", + "enroll_failed": "接入失敗,請再試一次", "cancel": "取消", "enroll_started": "已提交配對碼,正在接入 sctl…", "confirm_via_external": "透過外部接入觸發", diff --git a/src/pages/components/ui/pairing-code-input.tsx b/src/pages/components/ui/pairing-code-input.tsx index 0b8e71457..963abd0ee 100644 --- a/src/pages/components/ui/pairing-code-input.tsx +++ b/src/pages/components/ui/pairing-code-input.tsx @@ -117,10 +117,10 @@ export function PairingCodeInput({ data-testid={testId} role="group" aria-label={ariaLabel} - className={cn("flex items-center justify-center gap-2", className)} + className={cn("flex items-center justify-center gap-1.5 md:gap-2", className)} > {chars.map((char, index) => ( -
+
{groupSize > 0 && index > 0 && index % groupSize === 0 && ( )} @@ -144,7 +144,8 @@ export function PairingCodeInput({ onPaste={(e) => handlePaste(index, e)} onFocus={(e) => e.target.select()} className={cn( - "size-10 rounded-md border border-input bg-background text-center font-mono text-lg font-semibold uppercase text-foreground shadow-sm outline-none transition-colors", + // 8 格 + 分隔符在 40px 下约 396px 宽,超过手机宽度对话框的内容区;窄屏缩到 32px。 + "size-8 rounded-md border border-input bg-background text-center font-mono text-base font-semibold uppercase text-foreground shadow-sm outline-none transition-colors md:size-10 md:text-lg", "focus-visible:border-primary focus-visible:ring-1 focus-visible:ring-primary", "disabled:cursor-not-allowed disabled:opacity-50" )} diff --git a/src/pages/options/routes/Tools/sections/ExternalAccessSection.test.tsx b/src/pages/options/routes/Tools/sections/ExternalAccessSection.test.tsx index 7b51fff61..3d19b1f1b 100644 --- a/src/pages/options/routes/Tools/sections/ExternalAccessSection.test.tsx +++ b/src/pages/options/routes/Tools/sections/ExternalAccessSection.test.tsx @@ -1,16 +1,20 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react"; +import type { + ExternalAccessBridgeStatusInfo, + ExternalAccessEnrollResult, + ExternalAccessRenameResult, +} from "@App/app/service/service_worker/external_access/types"; import { initTestLanguage } from "@Tests/initTestLanguage"; -const { getBridgeStatus, enroll, stopExternalAccess } = vi.hoisted(() => ({ - getBridgeStatus: vi.fn( - (): Promise<{ status: string; daemonVersion?: string }> => Promise.resolve({ status: "connected" }) - ), - enroll: vi.fn(() => Promise.resolve()), +const { getBridgeStatus, enroll, rename, stopExternalAccess } = vi.hoisted(() => ({ + getBridgeStatus: vi.fn<() => Promise>(), + enroll: vi.fn<(code: string, name: string) => Promise>(), + rename: vi.fn<(name: string) => Promise>(), stopExternalAccess: vi.fn(() => Promise.resolve()), })); vi.mock("@App/pages/store/features/script", () => ({ - externalAccessClient: { getBridgeStatus, enroll, stopExternalAccess }, + externalAccessClient: { getBridgeStatus, enroll, rename, stopExternalAccess }, })); const navigate = vi.hoisted(() => vi.fn()); @@ -19,6 +23,7 @@ vi.mock("react-router-dom", () => ({ useNavigate: () => navigate })); const notify = vi.hoisted(() => ({ success: vi.fn(), info: vi.fn(), + error: vi.fn(), })); vi.mock("@App/pages/components/ui/toast", () => ({ notify })); @@ -66,10 +71,12 @@ beforeAll(() => initTestLanguage("zh-CN")); beforeEach(() => { vi.clearAllMocks(); get.mockResolvedValue(true); - getBridgeStatus.mockResolvedValue({ status: "connected" }); + getBridgeStatus.mockResolvedValue({ status: "connected", name: "work" }); getExternalAccessWritePolicy.mockResolvedValue("approval"); getExternalAccessSourceReadPolicy.mockResolvedValue("approval"); getExternalAccessUrl.mockResolvedValue("ws://localhost:8643"); + enroll.mockResolvedValue("ok"); + rename.mockResolvedValue("ok"); }); afterEach(cleanup); @@ -80,7 +87,7 @@ async function renderSection() { describe("ExternalAccessSection(外部接入单卡片)", () => { it("已接入状态展示状态胶囊、策略、查看审计与停止外部接入", async () => { - getBridgeStatus.mockResolvedValue({ status: "connected" }); + getBridgeStatus.mockResolvedValue({ status: "connected", name: "work" }); await renderSection(); expect(await screen.findByTestId("external_access_status_pill")).toBeInTheDocument(); expect(screen.getByTestId("external_access_write_policy_approval")).toBeInTheDocument(); @@ -90,14 +97,14 @@ describe("ExternalAccessSection(外部接入单卡片)", () => { }); it("待接入状态展示地址输入与「接入 sctl」按钮", async () => { - getBridgeStatus.mockResolvedValue({ status: "pending_enrollment" }); + getBridgeStatus.mockResolvedValue({ status: "pending_enrollment", name: "work" }); await renderSection(); expect(await screen.findByTestId("external_access_url_input")).toBeInTheDocument(); expect(screen.getByTestId("external_access_enroll_open")).toBeInTheDocument(); }); it("提交配对码后调用 enroll 并显示进行中提示", async () => { - getBridgeStatus.mockResolvedValue({ status: "pending_enrollment" }); + getBridgeStatus.mockResolvedValue({ status: "pending_enrollment", name: "home-browser" }); await renderSection(); fireEvent.click(await screen.findByTestId("external_access_enroll_open")); // 8 格 OTP 输入:粘贴整段配对码到第一格,一次填满 @@ -106,13 +113,13 @@ describe("ExternalAccessSection(外部接入单卡片)", () => { await act(async () => { fireEvent.click(screen.getByTestId("external_access_enroll_submit")); }); - expect(enroll).toHaveBeenCalledWith("ABCD1234"); + expect(enroll).toHaveBeenCalledWith("ABCD1234", "home-browser"); expect(notify.info).toHaveBeenCalledWith("已提交配对码,正在接入 sctl…"); expect(notify.success).not.toHaveBeenCalled(); }); it("帮助入口与待接入文档入口深链到外部接入指南", async () => { - getBridgeStatus.mockResolvedValue({ status: "pending_enrollment" }); + getBridgeStatus.mockResolvedValue({ status: "pending_enrollment", name: "work" }); await renderSection(); const expectedDocUrl = /^https:\/\/docs\.scriptcat\.org(?:\/en)?\/docs\/use\/external-access\/$/; @@ -127,13 +134,13 @@ describe("ExternalAccessSection(外部接入单卡片)", () => { }); it("已接入状态在状态条显示 sctl 版本号", async () => { - getBridgeStatus.mockResolvedValue({ status: "connected", daemonVersion: "0.3.1" }); + getBridgeStatus.mockResolvedValue({ status: "connected", name: "work", daemonVersion: "0.3.1" }); await renderSection(); expect(await screen.findByTestId("external_access_daemon_version")).toHaveTextContent("sctl v0.3.1"); }); it("写操作策略切到「直接允许」时写入配置并显示琥珀警示", async () => { - getBridgeStatus.mockResolvedValue({ status: "connected" }); + getBridgeStatus.mockResolvedValue({ status: "connected", name: "work" }); await renderSection(); fireEvent.click(await screen.findByTestId("external_access_write_policy_allow")); expect(setExternalAccessWritePolicy).toHaveBeenCalledWith("allow"); @@ -141,14 +148,14 @@ describe("ExternalAccessSection(外部接入单卡片)", () => { }); it("源码读取策略切到「直接允许」时写入配置", async () => { - getBridgeStatus.mockResolvedValue({ status: "connected" }); + getBridgeStatus.mockResolvedValue({ status: "connected", name: "work" }); await renderSection(); fireEvent.click(await screen.findByTestId("external_access_source_policy_allow")); expect(setExternalAccessSourceReadPolicy).toHaveBeenCalledWith("allow"); }); it("查看审计日志深链到日志页并以 component=external-access 预过滤", async () => { - getBridgeStatus.mockResolvedValue({ status: "connected" }); + getBridgeStatus.mockResolvedValue({ status: "connected", name: "work" }); await renderSection(); fireEvent.click(await screen.findByTestId("external_access_view_audit")); expect(navigate).toHaveBeenCalledWith(expect.stringContaining("/logs?query=")); @@ -156,9 +163,150 @@ describe("ExternalAccessSection(外部接入单卡片)", () => { }); it("关闭启用开关写入 external_access_enabled=false", async () => { - getBridgeStatus.mockResolvedValue({ status: "connected" }); + getBridgeStatus.mockResolvedValue({ status: "connected", name: "work" }); await renderSection(); fireEvent.click(await screen.findByTestId("external_access_enable_switch")); expect(set).toHaveBeenCalledWith("external_access_enabled", false); }); + + it("已接入状态显示实例名称与修改按钮", async () => { + getBridgeStatus.mockResolvedValue({ status: "connected", name: "work" }); + await renderSection(); + expect(await screen.findByTestId("external_access_name_value")).toHaveTextContent("work"); + expect(screen.getByTestId("external_access_name_edit")).toBeInTheDocument(); + }); + + it("修改名称为不合规值时即时提示并禁用保存", async () => { + getBridgeStatus.mockResolvedValue({ status: "connected", name: "work" }); + await renderSection(); + fireEvent.click(await screen.findByTestId("external_access_name_edit")); + const input = await screen.findByTestId("external_access_name_input"); + fireEvent.change(input, { target: { value: "Bad Name" } }); + expect(screen.getByTestId("external_access_name_error")).toHaveTextContent( + "名称需为 1–32 位小写字母、数字或 -,且不能是 scriptcat。" + ); + expect(screen.getByTestId("external_access_name_save")).toBeDisabled(); + + fireEvent.change(input, { target: { value: "office" } }); + expect(screen.queryByTestId("external_access_name_error")).not.toBeInTheDocument(); + expect(screen.getByTestId("external_access_name_save")).toBeEnabled(); + }); + + it("保存名称成功后显示成功提示并更新当前名称", async () => { + getBridgeStatus.mockResolvedValue({ status: "connected", name: "work" }); + rename.mockResolvedValue("ok"); + await renderSection(); + fireEvent.click(await screen.findByTestId("external_access_name_edit")); + fireEvent.change(await screen.findByTestId("external_access_name_input"), { target: { value: "office" } }); + await act(async () => { + fireEvent.click(screen.getByTestId("external_access_name_save")); + }); + expect(rename).toHaveBeenCalledWith("office"); + expect(notify.success).toHaveBeenCalledWith("实例名称已更新"); + expect(await screen.findByTestId("external_access_name_value")).toHaveTextContent("office"); + expect(screen.queryByTestId("external_access_name_input")).not.toBeInTheDocument(); + }); + + it("未连接时保存名称提示下次连接生效", async () => { + getBridgeStatus.mockResolvedValue({ status: "host_unreachable", name: "work" }); + rename.mockResolvedValue("applies-next-connection"); + await renderSection(); + fireEvent.click(await screen.findByTestId("external_access_name_edit")); + fireEvent.change(await screen.findByTestId("external_access_name_input"), { target: { value: "office" } }); + await act(async () => { + fireEvent.click(screen.getByTestId("external_access_name_save")); + }); + expect(rename).toHaveBeenCalledWith("office"); + expect(notify.info).toHaveBeenCalledWith("已保存,新名称将在下次连接时生效"); + expect(await screen.findByTestId("external_access_name_value")).toHaveTextContent("office"); + expect(notify.success).not.toHaveBeenCalled(); + }); + + it("保存名称冲突时留在编辑状态并提示名称已被占用", async () => { + getBridgeStatus.mockResolvedValue({ status: "connected", name: "work" }); + rename.mockResolvedValue("name-taken"); + await renderSection(); + fireEvent.click(await screen.findByTestId("external_access_name_edit")); + fireEvent.change(await screen.findByTestId("external_access_name_input"), { target: { value: "taken" } }); + await act(async () => { + fireEvent.click(screen.getByTestId("external_access_name_save")); + }); + expect(rename).toHaveBeenCalledWith("taken"); + expect(await screen.findByTestId("external_access_name_error")).toHaveTextContent( + "该名称已被另一个 ScriptCat 实例使用" + ); + expect(screen.getByTestId("external_access_name_input")).toHaveValue("taken"); + expect(notify.success).not.toHaveBeenCalled(); + }); + + it("接入对话框预填当前实例名称,名称不合规时禁用提交", async () => { + getBridgeStatus.mockResolvedValue({ status: "pending_enrollment", name: "home-browser" }); + await renderSection(); + fireEvent.click(await screen.findByTestId("external_access_enroll_open")); + const nameInput = await screen.findByTestId("external_access_enroll_name_input"); + expect(nameInput).toHaveValue("home-browser"); + + const cell0 = await screen.findByTestId("external_access_enroll_code-cell-0"); + fireEvent.paste(cell0, { clipboardData: { getData: () => "ABCD1234" } }); + fireEvent.change(nameInput, { target: { value: "Home Browser" } }); + expect(await screen.findByTestId("external_access_enroll_name_error")).toHaveTextContent( + "名称需为 1–32 位小写字母、数字或 -,且不能是 scriptcat。" + ); + expect(screen.getByTestId("external_access_enroll_submit")).toBeDisabled(); + }); + + it("接入时名称冲突保持对话框打开,提示名称与配对码均不可用并清空配对码", async () => { + getBridgeStatus.mockResolvedValue({ status: "pending_enrollment", name: "home-browser" }); + enroll.mockResolvedValue("name-taken"); + await renderSection(); + fireEvent.click(await screen.findByTestId("external_access_enroll_open")); + const cell0 = await screen.findByTestId("external_access_enroll_code-cell-0"); + fireEvent.paste(cell0, { clipboardData: { getData: () => "ABCD1234" } }); + await act(async () => { + fireEvent.click(screen.getByTestId("external_access_enroll_submit")); + }); + expect(enroll).toHaveBeenCalledWith("ABCD1234", "home-browser"); + const error = await screen.findByTestId("external_access_enroll_name_error"); + expect(error).toHaveTextContent("该名称已被另一个 ScriptCat 实例使用"); + expect(error).toHaveTextContent("这个配对码已用掉"); + expect(screen.getByTestId("external_access_enroll_name_input")).toBeInTheDocument(); + expect(screen.getByTestId("external_access_enroll_code-cell-0")).toHaveValue(""); + expect(screen.getByTestId("external_access_enroll_submit")).toBeDisabled(); + expect(notify.info).not.toHaveBeenCalled(); + }); + + it("接入请求本身失败时提示失败并保留对话框以便重试", async () => { + getBridgeStatus.mockResolvedValue({ status: "pending_enrollment", name: "home-browser" }); + enroll.mockRejectedValue(new Error("offscreen unavailable")); + await renderSection(); + fireEvent.click(await screen.findByTestId("external_access_enroll_open")); + const cell0 = await screen.findByTestId("external_access_enroll_code-cell-0"); + fireEvent.paste(cell0, { clipboardData: { getData: () => "ABCD1234" } }); + await act(async () => { + fireEvent.click(screen.getByTestId("external_access_enroll_submit")); + }); + expect(notify.error).toHaveBeenCalledWith("接入失败,请重试"); + expect(notify.info).not.toHaveBeenCalled(); + expect(screen.getByTestId("external_access_enroll_submit")).toBeEnabled(); + }); + + it("被 sctl 拒绝(refused)时说明原因并可重新接入", async () => { + getBridgeStatus.mockResolvedValue({ status: "rejected", reason: "refused", name: "work" }); + await renderSection(); + expect(await screen.findByTestId("external_access_rejected_reason")).toHaveTextContent( + "sctl 拒绝了这个 ScriptCat 实例" + ); + fireEvent.click(screen.getByTestId("external_access_reenroll")); + expect(await screen.findByTestId("external_access_enroll_name_input")).toHaveValue("work"); + }); + + it("被 sctl 拒绝(name-taken)时说明冲突并可进入改名", async () => { + getBridgeStatus.mockResolvedValue({ status: "rejected", reason: "name-taken", name: "work" }); + await renderSection(); + expect(await screen.findByTestId("external_access_rejected_reason")).toHaveTextContent( + "该名称已被另一个 ScriptCat 实例使用" + ); + fireEvent.click(screen.getByTestId("external_access_edit_name")); + expect(await screen.findByTestId("external_access_name_input")).toHaveValue("work"); + }); }); diff --git a/src/pages/options/routes/Tools/sections/ExternalAccessSection.tsx b/src/pages/options/routes/Tools/sections/ExternalAccessSection.tsx index 7afe4ead9..021b71485 100644 --- a/src/pages/options/routes/Tools/sections/ExternalAccessSection.tsx +++ b/src/pages/options/routes/Tools/sections/ExternalAccessSection.tsx @@ -1,7 +1,19 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { BookOpen, ShieldAlert, ScrollText, Terminal, PlugZap, Check, Download, Eye, Power } from "lucide-react"; +import { + BookOpen, + ShieldAlert, + ScrollText, + Terminal, + PlugZap, + Check, + Download, + Eye, + Loader2, + Pencil, + Power, +} from "lucide-react"; import { SettingCard } from "../../../components/SettingCard"; import { SettingRow } from "../../../components/SettingRow"; import { Switch } from "@App/pages/components/ui/switch"; @@ -25,7 +37,11 @@ import type { ExternalAccessBridgeStatus, ExternalAccessBridgeStatusInfo, } from "@App/app/service/service_worker/external_access/types"; -import type { ExternalAccessWritePolicy, ExternalAccessSourceReadPolicy } from "@App/pkg/config/config"; +import { + isValidExternalAccessName, + type ExternalAccessWritePolicy, + type ExternalAccessSourceReadPolicy, +} from "@App/pkg/config/config"; import { notify } from "@App/pages/components/ui/toast"; import { DocumentationSite } from "@App/app/const"; import { localePath } from "@App/locales/locales"; @@ -38,6 +54,7 @@ const STATUS_VARIANT: Record + {t(labelKey[status])} ); @@ -122,13 +144,20 @@ export function ExternalAccessSection({ register }: { register: (id: string) => const { t } = useTranslation(); const navigate = useNavigate(); const [enabled, setEnabled] = useState(false); - const [status, setStatus] = useState("disabled"); - const [daemonVersion, setDaemonVersion] = useState(); + const [statusInfo, setStatusInfo] = useState(); + const { status = "disabled", daemonVersion } = statusInfo ?? {}; const [writePolicy, setWritePolicy] = useState("approval"); const [sourcePolicy, setSourcePolicy] = useState("approval"); const [mcpUrl, setExternalAccessUrl] = useState(""); const [showEnroll, setShowEnroll] = useState(false); const [code, setCode] = useState(""); + const [enrollName, setEnrollName] = useState(""); + const [enrollNameError, setEnrollNameError] = useState(); + const [enrolling, setEnrolling] = useState(false); + const [nameEditing, setNameEditing] = useState(false); + const [nameDraft, setNameDraft] = useState(""); + const [nameError, setNameError] = useState(); + const [savingName, setSavingName] = useState(false); useEffect(() => { void Promise.resolve(systemConfig.get("external_access_enabled")).then((v) => setEnabled(Boolean(v))); @@ -137,19 +166,13 @@ export function ExternalAccessSection({ register }: { register: (id: string) => void systemConfig.getExternalAccessUrl().then(setExternalAccessUrl); void externalAccessClient .getBridgeStatus() - .then((info) => { - setStatus(info.status); - setDaemonVersion(info.daemonVersion); - }) - .catch(() => setStatus("disabled")); + .then(setStatusInfo) + .catch(() => setStatusInfo(undefined)); }, []); // ExternalAccessController 的状态机在 SW 里推进(接入完成、hello 到达、socket 断开),页面订阅广播以实时更新。 useEffect(() => { - return subscribeMessage("mcpStatusChanged", (data) => { - setStatus(data.status); - setDaemonVersion(data.daemonVersion); - }); + return subscribeMessage("mcpStatusChanged", setStatusInfo); }, []); const handleEnableToggle = (checked: boolean) => { @@ -172,14 +195,84 @@ export function ExternalAccessSection({ register }: { register: (id: string) => if (trimmed) systemConfig.setExternalAccessUrl(trimmed); }; + const nameInvalid = !isValidExternalAccessName(nameDraft); + const enrollNameInvalid = !isValidExternalAccessName(enrollName); + + // 对话框可能从待接入态或拒绝态的「重新接入」打开;两处都用当前名称预填并清掉上一轮的码与提示。 + const openEnroll = () => { + setCode(""); + setEnrollName(statusInfo?.name ?? ""); + setEnrollNameError(undefined); + setShowEnroll(true); + }; + + const startNameEdit = () => { + setNameDraft(statusInfo?.name ?? ""); + setNameError(undefined); + setNameEditing(true); + }; + + const cancelNameEdit = () => { + setNameDraft(statusInfo?.name ?? ""); + setNameError(undefined); + setNameEditing(false); + }; + const handleEnroll = async () => { const c = code.trim(); - if (!c) return; - await externalAccessClient.enroll(c); - setCode(""); - setShowEnroll(false); - // 此刻只是把配对码交给 daemon,握手成败未知(码错/sctl 未运行都会转「连接失败」),故是进行中而非成功。 - notify.info(t("external_access:enroll_started")); + if (!c || !statusInfo || enrollNameInvalid || enrolling) return; + setEnrolling(true); + setEnrollNameError(undefined); + try { + const result = await externalAccessClient.enroll(c, enrollName); + if (result === "name-taken") { + // sctl 在认证通过即消费配对码;冲突后旧码不可再用,必须改名并重新运行 sctl connect。 + setCode(""); + setEnrollNameError(t("external_access:enroll_name_taken")); + return; + } + if (result === "invalid-name") { + setEnrollNameError(t("external_access:name_invalid_hint")); + return; + } + setCode(""); + setShowEnroll(false); + // 名称问题之外的结果沿用原有的进行中提示:sctl 未运行时 offscreen 仍在重连,最终成败以状态胶囊为准。 + notify.info(t("external_access:enroll_started")); + } catch { + notify.error(t("external_access:enroll_failed")); + } finally { + setEnrolling(false); + } + }; + + const handleRename = async () => { + if (nameInvalid || savingName) return; + setSavingName(true); + setNameError(undefined); + try { + const result = await externalAccessClient.rename(nameDraft); + if (result === "ok" || result === "applies-next-connection") { + setStatusInfo((info) => (info ? { ...info, name: nameDraft } : info)); + setNameEditing(false); + if (result === "ok") notify.success(t("external_access:rename_success")); + else notify.info(t("external_access:rename_next_connection")); + return; + } + if (result === "name-taken") { + setNameError(t("external_access:name_taken")); + return; + } + if (result === "invalid-name") { + setNameError(t("external_access:name_invalid_hint")); + return; + } + setNameError(t("external_access:rename_not_connected")); + } catch { + setNameError(t("external_access:rename_failed")); + } finally { + setSavingName(false); + } }; const handleStop = async () => { @@ -286,7 +379,7 @@ export function ExternalAccessSection({ register }: { register: (id: string) =>
- @@ -323,22 +416,136 @@ export function ExternalAccessSection({ register }: { register: (id: string) => data-testid="external_access_retry" size="xs" variant="outline" - onClick={() => void externalAccessClient.getBridgeStatus().then((info) => setStatus(info.status))} + onClick={() => void externalAccessClient.getBridgeStatus().then(setStatusInfo)} > {t("external_access:retry")} )} + {status !== "rejected" && ( + + )} +
+
+ + {status === "rejected" && ( +
+
+ +

+ {statusInfo?.reason === "name-taken" + ? t("external_access:rejected_name_taken_reason") + : t("external_access:rejected_refused_reason")} +

+
+ {statusInfo?.reason === "name-taken" ? ( + + ) : ( + + )} +
+ )} + + {nameEditing ? ( +
+ + {t("external_access:instance_name_label")} + +
+ { + setNameDraft(e.target.value); + setNameError(undefined); + }} + disabled={savingName} + autoFocus + className="w-full font-mono text-xs md:max-w-xs" + /> +
+ + +
+
+ {(nameInvalid || nameError) && ( +

+ {nameError ?? t("external_access:name_invalid_hint")} +

+ )} +
+ ) : ( +
+
+ + {t("external_access:instance_name_label")} + + + {statusInfo?.name ?? "—"} + +
- + )} {renderPolicies()} @@ -396,10 +603,32 @@ export function ExternalAccessSection({ register }: { register: (id: string) => aria-label={t("external_access:enroll_dialog_title")} value={code} onChange={setCode} + disabled={enrolling} autoFocus /> +
+ {t("external_access:instance_name_label")} + { + setEnrollName(e.target.value); + setEnrollNameError(undefined); + }} + disabled={enrolling} + className="font-mono text-xs" + /> + {(enrollNameInvalid || enrollNameError) && ( +

+ {enrollNameError ?? t("external_access:name_invalid_hint")} +

+ )} +
+
{t("external_access:enroll_perms_title")} {[ @@ -416,15 +645,21 @@ export function ExternalAccessSection({ register }: { register: (id: string) =>
- diff --git a/src/pkg/config/config.test.ts b/src/pkg/config/config.test.ts index a31c26936..6b25afe51 100644 --- a/src/pkg/config/config.test.ts +++ b/src/pkg/config/config.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { SystemConfig } from "./config"; +import * as browserUtils from "../utils/utils"; +import { mockWebLocks } from "@Tests/mockWebLocks"; import { MessageQueue } from "@Packages/message/message_queue"; describe("SystemConfig 双 storage 与懒迁移", () => { @@ -7,6 +9,7 @@ describe("SystemConfig 双 storage 与懒迁移", () => { let config: SystemConfig; beforeEach(() => { + mockWebLocks(); // 清空 storage 数据 chrome.storage.sync.clear(); chrome.storage.local.clear(); @@ -19,6 +22,34 @@ describe("SystemConfig 双 storage 与懒迁移", () => { }); describe("local key 读写", () => { + it.each(["chrome", "edge", "firefox", "safari", "scriptcat"])( + "首次读取创建 %s 身份且跨配置对象保持稳定、不参与同步", + async (slug) => { + vi.spyOn(browserUtils, "getBrowserType").mockReturnValue({ + firefox: Number(slug === "firefox"), + webkit: Number(slug === "safari"), + chrome: slug === "edge" ? browserUtils.BrowserType.Edge : Number(slug === "chrome"), + unknown: Number(slug === "scriptcat"), + chromeVersion: 0, + device: 0, + }); + expect(await chrome.storage.local.get("system_external_access_identity")).toEqual({}); + const other = new SystemConfig(mq); + const [identity, concurrent] = await Promise.all([ + config.getExternalAccessIdentity(), + other.getExternalAccessIdentity(), + ]); + expect(identity.instanceId).toMatch(/^[0-9a-f]{32}$/); + expect(identity.name).toBe(`${slug}-${identity.instanceId.slice(0, 4)}`); + expect(concurrent).toEqual(identity); + expect(await new SystemConfig(mq).getExternalAccessIdentity()).toEqual(identity); + expect(await config.getExternalAccessIdentity()).toEqual(identity); + expect(await chrome.storage.local.get("system_external_access_identity")).toEqual({ + system_external_access_identity: identity, + }); + expect(await chrome.storage.sync.get("system_external_access_identity")).toEqual({}); + } + ); it("cloud_sync 应写入 local storage 而非 sync", async () => { const cloudSync = { enable: true, @@ -148,11 +179,11 @@ describe("SystemConfig 双 storage 与懒迁移", () => { }); it("external_access_pairing 默认未配对,写入后落 local storage 且不入 sync", async () => { - expect(await config.getExternalAccessPairing()).toEqual({ key: "", clientId: "" }); - config.setExternalAccessPairing({ key: "deadbeef", clientId: "cid-1" }); - expect(await config.getExternalAccessPairing()).toEqual({ key: "deadbeef", clientId: "cid-1" }); + expect(await config.getExternalAccessPairing()).toEqual({ key: "" }); + config.setExternalAccessPairing({ key: "deadbeef" }); + expect(await config.getExternalAccessPairing()).toEqual({ key: "deadbeef" }); const localData = await chrome.storage.local.get("system_external_access_pairing"); - expect(localData["system_external_access_pairing"]).toEqual({ key: "deadbeef", clientId: "cid-1" }); + expect(localData["system_external_access_pairing"]).toEqual({ key: "deadbeef" }); const syncData = await chrome.storage.sync.get("system_external_access_pairing"); expect(syncData["system_external_access_pairing"]).toBeUndefined(); }); diff --git a/src/pkg/config/config.ts b/src/pkg/config/config.ts index fbd630de8..89d8b0e5c 100644 --- a/src/pkg/config/config.ts +++ b/src/pkg/config/config.ts @@ -7,7 +7,7 @@ import { matchLanguage } from "@App/locales/locales"; import { ExtVersion } from "@App/app/const"; import defaultTypeDefinition from "@App/template/scriptcat.d.tpl"; import type { ScriptTemplateOverrides } from "@App/pkg/utils/script_template"; -import { toCamelCase } from "../utils/utils"; +import { BrowserType, getBrowserType, getBrowserVersion, toCamelCase } from "../utils/utils"; import EventEmitter from "eventemitter3"; import { STORAGE_LOCAL_KEYS } from "./consts"; @@ -46,13 +46,42 @@ export type FaviconService = "none" | "scriptcat" | "google" | "duckduckgo" | "i export type ExternalAccessWritePolicy = "approval" | "allow"; export type ExternalAccessSourceReadPolicy = "approval" | "allow"; -// MCP 配对成功后落地的长期共享密钥 K(小写 hex)与本扩展实例的客户端身份。 // key 为空串表示尚未配对。仅存 chrome.storage.local,绝不跨设备同步。 export type ExternalAccessPairing = { key: string; - clientId: string; }; +export type ExternalAccessIdentity = { + instanceId: string; + name: string; +}; + +// 与 sctl 的实例名称规则一致。scriptcat 是 sctl 为旧版 ScriptCat 保留的名称,daemon 必然拒绝; +// 接入时它会在认证后才被拒,白白消耗一次性配对码,所以在本地就拦下。 +export function isValidExternalAccessName(name: unknown): name is string { + return typeof name === "string" && /^[a-z0-9-]{1,32}$/.test(name) && name !== "scriptcat"; +} + +export function externalAccessProduct() { + const browser = getBrowserType(); + if (browser.firefox) return "Firefox"; + if (browser.webkit) return "Safari"; + if (browser.chrome & BrowserType.Edge) return "Edge"; + if (browser.chrome & BrowserType.Chrome) return "Chrome"; + return undefined; +} + +// getBrowserVersion() 只认 Chrome/ 标记;Firefox 与 Safari 的 UA 不带它,各自从品牌版本标记读取。 +// UA 可被用户改写;sctl 限定自报文本不超过 64 字节,超长会被 daemon 断开,而这里的匹配结果只含 ASCII 数字与点。 +export function externalAccessProductVersion(): string | undefined { + const browser = getBrowserType(); + if (browser.firefox) return navigator.userAgent.match(/Firefox\/(\d[\d.]*)/)?.[1]?.slice(0, 64); + if (browser.webkit) return navigator.userAgent.match(/Version\/(\d[\d.]*)/)?.[1]?.slice(0, 64); + if (!browser.chrome) return undefined; + const version = getBrowserVersion(); + return Number.isFinite(version) && version > 0 ? String(version) : undefined; +} + export type CATFileStorage = { filesystem: FileSystemType; params: { [key: string]: any }; @@ -713,7 +742,38 @@ export class SystemConfig { } getExternalAccessPairing() { - return this._get("external_access_pairing", { key: "", clientId: "" }); + return this._get("external_access_pairing", { key: "" }); + } + + getExternalAccessIdentity(): Promise { + return this.externalAccessIdentity(); + } + + setExternalAccessName(name: string): Promise { + return this.externalAccessIdentity(name); + } + + private async externalAccessIdentity(name?: string): Promise { + // 扩展页面与后台可能同时首次读取;用 Web Locks 串行化读-生成-写,保证只生成一份身份。 + // 身份没有历史同步数据,不能走普通配置的 sync → local 迁移路径。 + return navigator.locks.request("scriptcat-external-access-identity", async () => { + const key = "system_external_access_identity"; + const stored = await chrome.storage.local.get(key); + if (stored[key]) { + const identity = stored[key] as ExternalAccessIdentity; + if (name === undefined || identity.name === name) return identity; + const renamed = { ...identity, name }; + await chrome.storage.local.set({ [key]: renamed }); + return renamed; + } + const instanceId = Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => + byte.toString(16).padStart(2, "0") + ).join(""); + const slug = externalAccessProduct()?.toLowerCase() ?? "scriptcat"; + const identity = { instanceId, name: name ?? `${slug}-${instanceId.slice(0, 4)}` }; + await chrome.storage.local.set({ [key]: identity }); + return identity; + }); } setBlacklist(blacklist: string) { diff --git a/src/pkg/config/consts.ts b/src/pkg/config/consts.ts index d6ab4277b..27ada5037 100644 --- a/src/pkg/config/consts.ts +++ b/src/pkg/config/consts.ts @@ -14,7 +14,7 @@ export const STORAGE_LOCAL_KEYS: Set = new Set([ "external_access_url", // 外部接入 sctl 地址(设备相关) "external_access_write_policy", // 外部接入 · 写操作策略(设备相关) "external_access_source_read_policy", // 外部接入 · 源码读取策略(设备相关) - "external_access_pairing", // 外部接入长期共享密钥 K + 客户端身份(含密钥,绝不跨设备同步) + "external_access_pairing", // 外部接入长期共享密钥 K(绝不跨设备同步) "keep_ext_background_alive", // Chrome service worker 和 offscreen 保活开关 // 新建脚本模板:用户手写的代码,三份合计很容易超过 chrome.storage.sync 单项 8KB 配额, // 而 ChromeStorage.set 不检查 lastError,超额会静默丢失 diff --git a/tests/mockWebLocks.ts b/tests/mockWebLocks.ts new file mode 100644 index 000000000..e3aee8e63 --- /dev/null +++ b/tests/mockWebLocks.ts @@ -0,0 +1,16 @@ +import { vi } from "vitest"; + +// happy-dom 没有 Web Locks。所有锁名共用一条队列串行执行(比真实实现更严格),被锁保护的读写仍走真实的 chrome.storage mock。 +export function mockWebLocks() { + let tail = Promise.resolve(); + vi.spyOn(navigator, "locks", "get").mockReturnValue({ + request: (_name: string, run: () => Promise) => { + const result = tail.then(run); + tail = result.then( + () => undefined, + () => undefined + ); + return result; + }, + } as LockManager); +}