/** * QR-login state machine. * * The manager owns one pending login ticket, asks the injected `flow` for a QR * code and for status updates, persists a confirmed cookie through the injected * `store`, and points the provider at it. The AcFun endpoint contract lives in * the flow (`lib/qr-flow.js`), so tests and the panel can exercise the state * machine without touching the network. */ import { AcfunError } from './provider.js' /** Credential record key used to persist a QR-obtained session. */ export const SESSION_RECORD_KEY = 'acfun-session' /** How long a confirmed session is trusted before it is re-read from the store. */ const IDLE = 'idle' /** * Durable session store over the Host credential service. * * A `grant` record is the generic opaque-record kind, so the cookie round-trips * without a schema while staying out of the plugin config. With no credential * service the store degrades to memory-only for the lifetime of the plugin. * * @param {{readRecord: Function, modifyRecord: Function, deleteRecord: Function} | undefined} credentials */ export function createCredentialSessionStore(credentials) { let memory return { /** @returns {Promise<{cookie: string, savedAt: number} | undefined>} */ async read() { if (credentials === undefined) return memory try { const record = await credentials.readRecord(SESSION_RECORD_KEY) if (record === undefined || record === null) return undefined if (record.kind !== 'grant') return undefined const payload = record.payload if (payload === null || typeof payload !== 'object') return undefined if (typeof payload.cookie !== 'string' || payload.cookie.length === 0) return undefined return { cookie: payload.cookie, savedAt: Number(payload.savedAt) || 0 } } catch { return undefined } }, /** @param {string} cookie */ async write(cookie) { memory = { cookie, savedAt: Date.now() } if (credentials === undefined) return await credentials.modifyRecord(SESSION_RECORD_KEY, async () => ({ kind: 'grant', payload: { cookie, savedAt: Date.now(), plugin: 'dsh-plugin-acfun' }, })) }, async clear() { memory = undefined if (credentials === undefined) return await credentials.deleteRecord(SESSION_RECORD_KEY) }, } } /** * One QR-login manager bound to a provider and a session store. */ export class AcfunLoginManager { /** * @param {object} options * @param {import('./provider.js').AcfunProvider} options.provider * @param {{read: Function, write: Function, clear: Function}} options.store * @param {{start: Function, poll: Function, cancel?: Function} | null} [options.flow] * @param {() => number} [options.now] */ constructor(options) { this.provider = options.provider this.store = options.store this.flow = options.flow ?? null this.now = options.now ?? (() => Date.now()) this.ticket = '' this.qr = null this.expiresAt = 0 this.state = IDLE this.message = '' this.authSource = 'none' this.aborter = null this.loop = null } /** Whether a QR flow is wired in this Host. */ get available() { return this.flow !== null } /** Immutable view for the panel and tools. */ snapshot() { return { available: this.available, state: this.state, ticket: this.ticket, qr: this.qr, expiresAt: this.expiresAt, message: this.message, authSource: this.authSource, } } /** Reset the pending ticket without touching the stored session. */ #reset(state = IDLE, message = '') { this.ticket = '' this.qr = null this.expiresAt = 0 this.state = state this.message = message } /** * Load a persisted QR session at activation. * * @returns {Promise<{loaded: boolean, cookie?: string}>} */ async restore() { const saved = await this.store.read() if (saved === undefined) return { loaded: false } // A cookie from the plugin config wins until the user explicitly logs in // again; a stored QR session fills an otherwise anonymous provider. if (!this.provider.loggedIn) { this.provider.setCookie(saved.cookie) this.authSource = 'qr' } return { loaded: true, cookie: saved.cookie } } /** Start a fresh QR login, replacing any pending ticket. */ async start() { if (this.flow === null) { throw new AcfunError( 'AcFun QR login is unavailable: this Host did not detect the login endpoint contract.', { code: 'QR_LOGIN_UNAVAILABLE' }, ) } await this.cancel() const started = await this.flow.start() this.ticket = String(started?.ticket ?? '') this.qr = started?.qr ?? null this.expiresAt = Number(started?.expiresAt) || 0 this.state = 'waiting' this.message = '' // The QR service long-polls, so the exchange runs in the background and the // panel reads this in-memory state instead of blocking an HTTP response. this.#launchLoop() return this.snapshot() } /** Run the waiting → scanned → confirmed loop in the background. */ #launchLoop() { const aborter = new AbortController() this.aborter = aborter this.loop = (async () => { const signal = aborter.signal try { while (!signal.aborted) { if (this.expiresAt > 0 && this.now() > this.expiresAt) { this.state = 'expired' this.message = 'The QR code expired; start a new login.' return } const scanned = await this.flow.waitForScan({ signal }) if (signal.aborted) return if (scanned.state === 'waiting') { if (this.expiresAt > 0 && this.now() > this.expiresAt) { this.state = 'expired' this.message = 'The QR code expired; start a new login.' return } await new Promise((resolve) => setTimeout(resolve, 1500)) continue } if (scanned.state !== 'scanned') { this.state = scanned.state === 'expired' ? 'expired' : 'error' this.message = scanned.message || (this.state === 'expired' ? 'The QR code expired; start a new login.' : 'AcFun rejected this QR login.') return } this.state = 'scanned' this.message = 'Scanned; confirm the login on your phone.' const accepted = await this.flow.accept({ signal }) if (signal.aborted) return if (accepted.state === 'confirmed') { try { await this.store.write(accepted.cookie) } catch (error) { this.state = 'error' this.message = `Login succeeded but the session could not be saved: ${error instanceof Error ? error.message : String(error)}` return } this.provider.setCookie(accepted.cookie) this.authSource = 'qr' this.qr = null this.state = 'confirmed' this.message = 'Signed in to AcFun; the session cookie is saved.' return } this.state = accepted.state === 'expired' ? 'expired' : 'error' this.message = accepted.message || 'AcFun login failed.' return } } catch (error) { if (signal.aborted) return this.state = error?.name === 'TimeoutError' ? 'expired' : 'error' this.message = error?.name === 'TimeoutError' ? 'The QR code expired; start a new login.' : String(error?.message ?? error) } finally { if (this.aborter === aborter) this.aborter = null } })() } /** * Snapshot poll used by the panel and tools. The network work happens in the * background loop, so this never blocks on AcFun. * * @param {string} ticket - ticket returned by `start`; a stale ticket is ignored. */ async poll(ticket) { if (this.ticket !== '' && String(ticket ?? '') !== this.ticket) return this.snapshot() return this.snapshot() } /** Cancel the pending ticket; the stored session is untouched. */ async cancel() { if (this.aborter !== null) { this.aborter.abort() this.aborter = null } if (this.ticket !== '' && this.flow !== null && typeof this.flow.cancel === 'function') { try { await this.flow.cancel(this.ticket) } catch { // Cancelling is best-effort; the ticket expires on AcFun's side anyway. } } this.#reset('idle', '') return this.snapshot() } /** Forget the QR session and return the provider to anonymous/config mode. */ async logout() { await this.cancel() await this.store.clear() this.provider.setCookie('') this.authSource = 'none' return this.snapshot() } }