Files
webmail/lib/auth/token-lock.ts
T
Linus Rath bb2393cf04 feat: open messages and drafts in their own windows and save all attachments to a folder
An installed app had no title bar of its own, no way to save attachments
except a zip, and "pop out" stayed inside the page. A new window was a
second copy of the app that had to restore the session itself, and on
start-up that evicts any login made without "Remember me" from the list
every window shares.

- Title bar: `display_override` with `window-controls-overlay`, one
  `--titlebar-inset` variable at the page roots and skeletons, and a drag
  strip with the window title. 0 without an overlay, so nothing moves.
- Save all to a folder: `showDirectoryPicker`, asking the device policy
  before the picker and before each file, with the zip as the fallback.
- Pop-out routes `/popout/message/<id>` and `/popout/compose`, on offer in
  an installed app or when the desktop bridge has windows. Hidden in Lite.
- A pop-out borrows the main window's session and never evicts or starts a
  token timer. Every renewal now takes a Web Lock, so two windows can't
  send the same refresh token, which Stalwart 1.0 may treat as a replay.
- A draft moves to its window under the same id. A closing window hands
  unsaved text to the main window to save, since its own request wouldn't
  survive the close.
- Read state, drafts, theme and language follow across windows, and
  `html[data-desktop]` gates the app-like behaviour in `app/desktop.css`.
2026-11-03 06:50:21 +01:00

52 lines
1.9 KiB
TypeScript

/**
* One token renewal at a time across the windows of this browser.
*
* Refresh tokens rotate, and the token route reads the current one from a
* cookie. Two windows renewing at once send the same token twice; on Stalwart
* 1.0 a superseded token reused past its retry window counts as a replay and
* revokes the whole grant. Held across the request, so the next window's
* request carries the cookie the first one's answer wrote.
*
* Without Web Locks, or after waiting too long for a window that hangs, the
* work runs anyway: never worse than having no lock.
*/
interface LockManagerLike {
request<T>(name: string, options: { signal?: AbortSignal }, callback: () => Promise<T>): Promise<T>;
}
/** How long a renewal waits for another window's before going ahead. Above the route's own timeouts. */
export const TOKEN_LOCK_WAIT_MS = 30_000;
function lockManager(): LockManagerLike | null {
const locks = typeof navigator !== 'undefined' ? (navigator as { locks?: LockManagerLike }).locks : undefined;
return locks && typeof locks.request === 'function' ? locks : null;
}
export function tokenLockName(slot: number): string {
return `bulwark-token-refresh:${slot}`;
}
export async function withTokenLock<T>(slot: number, work: () => Promise<T>, waitMs: number = TOKEN_LOCK_WAIT_MS): Promise<T> {
const locks = lockManager();
if (!locks) return work();
const gaveUp = new AbortController();
const timer = setTimeout(() => gaveUp.abort(), waitMs);
let started = false;
try {
return await locks.request(tokenLockName(slot), { signal: gaveUp.signal }, () => {
started = true;
clearTimeout(timer);
return work();
});
} catch (error) {
// Only the lock itself failing runs the work: an error from the work
// (started) goes to the caller, who decides what it means.
if (started) throw error;
return work();
} finally {
clearTimeout(timer);
}
}