mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 08:16:22 +00:00
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`.
52 lines
1.9 KiB
TypeScript
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);
|
|
}
|
|
}
|