From 4a0d14ce56d15a72f5501b04314719edd296efea Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Mon, 3 Aug 2026 12:54:48 +0800 Subject: [PATCH] fix(bridge): keep account recovery non-interactive Keep background account discovery passive and distinguish an identified account from a ready session. Update the process dialog and regression coverage so recovery waits for authoritative readiness before exposing the account as online. --- packages/bridge/src/hook-session.ts | 2 +- packages/bridge/src/qq-port-probe.ts | 115 ++---------------- packages/bridge/tests/hook-session.test.ts | 28 ++--- packages/bridge/tests/qq-port-probe.test.ts | 26 +++- .../src/components/process-probe-dialog.tsx | 13 +- .../webui/tests/api-client-timeout.test.ts | 4 +- 6 files changed, 58 insertions(+), 130 deletions(-) diff --git a/packages/bridge/src/hook-session.ts b/packages/bridge/src/hook-session.ts index 8052b6c3..eecb61ac 100644 --- a/packages/bridge/src/hook-session.ts +++ b/packages/bridge/src/hook-session.ts @@ -685,7 +685,7 @@ export class HookSession extends EventEmitter { } // The await yielded — re-check we still want this before mutating state. if (this.disposed || !this.connected || this.loggedIn) { this.stopLoginReconcile(); return; } - if (info?.loggedIn && isRealUin(info.uin)) { + if (info?.identityKnown && isRealUin(info.uin)) { const client = this.client; if (!client) return; if (this.acceptedLoginHintUin === info.uin) { diff --git a/packages/bridge/src/qq-port-probe.ts b/packages/bridge/src/qq-port-probe.ts index 4daa5c78..99db28b8 100644 --- a/packages/bridge/src/qq-port-probe.ts +++ b/packages/bridge/src/qq-port-probe.ts @@ -1,6 +1,5 @@ import { createLogger } from '@snowluma/common/logger'; import { exec } from 'child_process'; -import net from 'net'; import http from 'http'; import https from 'https'; import { promisify } from 'util'; @@ -8,9 +7,6 @@ import { promisify } from 'util'; const execAsync = promisify(exec); const log = createLogger('LoginProbe'); -const PORT_RANGE_START = 9210; -const PORT_RANGE_END = 9219; -const PROBE_TIMEOUT_MS = 1000; const CONNECTION_TIMEOUT_MS = 500; const COMMAND_TIMEOUT_MS = 1500; const OVERALL_PROBE_TIMEOUT_MS = 5000; @@ -28,102 +24,11 @@ export interface QqPortLoginInfo { uin: string; uid?: string; nickName?: string; - loggedIn: boolean; + /** The local QQ endpoint exposed an account identity. This does not prove + * the native protocol session is ready to send or receive packets. */ + identityKnown: boolean; } -interface JwtPayload { - errCode: number; - errMsg: string; - port: number; - uin?: string; - uid?: string; - nickName?: string; - data?: { - uin?: string; - url?: string; - }; - iat: number; -} - -function decodeJwt(token: string): JwtPayload | null { - try { - const parts = token.split('.'); - if (parts.length !== 3) return null; - const payload = Buffer.from(parts[1], 'base64').toString('utf8'); - return JSON.parse(payload); - } catch { - return null; - } -} - - -async function probePort(port: number): Promise { - return new Promise((resolve) => { - const client = new net.Socket(); - const link = 'tencent://'; - const payload = `POST /tencent HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nConnection: close\r\nContent-Length: ${link.length}\r\n\r\n${link}`; - - let responseData = ''; - let timer: NodeJS.Timeout; - - const cleanup = () => { - clearTimeout(timer); - client.removeAllListeners(); - client.destroy(); - }; - - timer = setTimeout(() => { - cleanup(); - resolve(null); - }, PROBE_TIMEOUT_MS); - - client.setTimeout(CONNECTION_TIMEOUT_MS); - - client.connect(port, '127.0.0.1', () => { - client.write(payload); - }); - - client.on('data', (data) => { - responseData += data.toString(); - }); - - client.on('close', () => { - cleanup(); - const jwtMatch = responseData.match(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/); - if (!jwtMatch) { - resolve(null); - return; - } - - const decoded = decodeJwt(jwtMatch[0]); - if (!decoded || decoded.errCode !== 0) { - resolve(null); - return; - } - - const uin = decoded.uin || decoded.data?.uin || ''; - resolve({ - port, - uin, - uid: decoded.uid, - nickName: decoded.nickName, - loggedIn: uin.length > 0, - }); - }); - - client.on('error', () => { - cleanup(); - resolve(null); - }); - - client.on('timeout', () => { - cleanup(); - resolve(null); - }); - }); -} - - /** One entry of the Ptlogin `pt_get_uins` JSONP array. Only the fields the * probe reads are modelled; QQ sends more but they're irrelevant here. */ interface PtloginUin { @@ -194,7 +99,7 @@ async function tryPtloginMethod(port: number): Promise p >= PORT_RANGE_START && p <= PORT_RANGE_END); - for (const port of deepLinkPorts) { - const info = await probePort(port); - if (info) return info; - } - + // Background discovery must remain passive. Interactive application + // endpoints are deliberately excluded from automatic probing. return null; } diff --git a/packages/bridge/tests/hook-session.test.ts b/packages/bridge/tests/hook-session.test.ts index 1dab2f79..28dca715 100644 --- a/packages/bridge/tests/hook-session.test.ts +++ b/packages/bridge/tests/hook-session.test.ts @@ -97,9 +97,9 @@ function makeSession(opts: { inject: vi.fn(() => ({ method: 'loadModuleManual' as const, handle: DUMMY_HANDLE })), unload: vi.fn(), }; - // Default probe reports "not logged in" so the reconcile safety net is inert - // unless a test opts into a logged-in probe (never hits the real port scan). - const probeLogin = opts.probeLogin ?? (async () => ({ port: 0, uin: '', loggedIn: false })); + // Default probe exposes no identity so the reconcile safety net is inert + // unless a test opts into a known account. + const probeLogin = opts.probeLogin ?? (async () => ({ port: 0, uin: '', identityKnown: false })); const session = new HookSession(pid, { injector, @@ -828,7 +828,7 @@ describe('HookSession — login reconcile (Docker auto-login safety net)', () => it('keeps the packet sender usable only after native readiness confirms the identity hint', async () => { const ctx = makeSession({ pipeLive: true, - probeLogin: async () => ({ port: 4301, uin: '10001', loggedIn: true }), + probeLogin: async () => ({ port: 4301, uin: '10001', identityKnown: true }), nativeReadyOnIdentityHint: true, }); let sender: PacketSender | undefined; @@ -860,7 +860,7 @@ describe('HookSession — login reconcile (Docker auto-login safety net)', () => // for a login-state confirmation instead of synthesizing one. const ctx = makeSession({ pipeLive: true, - probeLogin: async () => ({ port: 4301, uin: '10001', loggedIn: true }), + probeLogin: async () => ({ port: 4301, uin: '10001', identityKnown: true }), }); const loginSpy = vi.fn(); ctx.session.on('login', loginSpy); @@ -880,7 +880,7 @@ describe('HookSession — login reconcile (Docker auto-login safety net)', () => vi.useFakeTimers(); const ctx = makeSession({ pipeLive: true, - probeLogin: async () => ({ port: 4301, uin: '10001', loggedIn: true }), + probeLogin: async () => ({ port: 4301, uin: '10001', identityKnown: true }), }); try { await ctx.session.load(); @@ -900,7 +900,7 @@ describe('HookSession — login reconcile (Docker auto-login safety net)', () => let uin = '10001'; const ctx = makeSession({ pipeLive: true, - probeLogin: async () => ({ port: 4301, uin, loggedIn: true }), + probeLogin: async () => ({ port: 4301, uin, identityKnown: true }), }); try { await ctx.session.load(); @@ -924,7 +924,7 @@ describe('HookSession — login reconcile (Docker auto-login safety net)', () => vi.useFakeTimers(); const ctx = makeSession({ pipeLive: true, - probeLogin: async () => ({ port: 4301, uin: '10001', loggedIn: true }), + probeLogin: async () => ({ port: 4301, uin: '10001', identityKnown: true }), identityHintFailures: 3, }); try { @@ -950,7 +950,7 @@ describe('HookSession — login reconcile (Docker auto-login safety net)', () => it('does not log in when the probe reports not-logged-in', async () => { const ctx = makeSession({ pipeLive: true, - probeLogin: async () => ({ port: 0, uin: '', loggedIn: false }), + probeLogin: async () => ({ port: 0, uin: '', identityKnown: false }), }); const loginSpy = vi.fn(); ctx.session.on('login', loginSpy); @@ -965,7 +965,7 @@ describe('HookSession — login reconcile (Docker auto-login safety net)', () => it('ignores a probe with a non-real uin (0)', async () => { const ctx = makeSession({ pipeLive: true, - probeLogin: async () => ({ port: 4301, uin: '0', loggedIn: true }), + probeLogin: async () => ({ port: 4301, uin: '0', identityKnown: true }), }); const loginSpy = vi.fn(); ctx.session.on('login', loginSpy); @@ -994,10 +994,10 @@ describe('HookSession — login reconcile (Docker auto-login safety net)', () => it('detects a login that lands on a LATER interval probe', async () => { vi.useFakeTimers(); try { - let loggedIn = false; + let identityKnown = false; const ctx = makeSession({ pipeLive: true, - probeLogin: async () => ({ port: 4301, uin: '10001', loggedIn }), + probeLogin: async () => ({ port: 4301, uin: '10001', identityKnown }), nativeReadyOnIdentityHint: true, }); const loginSpy = vi.fn(); @@ -1008,7 +1008,7 @@ describe('HookSession — login reconcile (Docker auto-login safety net)', () => await vi.advanceTimersByTimeAsync(0); // connect + immediate probe (not logged in yet) expect(loginSpy).not.toHaveBeenCalled(); - loggedIn = true; // QQ finishes auto-login + identityKnown = true; // QQ exposes the restored identity await vi.advanceTimersByTimeAsync(3000); // next interval probe picks it up expect(loginSpy).toHaveBeenCalledOnce(); @@ -1021,7 +1021,7 @@ describe('HookSession — login reconcile (Docker auto-login safety net)', () => it('does not double-emit when the pushed frame logs in first', async () => { // Pushed-frame login wins; a subsequent probe with the same uin must not // re-emit (handleLoginState dedups), and the reconcile timer is stopped. - const probeLogin = vi.fn(async () => ({ port: 4301, uin: '10001', loggedIn: true })); + const probeLogin = vi.fn(async () => ({ port: 4301, uin: '10001', identityKnown: true })); const ctx = makeSession({ pipeLive: true, probeLogin }); const loginSpy = vi.fn(); ctx.session.on('login', loginSpy); diff --git a/packages/bridge/tests/qq-port-probe.test.ts b/packages/bridge/tests/qq-port-probe.test.ts index 6a3289e9..9f555700 100644 --- a/packages/bridge/tests/qq-port-probe.test.ts +++ b/packages/bridge/tests/qq-port-probe.test.ts @@ -1,8 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const execMock = vi.hoisted(() => vi.fn()); +const socketCtorMock = vi.hoisted(() => vi.fn(function MockSocket() { + throw new Error('interactive QQ probe must not run'); +})); vi.mock('child_process', () => ({ exec: execMock })); +vi.mock('net', () => ({ + default: { Socket: socketCtorMock }, + Socket: socketCtorMock, +})); import { probeQqLoginInfo } from '../src/qq-port-probe'; @@ -10,6 +17,7 @@ describe('probeQqLoginInfo — bounded execution', () => { beforeEach(() => { vi.useFakeTimers(); execMock.mockReset(); + socketCtorMock.mockClear(); }); afterEach(() => { @@ -33,7 +41,23 @@ describe('probeQqLoginInfo — bounded execution', () => { await expect(probeQqLoginInfo(4242)).resolves.toEqual({ port: 0, uin: '', - loggedIn: false, + identityKnown: false, }); }); + + it('keeps background identity discovery passive', async () => { + execMock + .mockImplementationOnce((_command, _options, callback) => { + callback(null, { + stdout: 'LISTEN 0 128 127.0.0.1:9218 0.0.0.0:* users:(("qq",pid=4242,fd=12))\n', + stderr: '', + }); + }) + .mockImplementationOnce((_command, _options, callback) => { + callback(null, { stdout: '8\n', stderr: '' }); + }); + + await expect(probeQqLoginInfo(4242)).resolves.toBeNull(); + expect(socketCtorMock).not.toHaveBeenCalled(); + }); }); diff --git a/packages/webui/src/components/process-probe-dialog.tsx b/packages/webui/src/components/process-probe-dialog.tsx index 4b3c9dca..79ce0518 100644 --- a/packages/webui/src/components/process-probe-dialog.tsx +++ b/packages/webui/src/components/process-probe-dialog.tsx @@ -12,7 +12,7 @@ interface QqPortLoginInfo { uin: string; uid?: string; nickName?: string; - loggedIn: boolean; + identityKnown: boolean; } interface ProcessProbeDialogProps { @@ -46,7 +46,7 @@ export function ProcessProbeDialog({ pid, processName, open, onOpenChange, onLoa if (activeProbe.current !== controller) return; setInfo(result as QqPortLoginInfo | null); if (!result) { - setError('未检测到登录信息(端口 9210-9219 无响应)'); + setError('未检测到可用的账号身份信息'); } } catch (err) { if (activeProbe.current !== controller || controller.signal.aborted) return; @@ -101,7 +101,7 @@ export function ProcessProbeDialog({ pid, processName, open, onOpenChange, onLoa ) : info ? (
- {info.loggedIn ? ( + {info.identityKnown ? (
@@ -112,7 +112,7 @@ export function ProcessProbeDialog({ pid, processName, open, onOpenChange, onLoa
{info.nickName || info.uin} - 已登录 + 已识别账号
UIN: {info.uin}
{info.uid && ( @@ -121,6 +121,9 @@ export function ProcessProbeDialog({ pid, processName, open, onOpenChange, onLoa
)}
端口: {info.port}
+
+ 此结果仅表示账号身份已识别,协议会话是否就绪以进程状态为准。 +
) : ( @@ -141,7 +144,7 @@ export function ProcessProbeDialog({ pid, processName, open, onOpenChange, onLoa - {!loading && info?.loggedIn && onLoad && ( + {!loading && info?.identityKnown && onLoad && ( diff --git a/packages/webui/tests/api-client-timeout.test.ts b/packages/webui/tests/api-client-timeout.test.ts index d8b3e8f3..b6c30b64 100644 --- a/packages/webui/tests/api-client-timeout.test.ts +++ b/packages/webui/tests/api-client-timeout.test.ts @@ -45,7 +45,7 @@ describe('API client request deadline', () => { }), ) .mockResolvedValueOnce(new Response(JSON.stringify({ - info: { port: 0, uin: '', loggedIn: false }, + info: { port: 0, uin: '', identityKnown: false }, }), { status: 200, headers: { 'content-type': 'application/json' }, @@ -61,7 +61,7 @@ describe('API client request deadline', () => { await expect(client.processes.probeLoginInfo(4242)).resolves.toEqual({ port: 0, uin: '', - loggedIn: false, + identityKnown: false, }); });