diff --git a/apps/desktop/main.cjs b/apps/desktop/main.cjs index 62edfc8..f8cb9c0 100644 --- a/apps/desktop/main.cjs +++ b/apps/desktop/main.cjs @@ -39,6 +39,9 @@ let activeHotkeyBinding = null; let updateCheckPromise = null; let updateReadyToInstall = false; let updateInstallRequested = false; +let workerAuthPollTimer = null; +let workerAuthPollInFlight = false; +let usageCreditRefreshTimers = []; let desktopAuth = { token: "", account: null, @@ -52,6 +55,9 @@ const MAX_DICTIONARY_ENTRIES = 1000; const MAX_DICTIONARY_SEND_ENTRIES = 200; const MAX_DICTIONARY_PHRASE_LENGTH = 60; const MAX_DICTIONARY_REPLACEMENT_LENGTH = 120; +const WORKER_AUTH_POLL_INTERVAL_MS = 5 * 60 * 1000; +const POST_TRANSCRIPTION_USAGE_REFRESH_DELAYS_MS = [5_000, 20_000, 60_000]; +const DEFERRED_USAGE_REFRESH_RETRY_MS = 5_000; const hotkeyState = { ctrlDown: false, @@ -131,6 +137,7 @@ if (!singleInstanceLock) { createTray(); registerNativeHotkey(); configureAutoUpdates(); + configureWorkerAuthPolling(); void refreshWorkerAuth(); }); } @@ -161,6 +168,11 @@ app.on("will-quit", () => { } logSink?.close(); logSink = null; + clearUsageCreditRefreshTimers(); + if (workerAuthPollTimer) { + clearInterval(workerAuthPollTimer); + workerAuthPollTimer = null; + } }); function createWindow() { @@ -569,15 +581,86 @@ function registerIpc() { } exitDictationWindowMode(); patchStatus({ state: "idle", message: "Ready", lastTranscript: result }); + scheduleUsageCreditRefresh("transcription-complete"); } else { logWarn("transcription:submit:empty-text", summarizeTranscriptionResult(result)); exitDictationWindowMode(); patchStatus({ state: "idle", message: "No speech detected" }); + scheduleUsageCreditRefresh("transcription-empty"); } return result; }); } +function configureWorkerAuthPolling() { + if (workerAuthPollTimer) { + return; + } + + workerAuthPollTimer = setInterval(() => { + void refreshWorkerAuthFromPoll("interval"); + }, WORKER_AUTH_POLL_INTERVAL_MS); + workerAuthPollTimer.unref?.(); +} + +async function refreshWorkerAuthFromPoll(reason) { + if (!desktopAuth.token || workerAuthPollInFlight) { + return; + } + + if (isDictationBusy()) { + return; + } + + workerAuthPollInFlight = true; + try { + logInfo("worker:auth-poll:start", { reason }); + await refreshWorkerAuth(); + } catch (error) { + logWarn("worker:auth-poll:failed", { reason, error: formatErrorForLog(error) }); + } finally { + workerAuthPollInFlight = false; + } +} + +function scheduleUsageCreditRefresh(reason) { + clearUsageCreditRefreshTimers(); + + for (const delayMs of POST_TRANSCRIPTION_USAGE_REFRESH_DELAYS_MS) { + scheduleUsageCreditRefreshAttempt(reason, delayMs); + } +} + +function clearUsageCreditRefreshTimers() { + for (const timer of usageCreditRefreshTimers) { + clearTimeout(timer); + } + usageCreditRefreshTimers = []; +} + +function scheduleUsageCreditRefreshAttempt(reason, delayMs) { + const timer = setTimeout(() => { + usageCreditRefreshTimers = usageCreditRefreshTimers.filter((candidate) => candidate !== timer); + + if (!desktopAuth.token) { + return; + } + + if (workerAuthPollInFlight || isDictationBusy()) { + scheduleUsageCreditRefreshAttempt(reason, DEFERRED_USAGE_REFRESH_RETRY_MS); + return; + } + + void refreshWorkerAuthFromPoll(reason); + }, delayMs); + timer.unref?.(); + usageCreditRefreshTimers.push(timer); +} + +function isDictationBusy() { + return status.state === "recording" || status.state === "transcribing" || status.state === "pasting"; +} + function configureAutoUpdates() { if (!app.isPackaged) { logInfo("updates:skip-unpackaged"); @@ -848,6 +931,7 @@ async function startDeviceLogin() { } desktopAuth = { token: "", account: null, billing: null }; + clearUsageCreditRefreshTimers(); authGeneration += 1; saveDesktopAuth(); patchStatus({ @@ -917,6 +1001,7 @@ async function pollDeviceLogin(deviceCode, deviceName) { async function logoutDevice() { desktopAuth = { token: "", account: null, billing: null }; + clearUsageCreditRefreshTimers(); authGeneration += 1; saveDesktopAuth(); patchStatus({ diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index cb92b71..79c5810 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -203,6 +203,7 @@ const CLEANUP_USER_PROMPT_SUFFIX = ` `; const app = new Hono<{ Bindings: Env }>(); +const DESKTOP_AUTH_LIVE_BILLING_TIMEOUT_MS = 1_500; app.use( "/health/*", @@ -286,7 +287,7 @@ app.get("/health", (c) => ); app.get("/health/auth", async (c) => { - const authorized = await authorizeDesktop(c.req.raw, c.env); + const authorized = await authorizeDesktop(c.req.raw, c.env, { liveBilling: true }); if (!authorized) { return c.json({ error: "Unauthorized" }, 401); } @@ -947,7 +948,11 @@ function isAuthSession(value: unknown): value is AuthSession { return isRecord(value) && isRecord(value.user) && typeof value.user.id === "string" && typeof value.user.email === "string" && typeof value.user.name === "string"; } -async function authorizeDesktop(request: Request, env: Env): Promise { +async function authorizeDesktop( + request: Request, + env: Env, + options: { liveBilling?: boolean } = {} +): Promise { const authorization = request.headers.get("authorization") ?? ""; const token = authorization.startsWith("Bearer ") ? authorization.slice("Bearer ".length).trim() : ""; if (!token) { @@ -967,7 +972,9 @@ async function authorizeDesktop(request: Request, env: Env): Promise { + try { + return await withTimeout( + getBilling(env, userId), + DESKTOP_AUTH_LIVE_BILLING_TIMEOUT_MS, + `desktop auth billing timed out after ${DESKTOP_AUTH_LIVE_BILLING_TIMEOUT_MS}ms` + ); + } catch (error) { + console.warn( + JSON.stringify({ + level: "warn", + event: "desktop-auth:live-billing-fallback", + userId, + timeoutMs: DESKTOP_AUTH_LIVE_BILLING_TIMEOUT_MS, + error: formatError(error) + }) + ); + return getCachedBilling(env, userId); + } +} + async function getBilling(env: Env, userId: string): Promise { const billing = await ensureBillingProfile(env, userId); if (!isPolarConfigured(env)) {