mirror of
https://github.com/ckelsoe/obsidian-plaud-importer.git
synced 2026-07-22 07:49:02 +00:00
Fix session refresh setTimeout overflow; background refresh never opens a window (#63)
* Fix session refresh setTimeout overflow; background refresh never opens a window Root cause (debug-log proven): the proactive refresh timer computes its delay from the stored token's exp. A long-lived token (a 137-day exp was observed) produced a delay above the 32-bit setTimeout ceiling (2,147,483,647 ms), so the timer fired immediately instead of months out. The spurious refresh failed, retried on the 5/15/30/60-minute backoff, and every retry opened a hidden sign-in window whose login page leaked web.plaud.ai popup tabs into the default browser, roughly hourly. Fixes: - Clamp the scheduled delay to REFRESH_MAX_DELAY_MS (20 days), safely under the ceiling. New pure computeRefreshDelay in plaud-refresh.ts, unit tested. - Skip the proactive refresh while the stored token still has life beyond the 5-minute lead (new isRefreshDue guard in runScheduledRefresh). An early clamped fire re-arms without refreshing and resets the failure streak. - The background refresh is windowless-only: tryRefreshSession no longer falls back to a hidden openPlaudLogin window. On failure the plugin pauses and shows the one-click Reconnect notice. The dead headless machinery in plaud-login.ts is removed; only a user click can open the sign-in window. Closes #41. * Bound the windowless refresh POSTs with a 30s abort timeout CodeRabbit finding on PR #63: session.fetch had no timeout, so a stalled request would hold refreshInFlight/reauthInFlight forever and block every later refresh plus the manual Sign in until restart. Now each POST carries AbortSignal.timeout(30s); an abort rejects the post, performNetRefresh catches it and returns null, and the refresh fails cleanly. Matters more now that the windowless path is the only background refresh.
This commit is contained in:
parent
7c30dfa019
commit
c5c87b6414
6 changed files with 259 additions and 180 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
157
main.ts
157
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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string> {
|
||||
return {
|
||||
accept: 'application/json, text/plain, */*',
|
||||
|
|
@ -221,8 +228,8 @@ function baseHeaders(clientId: string): Record<string, string> {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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<string, string>;
|
||||
credentials?: 'include' | 'omit' | 'same-origin';
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<ElectronSessionFetchResponse>;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue