fix(desktop-client): stop false manual-verification states and auto-revive probes after sleep/offline

Challenge detection previously regex-matched the entire page innerText, so
chat transcripts or console copy containing words like 验证码/安全验证/verify
flagged healthy accounts as challenge_required. Detection is now scoped to
real evidence only (visible captcha vendor widgets, short modal copy, sparse
interstitial pages) via a shared challenge-signals module used by the generic
AI probe, doubao/kimi/qwen auth rules, and the deepseek runtime snapshot.
Platform API error codes (e.g. baijiahao_challenge_required) keep working.

Account health also could not recover after lid-close sleep or a network
drop: offline probes misread cached console pages as expired, and expired
records null out nextProbeAt so they were never re-probed. Probing is now
skipped while offline (network_error backoff instead), and powerMonitor
resume/unlock plus an offline-to-online transition watch schedule jittered
recovery probes for all tracked accounts, including expired ones. Challenge
rechecks wait 8s so transient widgets during page load no longer stick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 11:06:51 +08:00
parent c7bad83496
commit abacc1f421
7 changed files with 287 additions and 77 deletions
+74 -1
View File
@@ -2,7 +2,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import type { DesktopAccountInfo } from '@geo/shared-types'
import { app } from 'electron/main'
import { app, net, powerMonitor } from 'electron/main'
import type { PublishAccountIdentity } from './account-binder'
import type {
@@ -35,6 +35,11 @@ const MANUAL_PROBE_CACHE_TTL_MS = 5 * 60_000
const MANUAL_PROBE_MIN_INTERVAL_MS = 15_000
const ACCOUNT_PROBE_RETRY_BACKOFF_MS = [1_000, 3_000, 10_000] as const
const CHALLENGE_RECHECK_ATTEMPTS = 1
// 复核前留出缓冲:页面加载中的瞬态验证组件(无感验证/加载占位)几秒内会自行消失,
// 立即复核会把瞬态误报固化成"需人工验证"。
const CHALLENGE_RECHECK_DELAY_MS = process.env.VITEST ? 25 : 8_000
const RECOVERY_PROBE_MIN_MS = 2_000
const RECOVERY_PROBE_MAX_MS = 15_000
type ProbeTrigger = 'scheduled' | 'manual' | 'preflight' | 'confirm'
@@ -85,6 +90,20 @@ const probeQueue: string[] = []
let initialized = false
let probeTimer: ReturnType<typeof setInterval> | null = null
let activeProbeCount = 0
let recoveryWatchersInstalled = false
let lastKnownOnline = true
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function isNetworkOnline(): boolean {
try {
return net.isOnline()
} catch {
return true
}
}
function randomInt(min: number, max: number): number {
if (max <= min) {
@@ -176,13 +195,60 @@ function ensureInitialized(): void {
loadPersistedRecords()
}
// 休眠唤醒/断网恢复后,给所有被跟踪账号(包括已标记 expired、nextProbeAt 已被
// 置空的账号)重新排一次短延迟探测,让会话状态自动回到真实值,而不是停留在
// 断网期间产生的离线/失效快照上。
function scheduleRecoveryProbes(reason: string): void {
ensureInitialized()
const now = Date.now()
let touched = false
for (const accountId of trackedAccounts.keys()) {
const record = records.get(accountId)
if (!record || isProbeInFlight(record)) {
continue
}
record.nextProbeAt = now + randomInt(RECOVERY_PROBE_MIN_MS, RECOVERY_PROBE_MAX_MS)
touched = true
}
if (touched) {
persistRecords()
console.info('[desktop-account-health] recovery probes scheduled', { reason })
}
}
function ensureRecoveryWatchers(): void {
if (recoveryWatchersInstalled) {
return
}
recoveryWatchersInstalled = true
lastKnownOnline = isNetworkOnline()
try {
powerMonitor.on('resume', () => scheduleRecoveryProbes('power_resume'))
powerMonitor.on('unlock-screen', () => scheduleRecoveryProbes('unlock_screen'))
} catch {
// powerMonitor 在 app ready 之前或测试环境不可用;此时仅依赖下方的网络轮询恢复
}
}
function ensureProbeTimer(): void {
ensureInitialized()
ensureRecoveryWatchers()
if (probeTimer) {
return
}
probeTimer = setInterval(() => {
const onlineNow = isNetworkOnline()
if (onlineNow && !lastKnownOnline) {
scheduleRecoveryProbes('network_restored')
}
lastKnownOnline = onlineNow
if (!onlineNow) {
return
}
void runDueProbes()
}, PROBE_TICK_MS)
}
@@ -400,6 +466,7 @@ async function recheckChallengeResult(
let confirmed = result
for (let attempt = 0; attempt < CHALLENGE_RECHECK_ATTEMPTS; attempt += 1) {
await sleep(CHALLENGE_RECHECK_DELAY_MS)
confirmed = await probeAdapter(adapter, account, partition)
if (confirmed.verdict !== 'challenge_required') {
return confirmed
@@ -442,6 +509,12 @@ async function performProbeLocked(
record.probeState = 'probing'
commitRecord(record, previousSignature)
// 离线时页面探测只能得到缓存渲染或加载失败,任何"过期/需验证"的结论都不可信,
// 直接按网络错误退避,等网络恢复后由 recovery 探测纠正。
if (!isNetworkOnline()) {
return applyNetworkResult(record, networkProbeResult(), Date.now(), options.trigger)
}
const partition = resolveKnownPartition(account.id)
if (!partition) {
return applyExpiredResult(