5f6e9f11da
Desktop Client Build / Resolve Build Metadata (push) Successful in 19s
Frontend CI / Frontend (push) Successful in 3m20s
Backend CI / Backend (push) Successful in 16m48s
Desktop Client Build / Build Desktop Client (push) Successful in 24m46s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 37s
1019 lines
28 KiB
TypeScript
1019 lines
28 KiB
TypeScript
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 type { PublishAccountIdentity } from './account-binder'
|
|
import type {
|
|
AccountAuthReason,
|
|
AccountAuthState,
|
|
AccountHealthProfile,
|
|
AccountHealthSnapshot,
|
|
AccountProbeState,
|
|
AuthProbeResult,
|
|
} from './auth-types'
|
|
import {
|
|
getPlatformAdapter,
|
|
type PlatformAdapter,
|
|
type PlatformFailureInput,
|
|
} from './platform-auth-adapters'
|
|
import { emitRuntimeInvalidated } from './runtime-events'
|
|
import { getPersistedPartition, getSessionHandle } from './session-registry'
|
|
|
|
const PROBE_TICK_MS = 1_000
|
|
const STALE_AFTER_MS = 45 * 60_000
|
|
const INITIAL_PROBE_MIN_MS = 1_000
|
|
const INITIAL_PROBE_MAX_MS = 10_000
|
|
const REGULAR_PROBE_MIN_MS = 30 * 60_000
|
|
const REGULAR_PROBE_MAX_MS = STALE_AFTER_MS
|
|
const CONFIRM_BACKOFF_STEPS_MS = [30_000, 120_000] as const
|
|
const NETWORK_RETRY_MS = 2 * 60_000
|
|
const PREFLIGHT_MAX_AGE_MS = 15 * 60_000
|
|
const MAX_CONCURRENT_ACCOUNT_PROBES = 2
|
|
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
|
|
|
|
type ProbeTrigger = 'scheduled' | 'manual' | 'preflight' | 'confirm'
|
|
|
|
interface PersistedAccountHealthRecord {
|
|
accountId: string
|
|
platform: string
|
|
lastVerifiedAt: number | null
|
|
lastAuthFailureAt: number | null
|
|
profile: AccountHealthProfile | null
|
|
sessionFingerprint: string | null
|
|
}
|
|
|
|
interface AccountHealthRecord {
|
|
accountId: string
|
|
platform: string
|
|
authRevision: number
|
|
authState: AccountAuthState
|
|
probeState: AccountProbeState
|
|
authReason: AccountAuthReason
|
|
lastVerifiedAt: number | null
|
|
lastProbeAt: number | null
|
|
nextProbeAt: number | null
|
|
lastAuthFailureAt: number | null
|
|
profile: AccountHealthProfile | null
|
|
sessionFingerprint: string | null
|
|
suspectedExpiredAt: number | null
|
|
confirmFailureCount: number
|
|
}
|
|
|
|
interface AccountProbeJob {
|
|
account: PublishAccountIdentity
|
|
trigger: ProbeTrigger
|
|
allowSilentRefresh: boolean
|
|
force: boolean
|
|
status: 'queued' | 'running'
|
|
attempt: number
|
|
maxAttempts: number
|
|
resolve: (snapshot: AccountHealthSnapshot) => void
|
|
reject: (error: unknown) => void
|
|
promise: Promise<AccountHealthSnapshot>
|
|
}
|
|
|
|
const trackedAccounts = new Map<string, PublishAccountIdentity>()
|
|
const records = new Map<string, AccountHealthRecord>()
|
|
const accountLocks = new Map<string, Promise<void>>()
|
|
const probeJobs = new Map<string, AccountProbeJob>()
|
|
const probeQueue: string[] = []
|
|
let initialized = false
|
|
let probeTimer: ReturnType<typeof setInterval> | null = null
|
|
let activeProbeCount = 0
|
|
|
|
function randomInt(min: number, max: number): number {
|
|
if (max <= min) {
|
|
return min
|
|
}
|
|
return min + Math.floor(Math.random() * (max - min))
|
|
}
|
|
|
|
function persistedHealthPath(): string {
|
|
return join(app.getPath('userData'), 'desktop-account-health.json')
|
|
}
|
|
|
|
function resolvePartition(accountId: string): string {
|
|
return (
|
|
getSessionHandle(accountId)?.partition ??
|
|
getPersistedPartition(accountId) ??
|
|
`persist:acc-${accountId}`
|
|
)
|
|
}
|
|
|
|
function resolveKnownPartition(accountId: string): string | null {
|
|
return getSessionHandle(accountId)?.partition ?? getPersistedPartition(accountId) ?? null
|
|
}
|
|
|
|
function nextInitialProbeAt(now = Date.now()): number {
|
|
return now + randomInt(INITIAL_PROBE_MIN_MS, INITIAL_PROBE_MAX_MS)
|
|
}
|
|
|
|
function nextRegularProbeAt(now = Date.now()): number {
|
|
return now + randomInt(REGULAR_PROBE_MIN_MS, REGULAR_PROBE_MAX_MS)
|
|
}
|
|
|
|
function normalizePersisted(record: PersistedAccountHealthRecord): AccountHealthRecord {
|
|
return {
|
|
accountId: record.accountId,
|
|
platform: record.platform,
|
|
authRevision: 0,
|
|
authState: record.lastVerifiedAt ? 'active' : 'unknown',
|
|
probeState: 'idle',
|
|
authReason: record.lastVerifiedAt ? 'probe_success' : null,
|
|
lastVerifiedAt: record.lastVerifiedAt ?? null,
|
|
lastProbeAt: null,
|
|
nextProbeAt: nextInitialProbeAt(),
|
|
lastAuthFailureAt: record.lastAuthFailureAt ?? null,
|
|
profile: record.profile ?? null,
|
|
sessionFingerprint: record.sessionFingerprint ?? null,
|
|
suspectedExpiredAt: null,
|
|
confirmFailureCount: 0,
|
|
}
|
|
}
|
|
|
|
function loadPersistedRecords(): void {
|
|
try {
|
|
const raw = JSON.parse(
|
|
readFileSync(persistedHealthPath(), 'utf8'),
|
|
) as PersistedAccountHealthRecord[]
|
|
for (const item of raw) {
|
|
if (!item?.accountId || !item?.platform) {
|
|
continue
|
|
}
|
|
records.set(item.accountId, normalizePersisted(item))
|
|
}
|
|
} catch {
|
|
records.clear()
|
|
}
|
|
}
|
|
|
|
function persistRecords(): void {
|
|
const payload: PersistedAccountHealthRecord[] = [...records.values()].map((record) => ({
|
|
accountId: record.accountId,
|
|
platform: record.platform,
|
|
lastVerifiedAt: record.lastVerifiedAt,
|
|
lastAuthFailureAt: record.lastAuthFailureAt,
|
|
profile: record.profile,
|
|
sessionFingerprint: record.sessionFingerprint,
|
|
}))
|
|
|
|
const target = persistedHealthPath()
|
|
mkdirSync(dirname(target), { recursive: true })
|
|
writeFileSync(target, JSON.stringify(payload, null, 2), 'utf8')
|
|
}
|
|
|
|
function ensureInitialized(): void {
|
|
if (initialized) {
|
|
return
|
|
}
|
|
|
|
initialized = true
|
|
loadPersistedRecords()
|
|
}
|
|
|
|
function ensureProbeTimer(): void {
|
|
ensureInitialized()
|
|
if (probeTimer) {
|
|
return
|
|
}
|
|
|
|
probeTimer = setInterval(() => {
|
|
void runDueProbes()
|
|
}, PROBE_TICK_MS)
|
|
}
|
|
|
|
function coerceDerivedAuthState(record: AccountHealthRecord): AccountAuthState {
|
|
return record.authState
|
|
}
|
|
|
|
function toPublicSnapshot(record: AccountHealthRecord): AccountHealthSnapshot {
|
|
return {
|
|
accountId: record.accountId,
|
|
platform: record.platform,
|
|
partition: resolvePartition(record.accountId),
|
|
authState: coerceDerivedAuthState(record),
|
|
probeState: record.probeState,
|
|
authReason: record.authReason,
|
|
lastVerifiedAt: record.lastVerifiedAt,
|
|
lastProbeAt: record.lastProbeAt,
|
|
nextProbeAt: record.nextProbeAt,
|
|
lastAuthFailureAt: record.lastAuthFailureAt,
|
|
profile: record.profile,
|
|
sessionFingerprint: record.sessionFingerprint,
|
|
}
|
|
}
|
|
|
|
function snapshotSignature(record: AccountHealthRecord): string {
|
|
return JSON.stringify(toPublicSnapshot(record))
|
|
}
|
|
|
|
function commitRecord(
|
|
record: AccountHealthRecord,
|
|
previousSignature: string | null,
|
|
): AccountHealthSnapshot {
|
|
records.set(record.accountId, record)
|
|
persistRecords()
|
|
|
|
const nextSignature = snapshotSignature(record)
|
|
if (previousSignature !== nextSignature) {
|
|
emitRuntimeInvalidated('account-health', { accountId: record.accountId })
|
|
}
|
|
|
|
return toPublicSnapshot(record)
|
|
}
|
|
|
|
function ensureRecord(account: PublishAccountIdentity): AccountHealthRecord {
|
|
ensureInitialized()
|
|
|
|
const existing = records.get(account.id)
|
|
if (existing) {
|
|
existing.platform = account.platform
|
|
return existing
|
|
}
|
|
|
|
const created: AccountHealthRecord = {
|
|
accountId: account.id,
|
|
platform: account.platform,
|
|
authRevision: 0,
|
|
authState: 'unknown',
|
|
probeState: 'idle',
|
|
authReason: null,
|
|
lastVerifiedAt: null,
|
|
lastProbeAt: null,
|
|
nextProbeAt: nextInitialProbeAt(),
|
|
lastAuthFailureAt: null,
|
|
profile: null,
|
|
sessionFingerprint: null,
|
|
suspectedExpiredAt: null,
|
|
confirmFailureCount: 0,
|
|
}
|
|
|
|
records.set(account.id, created)
|
|
persistRecords()
|
|
return created
|
|
}
|
|
|
|
async function withAccountLock<T>(accountId: string, work: () => Promise<T>): Promise<T> {
|
|
const previous = accountLocks.get(accountId) ?? Promise.resolve()
|
|
let release!: () => void
|
|
const current = new Promise<void>((resolve) => {
|
|
release = resolve
|
|
})
|
|
|
|
accountLocks.set(accountId, current)
|
|
await previous.catch(() => undefined)
|
|
|
|
try {
|
|
return await work()
|
|
} finally {
|
|
release()
|
|
if (accountLocks.get(accountId) === current) {
|
|
accountLocks.delete(accountId)
|
|
}
|
|
}
|
|
}
|
|
|
|
function confirmBackoffMs(failureCount: number): number {
|
|
const index = Math.min(Math.max(failureCount - 1, 0), CONFIRM_BACKOFF_STEPS_MS.length - 1)
|
|
return (
|
|
CONFIRM_BACKOFF_STEPS_MS[index] ?? CONFIRM_BACKOFF_STEPS_MS[CONFIRM_BACKOFF_STEPS_MS.length - 1]
|
|
)
|
|
}
|
|
|
|
function applyActiveResult(
|
|
record: AccountHealthRecord,
|
|
result: AuthProbeResult,
|
|
now: number,
|
|
): AccountHealthSnapshot {
|
|
const previousSignature = snapshotSignature(record)
|
|
|
|
record.authState = 'active'
|
|
record.probeState = 'idle'
|
|
record.authReason = result.reason ?? 'probe_success'
|
|
record.lastVerifiedAt = now
|
|
record.lastProbeAt = now
|
|
record.nextProbeAt = nextRegularProbeAt(now)
|
|
record.lastAuthFailureAt = null
|
|
record.profile = result.profile ?? record.profile
|
|
record.sessionFingerprint = result.sessionFingerprint ?? record.sessionFingerprint
|
|
record.suspectedExpiredAt = null
|
|
record.confirmFailureCount = 0
|
|
|
|
return commitRecord(record, previousSignature)
|
|
}
|
|
|
|
function applyExpiredResult(
|
|
record: AccountHealthRecord,
|
|
result: AuthProbeResult,
|
|
now: number,
|
|
): AccountHealthSnapshot {
|
|
const previousSignature = snapshotSignature(record)
|
|
|
|
record.authState = 'expired'
|
|
record.probeState = 'idle'
|
|
record.authReason = result.reason ?? 'login_redirect'
|
|
record.lastProbeAt = now
|
|
record.nextProbeAt = null
|
|
record.lastAuthFailureAt = now
|
|
record.suspectedExpiredAt = null
|
|
record.confirmFailureCount = 0
|
|
|
|
return commitRecord(record, previousSignature)
|
|
}
|
|
|
|
function applyChallengeResult(
|
|
record: AccountHealthRecord,
|
|
result: AuthProbeResult,
|
|
now: number,
|
|
): AccountHealthSnapshot {
|
|
const previousSignature = snapshotSignature(record)
|
|
|
|
record.authState = 'challenge_required'
|
|
record.probeState = 'idle'
|
|
record.authReason = result.reason ?? 'captcha_gate'
|
|
record.lastProbeAt = now
|
|
record.nextProbeAt = nextRegularProbeAt(now)
|
|
record.lastAuthFailureAt = now
|
|
record.suspectedExpiredAt = null
|
|
record.confirmFailureCount = 0
|
|
|
|
return commitRecord(record, previousSignature)
|
|
}
|
|
|
|
function applyNetworkResult(
|
|
record: AccountHealthRecord,
|
|
result: AuthProbeResult,
|
|
now: number,
|
|
trigger: 'scheduled' | 'manual' | 'preflight' | 'confirm',
|
|
): AccountHealthSnapshot {
|
|
const previousSignature = snapshotSignature(record)
|
|
const hadSuspicion = Boolean(record.suspectedExpiredAt)
|
|
|
|
record.probeState = 'network_error'
|
|
record.authReason = result.reason ?? 'network_error'
|
|
record.lastProbeAt = now
|
|
if (hadSuspicion && trigger === 'confirm') {
|
|
record.confirmFailureCount += 1
|
|
record.authState = record.authState === 'unknown' ? 'unknown' : 'expiring_soon'
|
|
record.nextProbeAt = now + confirmBackoffMs(record.confirmFailureCount)
|
|
} else {
|
|
record.nextProbeAt = now + NETWORK_RETRY_MS
|
|
if (record.authState === 'active') {
|
|
record.authState = 'expiring_soon'
|
|
}
|
|
}
|
|
|
|
return commitRecord(record, previousSignature)
|
|
}
|
|
|
|
function networkProbeResult(): AuthProbeResult {
|
|
return {
|
|
verdict: 'network_error',
|
|
reason: 'network_error',
|
|
profile: null,
|
|
sessionFingerprint: null,
|
|
}
|
|
}
|
|
|
|
async function probeAdapter(
|
|
adapter: PlatformAdapter,
|
|
account: PublishAccountIdentity,
|
|
partition: string,
|
|
): Promise<AuthProbeResult> {
|
|
return await adapter.probe(account, partition).catch<AuthProbeResult>(() => networkProbeResult())
|
|
}
|
|
|
|
async function recheckChallengeResult(
|
|
adapter: PlatformAdapter,
|
|
account: PublishAccountIdentity,
|
|
partition: string,
|
|
result: AuthProbeResult,
|
|
): Promise<AuthProbeResult> {
|
|
if (result.verdict !== 'challenge_required') {
|
|
return result
|
|
}
|
|
|
|
let confirmed = result
|
|
for (let attempt = 0; attempt < CHALLENGE_RECHECK_ATTEMPTS; attempt += 1) {
|
|
confirmed = await probeAdapter(adapter, account, partition)
|
|
if (confirmed.verdict !== 'challenge_required') {
|
|
return confirmed
|
|
}
|
|
}
|
|
|
|
return confirmed
|
|
}
|
|
|
|
function applyProbeResult(
|
|
record: AccountHealthRecord,
|
|
result: AuthProbeResult,
|
|
now: number,
|
|
trigger: ProbeTrigger,
|
|
): AccountHealthSnapshot {
|
|
switch (result.verdict) {
|
|
case 'active':
|
|
return applyActiveResult(record, result, now)
|
|
case 'expired':
|
|
return applyExpiredResult(record, result, now)
|
|
case 'challenge_required':
|
|
return applyChallengeResult(record, result, now)
|
|
default:
|
|
return applyNetworkResult(record, result, now, trigger)
|
|
}
|
|
}
|
|
|
|
async function performProbeLocked(
|
|
account: PublishAccountIdentity,
|
|
options: {
|
|
trigger: ProbeTrigger
|
|
allowSilentRefresh: boolean
|
|
},
|
|
): Promise<AccountHealthSnapshot> {
|
|
const record = ensureRecord(account)
|
|
const adapter = getPlatformAdapter(account.platform)
|
|
const probeAuthRevision = record.authRevision
|
|
const previousSignature = snapshotSignature(record)
|
|
|
|
record.probeState = 'probing'
|
|
commitRecord(record, previousSignature)
|
|
|
|
const partition = resolveKnownPartition(account.id)
|
|
if (!partition) {
|
|
return applyExpiredResult(
|
|
record,
|
|
{
|
|
verdict: 'expired',
|
|
reason: 'missing_partition',
|
|
profile: null,
|
|
sessionFingerprint: null,
|
|
},
|
|
Date.now(),
|
|
)
|
|
}
|
|
|
|
if (options.allowSilentRefresh) {
|
|
const refreshed = await adapter.silentRefresh(account, partition).catch(() => false)
|
|
if (record.authRevision !== probeAuthRevision) {
|
|
return toPublicSnapshot(record)
|
|
}
|
|
if (!refreshed) {
|
|
const currentSignature = snapshotSignature(record)
|
|
record.authReason = 'silent_refresh_failed'
|
|
commitRecord(record, currentSignature)
|
|
}
|
|
}
|
|
|
|
const result = await probeAdapter(adapter, account, partition)
|
|
|
|
if (record.authRevision !== probeAuthRevision) {
|
|
return toPublicSnapshot(record)
|
|
}
|
|
|
|
const confirmedResult = await recheckChallengeResult(adapter, account, partition, result)
|
|
if (record.authRevision !== probeAuthRevision) {
|
|
return toPublicSnapshot(record)
|
|
}
|
|
|
|
return applyProbeResult(record, confirmedResult, Date.now(), options.trigger)
|
|
}
|
|
|
|
function canUseCachedProbe(
|
|
record: AccountHealthRecord,
|
|
options: { trigger: ProbeTrigger; force: boolean },
|
|
now = Date.now(),
|
|
): boolean {
|
|
if (options.force) {
|
|
return false
|
|
}
|
|
|
|
if (
|
|
options.trigger === 'manual' &&
|
|
record.lastProbeAt &&
|
|
now - record.lastProbeAt < MANUAL_PROBE_MIN_INTERVAL_MS
|
|
) {
|
|
return true
|
|
}
|
|
|
|
if (
|
|
(options.trigger === 'manual' || options.trigger === 'scheduled') &&
|
|
record.authState === 'active' &&
|
|
record.lastVerifiedAt &&
|
|
now - record.lastVerifiedAt < MANUAL_PROBE_CACHE_TTL_MS &&
|
|
record.probeState !== 'network_error'
|
|
) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
function markProbeQueued(record: AccountHealthRecord): AccountHealthSnapshot {
|
|
const previousSignature = snapshotSignature(record)
|
|
record.probeState = 'queued'
|
|
return commitRecord(record, previousSignature)
|
|
}
|
|
|
|
function isProbeInFlight(record: AccountHealthRecord): boolean {
|
|
return record.probeState === 'queued' || record.probeState === 'probing'
|
|
}
|
|
|
|
function isStaleActiveRecord(record: AccountHealthRecord, now = Date.now()): boolean {
|
|
return (
|
|
record.authState === 'active' &&
|
|
Boolean(record.lastVerifiedAt) &&
|
|
now - (record.lastVerifiedAt ?? 0) >= STALE_AFTER_MS
|
|
)
|
|
}
|
|
|
|
function maxAttemptsForTrigger(trigger: ProbeTrigger): number {
|
|
return trigger === 'preflight' ? 1 : ACCOUNT_PROBE_RETRY_BACKOFF_MS.length + 1
|
|
}
|
|
|
|
function enqueueProbeJob(job: AccountProbeJob): void {
|
|
if (!probeQueue.includes(job.account.id)) {
|
|
probeQueue.push(job.account.id)
|
|
}
|
|
drainProbeQueue()
|
|
}
|
|
|
|
function removeQueuedProbeJob(accountId: string): void {
|
|
const job = probeJobs.get(accountId)
|
|
if (!job || job.status !== 'queued') {
|
|
return
|
|
}
|
|
|
|
probeJobs.delete(accountId)
|
|
for (let index = probeQueue.length - 1; index >= 0; index -= 1) {
|
|
if (probeQueue[index] === accountId) {
|
|
probeQueue.splice(index, 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
function scheduleProbeRetry(job: AccountProbeJob): void {
|
|
const retryIndex = Math.min(
|
|
Math.max(job.attempt - 2, 0),
|
|
ACCOUNT_PROBE_RETRY_BACKOFF_MS.length - 1,
|
|
)
|
|
const backoff =
|
|
ACCOUNT_PROBE_RETRY_BACKOFF_MS[retryIndex] ??
|
|
ACCOUNT_PROBE_RETRY_BACKOFF_MS[ACCOUNT_PROBE_RETRY_BACKOFF_MS.length - 1]
|
|
job.status = 'queued'
|
|
setTimeout(() => enqueueProbeJob(job), backoff)
|
|
}
|
|
|
|
function drainProbeQueue(): void {
|
|
while (activeProbeCount < MAX_CONCURRENT_ACCOUNT_PROBES && probeQueue.length > 0) {
|
|
const accountId = probeQueue.shift()
|
|
if (!accountId) {
|
|
continue
|
|
}
|
|
|
|
const job = probeJobs.get(accountId)
|
|
if (!job || job.status !== 'queued') {
|
|
continue
|
|
}
|
|
|
|
job.status = 'running'
|
|
activeProbeCount += 1
|
|
void runProbeJob(job)
|
|
}
|
|
}
|
|
|
|
async function runProbeJob(job: AccountProbeJob): Promise<void> {
|
|
try {
|
|
const snapshot = await withAccountLock(job.account.id, async () => {
|
|
const record = ensureRecord(job.account)
|
|
if (canUseCachedProbe(record, job)) {
|
|
return toPublicSnapshot(record)
|
|
}
|
|
return await performProbeLocked(job.account, {
|
|
trigger: job.trigger,
|
|
allowSilentRefresh: job.allowSilentRefresh,
|
|
})
|
|
})
|
|
|
|
if (snapshot.probeState === 'network_error' && job.attempt < job.maxAttempts) {
|
|
job.attempt += 1
|
|
scheduleProbeRetry(job)
|
|
return
|
|
}
|
|
|
|
probeJobs.delete(job.account.id)
|
|
job.resolve(snapshot)
|
|
} catch (error) {
|
|
probeJobs.delete(job.account.id)
|
|
job.reject(error)
|
|
} finally {
|
|
activeProbeCount = Math.max(0, activeProbeCount - 1)
|
|
drainProbeQueue()
|
|
}
|
|
}
|
|
|
|
function enqueueAccountProbe(
|
|
account: PublishAccountIdentity,
|
|
options: {
|
|
trigger?: ProbeTrigger
|
|
allowSilentRefresh?: boolean
|
|
force?: boolean
|
|
} = {},
|
|
): Promise<AccountHealthSnapshot> {
|
|
syncTrackedAccountsInternal([account], false)
|
|
|
|
const trigger = options.trigger ?? 'manual'
|
|
const force = Boolean(options.force)
|
|
const record = ensureRecord(account)
|
|
if (canUseCachedProbe(record, { trigger, force })) {
|
|
return Promise.resolve(toPublicSnapshot(record))
|
|
}
|
|
|
|
const existing = probeJobs.get(account.id)
|
|
if (existing) {
|
|
if (force) {
|
|
existing.force = true
|
|
}
|
|
existing.allowSilentRefresh = existing.allowSilentRefresh || Boolean(options.allowSilentRefresh)
|
|
if (trigger === 'manual') {
|
|
existing.trigger = 'manual'
|
|
}
|
|
return existing.promise
|
|
}
|
|
|
|
markProbeQueued(record)
|
|
|
|
let resolve!: (snapshot: AccountHealthSnapshot) => void
|
|
let reject!: (error: unknown) => void
|
|
const promise = new Promise<AccountHealthSnapshot>((resolvePromise, rejectPromise) => {
|
|
resolve = resolvePromise
|
|
reject = rejectPromise
|
|
})
|
|
|
|
const job: AccountProbeJob = {
|
|
account,
|
|
trigger,
|
|
allowSilentRefresh: Boolean(options.allowSilentRefresh),
|
|
force,
|
|
status: 'queued',
|
|
attempt: 1,
|
|
maxAttempts: maxAttemptsForTrigger(trigger),
|
|
resolve,
|
|
reject,
|
|
promise,
|
|
}
|
|
|
|
probeJobs.set(account.id, job)
|
|
enqueueProbeJob(job)
|
|
return promise
|
|
}
|
|
|
|
async function runDueProbes(): Promise<void> {
|
|
const now = Date.now()
|
|
const dueAccounts = [...trackedAccounts.values()].filter((account) => {
|
|
const record = records.get(account.id)
|
|
if (!record || isProbeInFlight(record)) {
|
|
return false
|
|
}
|
|
return Boolean(
|
|
(record.nextProbeAt && record.nextProbeAt <= now) || isStaleActiveRecord(record, now),
|
|
)
|
|
})
|
|
|
|
for (const account of dueAccounts) {
|
|
const record = records.get(account.id)
|
|
if (!record) {
|
|
continue
|
|
}
|
|
|
|
const trigger = record.suspectedExpiredAt ? 'confirm' : 'scheduled'
|
|
void enqueueAccountProbe(account, {
|
|
trigger,
|
|
allowSilentRefresh: true,
|
|
}).catch((error) => {
|
|
console.warn('[desktop-account-health] scheduled probe failed', {
|
|
accountId: account.id,
|
|
platform: account.platform,
|
|
message: error instanceof Error ? error.message : String(error),
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
function parseVerifiedAt(value: string | null | undefined): number | null {
|
|
if (!value) {
|
|
return null
|
|
}
|
|
|
|
const timestamp = Date.parse(value)
|
|
return Number.isNaN(timestamp) ? null : timestamp
|
|
}
|
|
|
|
function fallbackAuthState(
|
|
health: DesktopAccountInfo['health'] | null | undefined,
|
|
): AccountAuthState {
|
|
switch (health) {
|
|
case 'live':
|
|
return 'unknown'
|
|
case 'captcha':
|
|
return 'challenge_required'
|
|
case 'expired':
|
|
return 'expired'
|
|
default:
|
|
return 'unknown'
|
|
}
|
|
}
|
|
|
|
export function initAccountHealth(): void {
|
|
ensureInitialized()
|
|
ensureProbeTimer()
|
|
}
|
|
|
|
export function syncTrackedAccounts(accounts: PublishAccountIdentity[]): void {
|
|
syncTrackedAccountsInternal(accounts, true)
|
|
}
|
|
|
|
function syncTrackedAccountsInternal(
|
|
accounts: PublishAccountIdentity[],
|
|
pruneMissing: boolean,
|
|
): void {
|
|
initAccountHealth()
|
|
let removed = false
|
|
|
|
if (pruneMissing) {
|
|
const nextIds = new Set(accounts.map((account) => account.id))
|
|
for (const [accountId] of trackedAccounts.entries()) {
|
|
if (!nextIds.has(accountId)) {
|
|
trackedAccounts.delete(accountId)
|
|
records.delete(accountId)
|
|
removed = true
|
|
emitRuntimeInvalidated('account-health', { accountId })
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const account of accounts) {
|
|
trackedAccounts.set(account.id, account)
|
|
ensureRecord(account)
|
|
}
|
|
|
|
persistRecords()
|
|
if (removed) {
|
|
emitRuntimeInvalidated('account-health')
|
|
}
|
|
if (pruneMissing) {
|
|
queueMicrotask(() => {
|
|
void runDueProbes()
|
|
})
|
|
}
|
|
}
|
|
|
|
export function forgetTrackedAccountHealth(accountId: string): void {
|
|
trackedAccounts.delete(accountId)
|
|
if (records.delete(accountId)) {
|
|
persistRecords()
|
|
emitRuntimeInvalidated('account-health', { accountId })
|
|
}
|
|
}
|
|
|
|
export function markTrackedAccountBound(
|
|
account: PublishAccountIdentity,
|
|
profile: AccountHealthProfile | null = null,
|
|
): AccountHealthSnapshot {
|
|
const record = ensureRecord(account)
|
|
const previousSignature = snapshotSignature(record)
|
|
const now = Date.now()
|
|
const resolvedProfile: AccountHealthProfile = {
|
|
displayName: profile?.displayName ?? account.displayName ?? null,
|
|
avatarUrl: profile?.avatarUrl ?? null,
|
|
subjectUid: profile?.subjectUid ?? account.platformUid ?? null,
|
|
}
|
|
|
|
trackedAccounts.set(account.id, account)
|
|
record.authRevision += 1
|
|
record.platform = account.platform
|
|
record.authState = 'active'
|
|
record.probeState = 'idle'
|
|
record.authReason = 'bind_success'
|
|
record.lastVerifiedAt = now
|
|
record.lastProbeAt = now
|
|
record.nextProbeAt = nextRegularProbeAt(now)
|
|
record.lastAuthFailureAt = null
|
|
record.profile = resolvedProfile
|
|
record.sessionFingerprint = resolvedProfile.subjectUid ?? record.sessionFingerprint
|
|
record.suspectedExpiredAt = null
|
|
record.confirmFailureCount = 0
|
|
|
|
const snapshot = commitRecord(record, previousSignature)
|
|
const queuedJob = probeJobs.get(account.id)
|
|
if (queuedJob?.status === 'queued') {
|
|
removeQueuedProbeJob(account.id)
|
|
queuedJob.resolve(snapshot)
|
|
}
|
|
|
|
return snapshot
|
|
}
|
|
|
|
export function markTrackedAccountMissingPartition(
|
|
account: PublishAccountIdentity,
|
|
): AccountHealthSnapshot {
|
|
const record = ensureRecord(account)
|
|
const previousSignature = snapshotSignature(record)
|
|
const now = Date.now()
|
|
|
|
trackedAccounts.delete(account.id)
|
|
record.authRevision += 1
|
|
record.platform = account.platform
|
|
record.authState = 'expired'
|
|
record.probeState = 'idle'
|
|
record.authReason = 'missing_partition'
|
|
record.lastProbeAt = now
|
|
record.nextProbeAt = null
|
|
record.lastAuthFailureAt = now
|
|
record.profile = null
|
|
record.sessionFingerprint = null
|
|
record.suspectedExpiredAt = null
|
|
record.confirmFailureCount = 0
|
|
|
|
const snapshot = commitRecord(record, previousSignature)
|
|
const queuedJob = probeJobs.get(account.id)
|
|
if (queuedJob?.status === 'queued') {
|
|
removeQueuedProbeJob(account.id)
|
|
queuedJob.resolve(snapshot)
|
|
}
|
|
|
|
return snapshot
|
|
}
|
|
|
|
export function getAccountHealthSnapshot(accountId: string): AccountHealthSnapshot | null {
|
|
ensureInitialized()
|
|
const record = records.get(accountId)
|
|
return record ? toPublicSnapshot(record) : null
|
|
}
|
|
|
|
export function getProjectedAccountHealth(input: {
|
|
accountId: string
|
|
platform: string
|
|
health?: DesktopAccountInfo['health']
|
|
verifiedAt?: string | null
|
|
}) {
|
|
const snapshot = getAccountHealthSnapshot(input.accountId)
|
|
if (!snapshot) {
|
|
return {
|
|
health: input.health ?? 'risk',
|
|
authState: fallbackAuthState(input.health),
|
|
probeState: 'idle' as AccountProbeState,
|
|
authReason: null as AccountAuthReason,
|
|
lastVerifiedAt: parseVerifiedAt(input.verifiedAt),
|
|
lastProbeAt: null,
|
|
nextProbeAt: null,
|
|
lastAuthFailureAt: null,
|
|
profile: null,
|
|
sessionFingerprint: null,
|
|
partition: resolvePartition(input.accountId),
|
|
}
|
|
}
|
|
|
|
const health =
|
|
snapshot.authState === 'active'
|
|
? ('live' as const)
|
|
: snapshot.authState === 'challenge_required'
|
|
? ('captcha' as const)
|
|
: snapshot.authState === 'expired' || snapshot.authState === 'revoked'
|
|
? ('expired' as const)
|
|
: ('risk' as const)
|
|
|
|
return {
|
|
...snapshot,
|
|
health,
|
|
}
|
|
}
|
|
|
|
export async function probeTrackedAccount(
|
|
account: PublishAccountIdentity,
|
|
options: {
|
|
trigger?: ProbeTrigger
|
|
allowSilentRefresh?: boolean
|
|
force?: boolean
|
|
} = {},
|
|
): Promise<AccountHealthSnapshot> {
|
|
return await enqueueAccountProbe(account, {
|
|
trigger: options.trigger ?? 'manual',
|
|
allowSilentRefresh: Boolean(options.allowSilentRefresh),
|
|
force: Boolean(options.force),
|
|
})
|
|
}
|
|
|
|
export async function ensureAccountReady(
|
|
account: PublishAccountIdentity,
|
|
): Promise<AccountHealthSnapshot> {
|
|
syncTrackedAccountsInternal([account], false)
|
|
const snapshot = getAccountHealthSnapshot(account.id)
|
|
const isFresh = snapshot?.lastVerifiedAt
|
|
? Date.now() - snapshot.lastVerifiedAt < PREFLIGHT_MAX_AGE_MS
|
|
: false
|
|
|
|
if (
|
|
snapshot &&
|
|
snapshot.authState === 'active' &&
|
|
snapshot.probeState !== 'network_error' &&
|
|
isFresh
|
|
) {
|
|
return snapshot
|
|
}
|
|
|
|
if (
|
|
snapshot &&
|
|
['expired', 'challenge_required', 'revoked'].includes(snapshot.authState) &&
|
|
!(account.platform === 'baijiahao' && snapshot.authState === 'challenge_required')
|
|
) {
|
|
return snapshot
|
|
}
|
|
|
|
return await withAccountLock(account.id, async () => {
|
|
const lockedSnapshot = getAccountHealthSnapshot(account.id)
|
|
const lockedFresh = lockedSnapshot?.lastVerifiedAt
|
|
? Date.now() - lockedSnapshot.lastVerifiedAt < PREFLIGHT_MAX_AGE_MS
|
|
: false
|
|
|
|
if (
|
|
lockedSnapshot &&
|
|
lockedSnapshot.authState === 'active' &&
|
|
lockedSnapshot.probeState !== 'network_error' &&
|
|
lockedFresh
|
|
) {
|
|
return lockedSnapshot
|
|
}
|
|
|
|
return await performProbeLocked(account, {
|
|
trigger: 'preflight',
|
|
allowSilentRefresh: true,
|
|
})
|
|
})
|
|
}
|
|
|
|
export async function reportAccountFailure(
|
|
account: PublishAccountIdentity,
|
|
input: PlatformFailureInput,
|
|
): Promise<AccountHealthSnapshot | null> {
|
|
syncTrackedAccountsInternal([account], false)
|
|
const adapter = getPlatformAdapter(account.platform)
|
|
const classification = adapter.classifyFailure(input)
|
|
|
|
if (classification === 'ok') {
|
|
return getAccountHealthSnapshot(account.id)
|
|
}
|
|
|
|
return await withAccountLock(account.id, async () => {
|
|
const record = ensureRecord(account)
|
|
const previousSignature = snapshotSignature(record)
|
|
const now = Date.now()
|
|
|
|
if (classification === 'challenge' || classification === 'risk_control') {
|
|
record.probeState = 'probing'
|
|
commitRecord(record, previousSignature)
|
|
|
|
const partition = resolveKnownPartition(account.id)
|
|
const result = partition
|
|
? await recheckChallengeResult(adapter, account, partition, {
|
|
verdict: 'challenge_required',
|
|
reason: classification === 'risk_control' ? 'risk_control' : 'captcha_gate',
|
|
profile: null,
|
|
sessionFingerprint: null,
|
|
})
|
|
: {
|
|
verdict: 'expired' as const,
|
|
reason: 'missing_partition' as const,
|
|
profile: null,
|
|
sessionFingerprint: null,
|
|
}
|
|
|
|
return applyProbeResult(record, result, Date.now(), 'confirm')
|
|
}
|
|
|
|
if (classification === 'network') {
|
|
record.probeState = 'network_error'
|
|
record.authReason = 'network_error'
|
|
record.lastProbeAt = now
|
|
record.nextProbeAt = now + NETWORK_RETRY_MS
|
|
return commitRecord(record, previousSignature)
|
|
}
|
|
|
|
record.lastAuthFailureAt = now
|
|
record.suspectedExpiredAt = record.suspectedExpiredAt ?? now
|
|
if (record.authState === 'active') {
|
|
record.authState = 'expiring_soon'
|
|
}
|
|
record.nextProbeAt = now
|
|
commitRecord(record, previousSignature)
|
|
|
|
return await performProbeLocked(account, {
|
|
trigger: 'confirm',
|
|
allowSilentRefresh: true,
|
|
})
|
|
})
|
|
}
|