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.
This commit is contained in:
DrSmoothl
2026-08-03 12:54:48 +08:00
parent dae486a0ad
commit 4a0d14ce56
6 changed files with 58 additions and 130 deletions
+1 -1
View File
@@ -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) {
+8 -107
View File
@@ -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<QqPortLoginInfo | null> {
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<QqPortLoginInfo | 'fallba
port,
uin: String(account.uin || account.account || ''),
nickName: account.nickname || '',
loggedIn: true,
identityKnown: true,
};
}
@@ -264,7 +169,7 @@ async function probeQqLoginInfoInternal(pid: number): Promise<QqPortLoginInfo |
if (ports.length === 0) {
const totalPids = await getQqProcessCount();
if (totalPids < LOGGED_OUT_PROCESS_COUNT_MAX) {
return { port: 0, uin: '', loggedIn: false };
return { port: 0, uin: '', identityKnown: false };
}
return null;
}
@@ -294,17 +199,13 @@ async function probeQqLoginInfoInternal(pid: number): Promise<QqPortLoginInfo |
return {
port: ports[0] || 0,
uin: '',
loggedIn: false,
identityKnown: false,
};
}
}
const deepLinkPorts = ports.filter(p => 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;
}
+14 -14
View File
@@ -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);
+25 -1
View File
@@ -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();
});
});
@@ -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
</div>
) : info ? (
<div className="space-y-4">
{info.loggedIn ? (
{info.identityKnown ? (
<div className="flex items-center gap-4 rounded-lg border bg-card/50 p-4">
<Avatar className="size-14">
<AvatarImage src={qqAvatarUrl(info.uin)} alt={info.uin} />
@@ -112,7 +112,7 @@ export function ProcessProbeDialog({ pid, processName, open, onOpenChange, onLoa
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold">{info.nickName || info.uin}</span>
<Badge variant="success">已登录</Badge>
<Badge variant="secondary">已识别账号</Badge>
</div>
<div className="mt-1 text-sm text-muted-foreground">UIN: {info.uin}</div>
{info.uid && (
@@ -121,6 +121,9 @@ export function ProcessProbeDialog({ pid, processName, open, onOpenChange, onLoa
</div>
)}
<div className="mt-1 text-xs text-muted-foreground">端口: {info.port}</div>
<div className="mt-2 text-xs text-muted-foreground">
此结果仅表示账号身份已识别,协议会话是否就绪以进程状态为准。
</div>
</div>
</div>
) : (
@@ -141,7 +144,7 @@ export function ProcessProbeDialog({ pid, processName, open, onOpenChange, onLoa
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
关闭
</Button>
{!loading && info?.loggedIn && onLoad && (
{!loading && info?.identityKnown && onLoad && (
<Button size="sm" onClick={onLoad}>
加载
</Button>
@@ -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,
});
});