diff --git a/CHANGELOG.md b/CHANGELOG.md index 04dc137..53b9960 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to Plaud Importer will be documented in this file. ## [Unreleased] +### Fixed + +- **The background session refresh no longer opens `web.plaud.ai` tabs in your default browser.** The refresh timer's delay is computed from the stored token's expiry, and for a long-lived token (an expiry 137 days out was observed) that delay overflowed the browser's 32-bit timer limit (about 24.8 days) and fired immediately instead of months later. The needless refresh then failed, retried on a backoff up to hourly, and every retry opened a hidden sign-in window whose login page leaked popup tabs into your default browser. The schedule is now capped safely below the timer limit, and a timer that wakes early re-arms without refreshing while the token still has plenty of life, so a valid token is never refreshed for no reason. + +### Changed + +- **A background refresh never opens a sign-in window anymore, not even a hidden one.** It uses only the silent, windowless renewal. If that fails, the plugin pauses and shows the one-click Reconnect prompt instead of loading Plaud's site in the background; only clicking Sign in or Reconnect ever opens a window. +- **A hung refresh request can no longer wedge the plugin.** Each renewal call is bounded by a 30-second timeout, so a stalled connection now counts as a failed refresh instead of silently blocking every later refresh and the Sign in button until Obsidian restarts. + ## [0.28.0] - 2026-07-09 ### Added diff --git a/__tests__/plaud-refresh.test.ts b/__tests__/plaud-refresh.test.ts index dbd3183..7081dd8 100644 --- a/__tests__/plaud-refresh.test.ts +++ b/__tests__/plaud-refresh.test.ts @@ -1,4 +1,15 @@ -import { decodeJwtExpMs, isFreshAccessToken, jwtTyp } from '../plaud-refresh'; +import { + computeRefreshDelay, + decodeJwtExpMs, + isFreshAccessToken, + isRefreshDue, + jwtTyp, + REFRESH_LEAD_MS, + REFRESH_MAX_DELAY_MS, + REFRESH_MIN_DELAY_MS, + REFRESH_OPAQUE_FALLBACK_MS, + REFRESH_RETRY_BACKOFF_MS, +} from '../plaud-refresh'; // Build a minimal unsigned JWT with the given header `typ` and payload `exp` // (seconds). The helpers only read unverified claims (typ, exp), so an unsigned @@ -23,6 +34,12 @@ const FRESH_WT = makeJwt('WT', FUTURE_EXP_S); const EXPIRED_WT = makeJwt('WT', PAST_EXP_S); const FRESH_UT = makeJwt('UT', FUTURE_EXP_S); // the user token the refresh endpoint returns const FRESH_WRT = makeJwt('WRT', FUTURE_EXP_S); +// Mirrors the token that produced the window storm: header typ JWT (not WT) +// with an exp ~137 days out, whose exp-driven delay overflows setTimeout's +// 32-bit signed range (2,147,483,647 ms) and used to fire immediately. +const LONG_EXP_S = Math.floor(NOW / 1000) + 137 * 24 * 3600; +const LONG_LIVED_JWT = makeJwt('JWT', LONG_EXP_S); +const NO_EXP_WT = makeJwt('WT', null); describe('jwtTyp / decodeJwtExpMs', () => { it('reads the header typ', () => { @@ -60,3 +77,69 @@ describe('isFreshAccessToken', () => { expect(isFreshAccessToken(FRESH_WRT, NOW)).toBe(false); }); }); + +describe('computeRefreshDelay', () => { + it('schedules a normal 24h token at exp minus the lead, unclamped', () => { + const delay = computeRefreshDelay(FRESH_WT, 0, NOW); + expect(delay).toBe(FUTURE_EXP_S * 1000 - REFRESH_LEAD_MS - NOW); + expect(delay).toBeLessThan(REFRESH_MAX_DELAY_MS); + }); + + it('clamps a long-lived token under the 32-bit setTimeout ceiling', () => { + // Prove the fixture matters: unclamped, this delay overflows setTimeout. + const unclamped = LONG_EXP_S * 1000 - REFRESH_LEAD_MS - NOW; + expect(unclamped).toBeGreaterThan(2_147_483_647); + const delay = computeRefreshDelay(LONG_LIVED_JWT, 0, NOW); + expect(delay).toBe(REFRESH_MAX_DELAY_MS); + expect(delay).toBeLessThan(2_147_483_647); + }); + + it('floors a past-due token at the minimum delay', () => { + expect(computeRefreshDelay(EXPIRED_WT, 0, NOW)).toBe(REFRESH_MIN_DELAY_MS); + }); + + it('polls hourly for a token with no decodable exp', () => { + expect(computeRefreshDelay(NO_EXP_WT, 0, NOW)).toBe(REFRESH_OPAQUE_FALLBACK_MS); + expect(computeRefreshDelay('not-a-token', 0, NOW)).toBe(REFRESH_OPAQUE_FALLBACK_MS); + }); + + it('uses the failure backoff on a streak, clamped to the last entry', () => { + expect(computeRefreshDelay(FRESH_WT, 1, NOW)).toBe(REFRESH_RETRY_BACKOFF_MS[0]); + expect(computeRefreshDelay(FRESH_WT, 4, NOW)).toBe( + REFRESH_RETRY_BACKOFF_MS[REFRESH_RETRY_BACKOFF_MS.length - 1], + ); + expect(computeRefreshDelay(FRESH_WT, 9, NOW)).toBe( + REFRESH_RETRY_BACKOFF_MS[REFRESH_RETRY_BACKOFF_MS.length - 1], + ); + }); + + it('backoff ignores the token exp (still inside the clamp)', () => { + expect(computeRefreshDelay(LONG_LIVED_JWT, 1, NOW)).toBe(REFRESH_RETRY_BACKOFF_MS[0]); + }); +}); + +describe('isRefreshDue', () => { + it('is not due for a 24h token well outside the lead window', () => { + expect(isRefreshDue(FRESH_WT, NOW)).toBe(false); + }); + + it('is not due for the long-lived non-WT token that caused the storm', () => { + // The skip-if-fresh guard: an early (clamped) timer fire on this token + // must be a no-op re-arm, never a refresh attempt. + expect(isRefreshDue(LONG_LIVED_JWT, NOW)).toBe(false); + }); + + it('is due once exp is inside the lead window', () => { + const soon = makeJwt('WT', Math.floor(NOW / 1000) + 3 * 60); // +3 min < 5 min lead + expect(isRefreshDue(soon, NOW)).toBe(true); + }); + + it('is due for an expired token', () => { + expect(isRefreshDue(EXPIRED_WT, NOW)).toBe(true); + }); + + it('is due for a token with no decodable exp (matches the hourly poll)', () => { + expect(isRefreshDue(NO_EXP_WT, NOW)).toBe(true); + expect(isRefreshDue('not-a-token', NOW)).toBe(true); + }); +}); diff --git a/main.ts b/main.ts index 46526b4..cc33566 100644 --- a/main.ts +++ b/main.ts @@ -26,7 +26,12 @@ import { isAccessToken, openPlaudLogin, } from "./plaud-login"; -import { decodeJwtExpMs, isFreshAccessToken } from "./plaud-refresh"; +import { + computeRefreshDelay, + isFreshAccessToken, + isRefreshDue, + REFRESH_RETRY_BACKOFF_MS, +} from "./plaud-refresh"; import { buildPartitionPost, performNetRefresh } from "./plaud-refresh-net"; import { NoteWriter, @@ -88,25 +93,6 @@ const CAPTURED_SECRET_ID = "plaud-importer-token"; // setSecret accepts it. const CAPTURED_REFRESH_SECRET_ID = "plaud-importer-refresh-token"; -// Silent-refresh scheduling. Refresh this long before the access token's `exp` -// so a slow round-trip still lands before expiry. -const REFRESH_LEAD_MS = 5 * 60 * 1000; -// Never schedule a refresh sooner than this, so a past-due or about-to-expire -// token triggers one prompt refresh rather than a tight loop. -const REFRESH_MIN_DELAY_MS = 30 * 1000; -// When the stored token is opaque (no decodable `exp`), poll at this cadence as -// a fallback so a session can still be kept alive. -const REFRESH_OPAQUE_FALLBACK_MS = 60 * 60 * 1000; -// Backoff after a failed refresh, indexed by (failure streak - 1) and clamped to -// the last entry. Keeps a dead 30-day refresh token from hammering the endpoint -// (Plaud counts refreshes per hour) while still retrying periodically. -const REFRESH_RETRY_BACKOFF_MS = [ - 5 * 60 * 1000, - 15 * 60 * 1000, - 30 * 60 * 1000, - 60 * 60 * 1000, -]; - // Plaud web app, opened in the system browser for the browser-based sign-in // flow (where Google/Apple SSO work, unlike an embedded webview). const PLAUD_WEB_URL = "https://web.plaud.ai"; @@ -1744,21 +1730,21 @@ export default class PlaudImporterPlugin extends Plugin { } /** - * Attempt one silent session refresh by re-capturing a fresh workspace token - * from a HIDDEN sign-in window on the persistent Plaud partition. The web app - * auto-authenticates from the stored session and mints a fresh WT on load, - * which the same capture used at login grabs — no user interaction, no need to - * replicate Plaud's UT->WT token derivation (the direct refresh endpoint only - * returns a UT, not the WT the data API needs). + * Attempt one silent session refresh via the direct, windowless path only + * (POST /auth/refresh-user-token over the partition cookies, then the + * workspace-token mint; see plaud-refresh-net.ts). A background refresh + * NEVER opens a window: the old hidden-window fallback loaded the full + * Plaud web app, whose login page leaked popup tabs into the default + * browser. On failure the caller pauses and prompts the user; only a user + * click ever opens the interactive sign-in window. * - * FAIL-SAFE: the stored token is replaced only when a fresh WT (typ WT, future - * exp) is captured. A timeout, a closed window, or an unusable token leaves - * every stored value untouched and returns false, so the caller falls back to - * today's behavior. Never throws. + * FAIL-SAFE: the stored token is replaced only when the refresh yields a + * fresh WT (typ WT, future exp). Any failure leaves every stored value + * untouched and returns false. Never throws. * - * Serialized against BOTH a concurrent silent refresh (`refreshInFlight`) and - * a visible interactive sign-in (`reauthInFlight`): two windows on the same - * partition would clobber each other's capture. + * Serialized against BOTH a concurrent silent refresh (`refreshInFlight`) + * and a visible interactive sign-in (`reauthInFlight`): a refresh landing + * mid-sign-in would race the interactive capture's token write. */ private async tryRefreshSession( reason: "scheduled" | "reactive" | "manual", @@ -1767,45 +1753,12 @@ export default class PlaudImporterPlugin extends Plugin { return false; } this.refreshInFlight = true; - // Also hold the interactive-sign-in gate: a manual "Sign in" opening a - // second window on this partition mid-refresh would clobber the capture. + // Also hold the interactive-sign-in gate: a manual "Sign in" storing its + // capture mid-refresh would clobber this path's token write (and vice + // versa). this.reauthInFlight = true; try { - // Primary: the direct, windowless refresh over the partition cookies - // (POST /auth/refresh-user-token then the workspace/token mint). Returns - // false when the session.fetch surface is unavailable or any step fails, - // so we fall through to the headless window below. - if (await this.tryNetRefresh(reason)) { - return true; - } - if (this.disposed) { - return false; - } - // Fallback: re-capture a fresh WT from a HIDDEN sign-in window, letting - // the web app mint it on load. Slower and heavier, but needs no knowledge - // of the credential and covers the case where the partition did not - // retain the httpOnly cookies the direct path relies on. - const result = await openPlaudLogin(this.app, { - debugLogger: this.debugLogger, - headless: true, - }); - // The capture awaits a full app load; do not persist onto a torn-down - // plugin, and treat a timeout / closed window (null) as a benign failure. - if (this.disposed || result === null) { - if (result === null) { - this.logAutoSync( - `session refresh (${reason}) captured no token (timed out or session needs interaction)`, - ); - } - return false; - } - return await this.applyRefreshedToken( - result.token, - result.refreshToken, - result.apiBaseUrl, - reason, - "window", - ); + return await this.tryNetRefresh(reason); } catch (err) { // A refresh bug must log, not throw into the timer or the auto-sync tick. console.error("Plaud importer: silent session refresh failed", err); @@ -1817,20 +1770,20 @@ export default class PlaudImporterPlugin extends Plugin { } /** - * Primary refresh: the direct, windowless path. Reads `wid`/`client_id` off - * the stored token, runs the two-step refresh over the partition cookie jar, - * and hands the candidate WT to the shared fail-safe. Returns false (never - * throws) when the session.fetch surface is missing, no token is stored, or - * any step fails, so `tryRefreshSession` falls back to the headless window. + * The direct, windowless refresh. Reads `wid`/`client_id` off the stored + * token, runs the two-step refresh over the partition cookie jar, and hands + * the candidate WT to the shared fail-safe. Returns false (never throws) + * when the session.fetch surface is missing, no token is stored, or any + * step fails; the refresh is then simply a failure (no window fallback). */ private async tryNetRefresh( reason: "scheduled" | "reactive" | "manual", ): Promise { const post = buildPartitionPost(); if (post === null) { - // No session.fetch on this runtime; the window path is the only option. + // No session.fetch on this runtime; silent refresh cannot run here. this.logAutoSync( - "net refresh unavailable: no partition session.fetch transport (Electron remote/session/fetch missing); falling back to the window", + "net refresh unavailable: no partition session.fetch transport (Electron remote/session/fetch missing)", ); return false; } @@ -1853,14 +1806,13 @@ export default class PlaudImporterPlugin extends Plugin { result.refreshToken, result.apiBaseUrl, reason, - "direct", ); } /** - * Shared fail-safe for both refresh paths. Replaces the stored secrets ONLY - * when the candidate decodes as a fresh workspace token (typ WT, future exp); - * a non-WT or already-expired value is rejected and nothing is written, so a + * Fail-safe for the refresh path. Replaces the stored secrets ONLY when the + * candidate decodes as a fresh workspace token (typ WT, future exp); a + * non-WT or already-expired value is rejected and nothing is written, so a * failed refresh can never make things worse than the current stored token. */ private async applyRefreshedToken( @@ -1868,14 +1820,13 @@ export default class PlaudImporterPlugin extends Plugin { refreshToken: string | null, apiBaseUrl: string | null, reason: "scheduled" | "reactive" | "manual", - via: "direct" | "window", ): Promise { if ( !isAccessToken(candidate) || !isFreshAccessToken(candidate, Date.now()) ) { this.logAutoSync( - `session refresh (${reason}, ${via}) produced a token that is not a fresh WT; keeping existing`, + `session refresh (${reason}, direct) produced a token that is not a fresh WT; keeping existing`, ); return false; } @@ -1900,7 +1851,7 @@ export default class PlaudImporterPlugin extends Plugin { this.settings.apiBaseUrl = apiBaseUrl; } await this.saveSettings(); - this.logAutoSync(`session refreshed (${reason}, ${via})`); + this.logAutoSync(`session refreshed (${reason}, direct)`); return true; } @@ -1920,20 +1871,14 @@ export default class PlaudImporterPlugin extends Plugin { // Nothing to refresh proactively until a token exists; a fresh sign-in // calls this again to (re)schedule. if (token === null) return; - let delay: number; - if (this.refreshFailureStreak > 0) { - const index = Math.min( - this.refreshFailureStreak - 1, - REFRESH_RETRY_BACKOFF_MS.length - 1, - ); - delay = REFRESH_RETRY_BACKOFF_MS[index]; - } else { - const expMs = decodeJwtExpMs(token); - delay = - expMs === null - ? REFRESH_OPAQUE_FALLBACK_MS - : Math.max(REFRESH_MIN_DELAY_MS, expMs - REFRESH_LEAD_MS - Date.now()); - } + // Backoff on a failure streak, expiry-driven otherwise, and always + // clamped under setTimeout's 32-bit ceiling (a long-lived token used to + // overflow the delay and fire immediately). See plaud-refresh.ts. + const delay = computeRefreshDelay( + token, + this.refreshFailureStreak, + Date.now(), + ); this.refreshTimeoutId = window.setTimeout(() => { this.refreshTimeoutId = undefined; void this.runScheduledRefresh(); @@ -1947,10 +1892,24 @@ export default class PlaudImporterPlugin extends Plugin { /** * The proactive-timer callback: refresh, resume any auth-paused sync on * success, then reschedule (from the new token's expiry on success, or a - * backoff on failure). Guarded so it never runs after unload or a disable. + * backoff on failure). Skips the refresh entirely (and just re-arms) while + * the stored token still has life beyond the lead window, so an early fire + * never refreshes a token that does not need it. Guarded so it never runs + * after unload or a disable. */ private async runScheduledRefresh(): Promise { if (this.disposed || !this.settings.keepSessionAlive) return; + // The timer can legitimately fire long before expiry: the schedule is + // clamped to REFRESH_MAX_DELAY_MS for long-lived tokens, and a backoff + // retry can outlive the failure that armed it (e.g. after a fresh + // sign-in). Refreshing a token that does not need it is what produced + // the spurious hourly refresh storm, so re-arm and wait instead. + const token = this.currentAccessToken(); + if (token !== null && !isRefreshDue(token, Date.now())) { + this.refreshFailureStreak = 0; + this.reconcileTokenRefresh(); + return; + } const ok = await this.tryRefreshSession("scheduled"); if (this.disposed) return; if (ok) { diff --git a/plaud-login.ts b/plaud-login.ts index 561f78e..a612341 100644 --- a/plaud-login.ts +++ b/plaud-login.ts @@ -37,9 +37,6 @@ const PLAUD_LOGIN_URL = 'https://web.plaud.ai'; // not have to sign in every time. Isolated from Obsidian's own web sessions. const PLAUD_PARTITION = 'persist:plaud-importer'; const POLL_INTERVAL_MS = 1000; -// Silent-refresh giveup. The app boot + auto-auth + first data call took ~30s in -// practice; 90s gives comfortable headroom before falling back to interactive. -const DEFAULT_HEADLESS_TIMEOUT_MS = 90 * 1000; // Match patterns for Plaud API hosts (covers regional hosts like api-euc1). const SESSION_FILTER = { urls: ['*://*.plaud.ai/*'] }; // A JWT, optionally bearer-prefixed. @@ -161,20 +158,6 @@ export interface PlaudLoginResult { export interface PlaudLoginOptions { readonly debugLogger?: DebugLogger; - /** - * Silent refresh mode: open the window hidden and expect no user interaction. - * The persistent partition auto-authenticates and the web app mints a fresh - * WT on load, which the same capture grabs. Paired with `timeoutMs` so a - * session that DOES need interaction (e.g. a 30-day-expired login) times out - * and resolves null instead of leaving an invisible window open forever. - */ - readonly headless?: boolean; - /** - * When `headless`, give up and resolve null after this long if no token was - * captured. Ignored for the visible flow (the user drives that). Defaults to - * DEFAULT_HEADLESS_TIMEOUT_MS. - */ - readonly timeoutMs?: number; } interface ProbeResult { @@ -228,10 +211,7 @@ interface BrowserWindowOptions { height?: number; title?: string; autoHideMenuBar?: boolean; - // false opens the window hidden (silent-refresh mode). Omitted/true is the - // visible sign-in window. - show?: boolean; - webPreferences?: { partition?: string; backgroundThrottling?: boolean }; + webPreferences?: { partition?: string }; } interface BrowserWindowConstructor { new (options: BrowserWindowOptions): BrowserWindowLike; @@ -323,10 +303,6 @@ class PlaudLoginSession { // arrives and the session settles. private capturedRefresh: string | null = null; private webRequestSession: SessionLike | null = null; - // Silent-refresh mode: window opens hidden and auto-gives-up after the timeout. - private readonly headless: boolean; - private readonly timeoutMs: number; - private timeoutHandle: number | null = null; constructor( options: PlaudLoginOptions, @@ -334,8 +310,6 @@ class PlaudLoginSession { ) { this.debugLogger = options.debugLogger ?? new NoopDebugLogger(); this.resolve = resolve; - this.headless = options.headless === true; - this.timeoutMs = options.timeoutMs ?? DEFAULT_HEADLESS_TIMEOUT_MS; } start(): void { @@ -356,15 +330,8 @@ class PlaudLoginSession { height: 760, title: 'Plaud sign-in', autoHideMenuBar: true, - // Silent refresh opens hidden: the persistent partition auto- - // authenticates and the web app mints a fresh WT on load with no - // user interaction, which the same capture grabs. - show: !this.headless, webPreferences: { partition: PLAUD_PARTITION, - // A hidden window is otherwise timer-throttled by Electron, which - // would stall the web app's auth/boot in the headless refresh. - backgroundThrottling: !this.headless, }, }); } catch (err) { @@ -373,18 +340,6 @@ class PlaudLoginSession { return; } - // In headless mode a session that genuinely needs interaction (e.g. the - // 30-day login finally expired) would otherwise leave an invisible window - // open forever. Give up after the timeout and resolve null so the caller - // falls back to the interactive flow. No timer in the visible flow — the - // user drives that and closing the window resolves null. - if (this.headless && Number.isFinite(this.timeoutMs)) { - this.timeoutHandle = window.setTimeout(() => { - this.note('headless refresh timed out; no token captured'); - this.settle(null); - }, this.timeoutMs); - } - // Deny every popup / new window the page requests, BEFORE loading it. The // Plaud web app fires window.open on load (feedback widget, analytics, an // auth-redirect popup); Obsidian routes those to the system browser, which @@ -567,15 +522,10 @@ class PlaudLoginSession { return; } this.settled = true; - if (this.timeoutHandle !== null) { - window.clearTimeout(this.timeoutHandle); - this.timeoutHandle = null; - } this.stopPolling(); this.teardownSessionCapture(); // Close the window on every settle path (success closes it too, via - // captureToken). Matters for the headless timeout: without this the hidden - // window would leak. Guarded/idempotent; a no-op once 'closed' has fired. + // captureToken). Guarded/idempotent; a no-op once 'closed' has fired. this.closeWindow(); this.resolve(result); } diff --git a/plaud-refresh-net.ts b/plaud-refresh-net.ts index 0a08da1..d10c8a7 100644 --- a/plaud-refresh-net.ts +++ b/plaud-refresh-net.ts @@ -1,7 +1,9 @@ -// Direct, windowless session refresh (Release B, primary path). +// Direct, windowless session refresh (Release B, the ONLY background path). // -// Replaces the hidden-BrowserWindow re-capture with two API calls on the -// persistent Plaud sign-in partition. Reverse-engineered from a full login HAR +// Two API calls on the persistent Plaud sign-in partition. The hidden +// BrowserWindow re-capture this replaced is gone: a background refresh never +// opens a window (the hidden window's login page leaked web.plaud.ai popup +// tabs into the default browser). Reverse-engineered from a full login HAR // on 2026-07-06 (see dev-docs/plaud-importer/2026-07-06-token-refresh-mechanism.md, // "WT-mint endpoint IDENTIFIED"). Both the user token (UT) and workspace token // (WT) live ~24h, so after a day both are stale and the refresh is two steps: @@ -20,25 +22,30 @@ // // FAIL-SAFE: this module only ever RETURNS a candidate token; it never writes // storage. The caller re-validates (typ WT, future exp) before replacing -// anything, and on any null result falls back to the headless-window path. Every -// step is guarded and the orchestrator never throws. +// anything, and on any null result the background pauses and prompts the user +// to reconnect. Every step is guarded and the orchestrator never throws. // -// NOT YET HANDS-ON VALIDATED: exercised only by unit tests with a stubbed -// transport. The live assumptions (partition retains the api.plaud.ai httpOnly -// cookies across restarts; the mint call needs no x-device-id) are unproven -// until Charles runs "Refresh session now" in his vault. A failure here is -// benign: it returns null and the headless fallback runs. +// Hands-on validated 2026-07-09: a manual "Refresh session now" on 0.28.0 +// completed this path live end to end (debug log: "net refresh succeeded via +// the direct (windowless) path", next refresh scheduled ~24h out). A failure +// here is benign: it returns null and the caller treats the refresh as failed. import { readJwtPayloadClaim } from './plaud-refresh'; // The Plaud web app's sign-in partition. Mirrors PLAUD_PARTITION in -// plaud-login.ts (kept local so this module has no import cycle with the login -// window it falls back to). +// plaud-login.ts (kept local so this module never imports the login-window +// module). const PLAUD_PARTITION = 'persist:plaud-importer'; const REFRESH_USER_TOKEN_PATH = '/auth/refresh-user-token'; const WORKSPACE_TOKEN_PATH_PREFIX = '/user-app/auth/workspace/token/'; +// Bound each refresh POST so a stalled request cannot hang the refresh path +// forever. The two calls each complete in a couple of seconds normally; 30s is +// generous headroom. An abort rejects the post, performNetRefresh catches it +// and returns null, and the refresh reports failure cleanly. +const NET_REFRESH_TIMEOUT_MS = 30 * 1000; + // Plaud's "success" status in a JSON envelope. Anything else is treated as a // failure (fall back), except the region-redirect status handled below. const STATUS_OK = 0; @@ -207,7 +214,7 @@ export function parseWorkspaceTokenResponse( // server's parse-mode check agrees (see plaud-client-re.ts). x-device-id is // deliberately omitted: it lives in the partition's localStorage, out of reach // without a window, and the mint call authenticates by cookie. If the server -// turns out to require it, this path fails and the headless fallback runs. +// turns out to require it, this path fails and the refresh reports failure. function baseHeaders(clientId: string): Record { return { accept: 'application/json, text/plain, */*', @@ -221,8 +228,8 @@ function baseHeaders(clientId: string): Record { } /** - * Run the two-step direct refresh. Returns a candidate WT (never stores it), or - * null on any failure so the caller falls back to the headless window. Never + * Run the two-step direct refresh. Returns a candidate WT (never stores it), + * or null on any failure so the caller can pause and prompt the user. Never * throws. */ export async function performNetRefresh( @@ -341,6 +348,7 @@ interface ElectronSessionLike { body: string; headers: Record; credentials?: 'include' | 'omit' | 'same-origin'; + signal?: AbortSignal; }, ): Promise; } @@ -374,8 +382,8 @@ function requireElectron(): ElectronLike | null { /** * Build a session-bound POST over the sign-in partition using Electron's * `session.fetch` (Electron 28+, present on Obsidian 1.11.4's runtime). Returns - * null when the remote/session/fetch surface is unavailable, so the caller - * skips the direct path and uses the headless window instead. + * null when the remote/session/fetch surface is unavailable, in which case the + * silent refresh cannot run on this build. */ export function buildPartitionPost(): SessionPost | null { const session = requireElectron()?.remote?.session?.fromPartition( @@ -390,11 +398,16 @@ export function buildPartitionPost(): SessionPost | null { // the partition's httpOnly Plaud cookies, and fetch's default same-origin // credentials mode would send none (the call has no document origin that // matches api.plaud.ai), so the refresh would silently 401/return empty. + // The abort signal bounds the whole call (request AND body read): the + // caller holds refreshInFlight/reauthInFlight until this settles, so a + // stalled request with no timeout would wedge every future refresh and + // the manual Sign in until restart. const res = await session.fetch(url, { method: 'POST', body, headers: { ...headers }, credentials: 'include', + signal: AbortSignal.timeout(NET_REFRESH_TIMEOUT_MS), }); const text = await res.text(); return { status: res.status, text }; diff --git a/plaud-refresh.ts b/plaud-refresh.ts index d0a959e..2c69a14 100644 --- a/plaud-refresh.ts +++ b/plaud-refresh.ts @@ -1,22 +1,48 @@ -// JWT helpers for the silent session refresh (Release B). +// JWT helpers and scheduling math for the silent session refresh (Release B). // -// The refresh itself is performed by re-capturing a fresh workspace token (WT) -// from a hidden sign-in window on the persistent Plaud partition (see -// plaud-login.ts `openPlaudLogin({ headless: true })`). Empirically the direct -// `POST /auth/refresh-user-token` call returns a USER token (typ UT, ~30 days), -// not the WORKSPACE token (typ WT, 24h) the data API needs; Plaud mints the WT -// in a separate step, which the web app does on load. Letting the app do that -// and re-capturing the WT (exactly as at login) sidesteps replicating Plaud's -// token derivation. +// The refresh itself is the direct, windowless two-step call in +// plaud-refresh-net.ts (POST /auth/refresh-user-token over the partition +// cookies, then the workspace-token mint). The background NEVER opens a +// window: when the windowless refresh fails, the caller pauses and prompts the +// user to reconnect. The old hidden-window fallback is gone; its login page +// leaked web.plaud.ai popup tabs into the default browser. // -// These pure helpers decode the WT's `exp` (to schedule the refresh ~5 min -// before expiry) and validate a captured token is a fresh WT (fail-safe: never +// These pure helpers decode the WT's `exp`, compute the proactive-refresh +// schedule (including the 32-bit setTimeout clamp and the skip-if-fresh +// guard), and validate a captured token is a fresh WT (fail-safe: never // replace the stored token with something that is not a future-dated WT). // A JWT, optionally bearer-prefixed. const JWT_RE = /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/; const ACCESS_TOKEN_TYP = 'WT'; +// Silent-refresh scheduling. Refresh this long before the access token's `exp` +// so a slow round-trip still lands before expiry. +export const REFRESH_LEAD_MS = 5 * 60 * 1000; +// Never schedule a refresh sooner than this, so a past-due or about-to-expire +// token triggers one prompt refresh rather than a tight loop. +export const REFRESH_MIN_DELAY_MS = 30 * 1000; +// When the stored token is opaque (no decodable `exp`), poll at this cadence as +// a fallback so a session can still be kept alive. +export const REFRESH_OPAQUE_FALLBACK_MS = 60 * 60 * 1000; +// window.setTimeout stores its delay as a 32-bit signed int; anything above +// 2,147,483,647 ms (about 24.8 days) overflows and fires almost immediately. +// A long-lived token (a ~137-day exp was observed in the wild) would otherwise +// schedule an instant, spurious refresh whose failure loop reopened a sign-in +// window every backoff interval. Cap the schedule well under the ceiling; an +// early wake-up is a no-op because the runner re-checks token life before +// refreshing (isRefreshDue). +export const REFRESH_MAX_DELAY_MS = 20 * 24 * 60 * 60 * 1000; +// Backoff after a failed refresh, indexed by (failure streak - 1) and clamped to +// the last entry. Keeps a dead 30-day refresh token from hammering the endpoint +// (Plaud counts refreshes per hour) while still retrying periodically. +export const REFRESH_RETRY_BACKOFF_MS = [ + 5 * 60 * 1000, + 15 * 60 * 1000, + 30 * 60 * 1000, + 60 * 60 * 1000, +]; + /** Reads the JWT header `typ`, or null when the value is not a decodable JWT. */ export function jwtTyp(value: string): string | null { const match = value.replace(/^bearer\s+/i, '').match(JWT_RE); @@ -48,6 +74,45 @@ export function decodeJwtExpMs(value: string): number | null { return payload.exp * 1000; } +/** + * True when the stored token is close enough to expiry (inside the lead + * window) that a proactive refresh should actually run. An opaque token (no + * decodable `exp`) counts as due, matching the hourly-poll fallback. Guards + * the scheduled runner so an early timer fire (the REFRESH_MAX_DELAY_MS clamp, + * or a backoff retry that outlived its failure) never refreshes a token that + * still has ample life. + */ +export function isRefreshDue(token: string, nowMs: number): boolean { + const expMs = decodeJwtExpMs(token); + return expMs === null || expMs - nowMs <= REFRESH_LEAD_MS; +} + +/** + * Delay until the next proactive-refresh timer fire. On a failure streak the + * delay is the retry backoff; otherwise it is expiry-driven (`exp` minus the + * lead), floored at REFRESH_MIN_DELAY_MS for past-due tokens, with an hourly + * poll for opaque tokens. Always clamped to REFRESH_MAX_DELAY_MS so the value + * stays inside setTimeout's 32-bit range. + */ +export function computeRefreshDelay( + token: string, + failureStreak: number, + nowMs: number, +): number { + let delay: number; + if (failureStreak > 0) { + const index = Math.min(failureStreak - 1, REFRESH_RETRY_BACKOFF_MS.length - 1); + delay = REFRESH_RETRY_BACKOFF_MS[index]; + } else { + const expMs = decodeJwtExpMs(token); + delay = + expMs === null + ? REFRESH_OPAQUE_FALLBACK_MS + : Math.max(REFRESH_MIN_DELAY_MS, expMs - REFRESH_LEAD_MS - nowMs); + } + return Math.min(delay, REFRESH_MAX_DELAY_MS); +} + /** * Reads a string claim from the JWT payload, or null when the value is not a * decodable JWT or the claim is absent / not a string. Used by the direct