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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user