{mvShell && (
diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx
index 4512d1c74..88fba7e0c 100644
--- a/components/ui/dialog.tsx
+++ b/components/ui/dialog.tsx
@@ -155,8 +155,8 @@ export function Dialog({
return createPortal(
{
pressedOnBackdrop.current = e.target === e.currentTarget;
diff --git a/hooks/use-calendar-alerts.ts b/hooks/use-calendar-alerts.ts
index 7915de752..8c65c12ee 100644
--- a/hooks/use-calendar-alerts.ts
+++ b/hooks/use-calendar-alerts.ts
@@ -3,6 +3,7 @@
import { useEffect, useRef, useCallback } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { parseISO } from 'date-fns';
+import { isPopoutWindow } from '@/lib/popout/window-role';
import { useAuthStore } from '@/stores/auth-store';
import { useCalendarStore } from '@/stores/calendar-store';
import { useSettingsStore } from '@/stores/settings-store';
@@ -72,7 +73,9 @@ function remind(view: CalendarAlertView, show: () => void): void {
}
export function useCalendarAlerts() {
- const { isAuthenticated, client } = useAuthStore();
+ const { isAuthenticated: signedIn, client } = useAuthStore();
+ // The main window rings; a pop-out would ring the same alert again.
+ const isAuthenticated = signedIn && !isPopoutWindow();
const { events, calendars, supportsCalendar } = useCalendarStore();
const { calendarNotificationsEnabled, calendarNotificationSound, enableCalendarTasks, notificationSoundChoice } = useSettingsStore();
const { tasks: storeTasks } = useTaskStore();
diff --git a/hooks/use-popouts-available.ts b/hooks/use-popouts-available.ts
new file mode 100644
index 000000000..7628846a0
--- /dev/null
+++ b/hooks/use-popouts-available.ts
@@ -0,0 +1,53 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
+import { useAuthStore } from "@/stores/auth-store";
+import { resolveEmailBlobContext } from "@/stores/email-store";
+import { toast } from "@/stores/toast-store";
+import type { Email } from "@/lib/jmap/types";
+import { openMessageWindow } from "@/lib/popout/open";
+import { popoutsAvailable } from "@/lib/popout/support";
+import { isPopoutWindow } from "@/lib/popout/window-role";
+
+/**
+ * Whether to offer "Open in new window" here. False on the server and in the
+ * first render, so the markup hydrates the same; false in the demo, whose
+ * client has no session to share, and inside a pop-out (for a message: it is
+ * already in its own window).
+ */
+export function usePopoutsAvailable(): boolean {
+ const isDemoMode = useAuthStore((s) => s.isDemoMode);
+ const [available, setAvailable] = useState(false);
+ useEffect(() => {
+ const check = () => setAvailable(popoutsAvailable() && !isPopoutWindow());
+ check();
+ // Installing the running tab turns it into a window without a reload.
+ if (typeof window.matchMedia !== "function") return;
+ const media = window.matchMedia("(display-mode: standalone)");
+ media.addEventListener?.("change", check);
+ return () => media.removeEventListener?.("change", check);
+ }, []);
+ return available && !isDemoMode;
+}
+
+/**
+ * Opens a message in its own window, through the login and the account it was
+ * read in. Null when that isn't on offer here. Says so when the browser
+ * blocks the window; nothing else changes then.
+ */
+export function useOpenEmailInWindow(): ((email: Email) => void) | null {
+ const available = usePopoutsAvailable();
+ const t = useTranslations("pwaWindows");
+ const open = useCallback((email: Email) => {
+ const auth = useAuthStore.getState();
+ const source = resolveEmailBlobContext(email, auth.client);
+ const opened = openMessageWindow({
+ emailId: email.id,
+ localAccountId: source.clientAccountId ?? auth.activeAccountId,
+ ownerAccountId: source.accountId,
+ });
+ if (!opened) toast.error(t("popup_blocked"));
+ }, [t]);
+ return available ? open : null;
+}
diff --git a/integration/tests/23-do-pwa-windows.spec.ts b/integration/tests/23-do-pwa-windows.spec.ts
new file mode 100644
index 000000000..a573d1707
--- /dev/null
+++ b/integration/tests/23-do-pwa-windows.spec.ts
@@ -0,0 +1,675 @@
+import { test, expect, type Browser, type BrowserContext, type Page } from '@playwright/test';
+import { ENT, adminContext, contextOptions, deliver, jmapAs, requireEntEnv, shot, withPolicy, ENT_PASSWORD } from './helpers/ent';
+import { accountSwitcher, neutralizeDevOverlay } from './helpers/app';
+
+/**
+ * Windows of an installed app (design B, step 2) through the real UI on a
+ * VM 106 test Stalwart with the ent-* accounts:
+ * - the title bar next to the native window controls;
+ * - "Save all to a folder", with a stubbed showDirectoryPicker;
+ * - pop-out message and composer windows: session, draft handover, send,
+ * close with unsaved text, a message deleted or moved, state across windows.
+ *
+ * Headless Chromium has no installed-app mode, so the display mode and the
+ * overlay API are stubbed by an init script (which reaches popups too).
+ * window.open, the second window and everything behind it are real. What this
+ * can't show: a real install opening a popup inside the app rather than in
+ * the browser. A person has to check that once in Chrome and Edge.
+ *
+ * Rig:
+ * BUILD=1 bash start-prod.sh
pw-1.0
+ * bash run-spec.sh 1.0 integration/tests/23-do-pwa-windows.spec.ts
+ */
+
+test.setTimeout(180_000);
+
+const RUN = Date.now().toString(36);
+/** A server check every second: tighter polling gets a test account rate limited. */
+const POLL = [1000];
+const STANDALONE = `(() => {
+ const real = window.matchMedia.bind(window);
+ window.matchMedia = (query) => query.includes('display-mode: standalone')
+ ? { matches: true, media: query, onchange: null, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, dispatchEvent() { return false; } }
+ : real(query);
+})();`;
+const OVERLAY = `(() => {
+ const overlay = new EventTarget();
+ overlay.visible = true;
+ overlay.getTitlebarAreaRect = () => ({ x: 0, y: 0, width: window.innerWidth - 140, height: 40 });
+ Object.defineProperty(navigator, 'windowControlsOverlay', { value: overlay });
+})();`;
+const BRIDGE = `window.bulwarkDesktop = { windows: {} };`;
+
+async function appPage(browser: Browser, ...initScripts: string[]): Promise {
+ const context = await browser.newContext(contextOptions());
+ for (const script of initScripts) await context.addInitScript(script);
+ const page = await context.newPage();
+ await neutralizeDevOverlay(page);
+ await page.goto('/');
+ await page.fill('#username', ENT.alice);
+ await page.fill('#password', ENT_PASSWORD);
+ await page.click('button[type="submit"]');
+ await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 });
+ return page;
+}
+
+/** A toast: the text is also in the live region for screen readers. */
+function toast(page: Page, text: string | RegExp) {
+ return page.getByRole('region', { name: 'Notifications' }).getByText(text).first();
+}
+
+function row(page: Page, subject: string) {
+ return page.locator('[data-testid="email-list-item"]', { hasText: subject }).first();
+}
+
+async function openInMain(page: Page, subject: string): Promise {
+ await expect(row(page, subject)).toBeVisible({ timeout: 60_000 });
+ await row(page, subject).click();
+}
+
+async function deliverToAlice(subject: string): Promise {
+ await deliver({ from: ENT.bob, to: [ENT.alice], subject, text: `Body of ${subject}` });
+ const alice = await jmapAs(ENT.alice);
+ const found = await alice.waitForEmail(subject, { timeoutMs: 30_000 });
+ return found.id;
+}
+
+/** A message in Alice's inbox with two attachments of the same name and one other. */
+async function messageWithAttachments(subject: string): Promise {
+ const alice = await jmapAs(ENT.alice);
+ const inbox = await alice.mailboxByRole('inbox');
+ const res = await alice.request([['Email/set', {
+ accountId: alice.accountId,
+ create: { m: {
+ mailboxIds: { [inbox!.id]: true },
+ from: [{ email: ENT.bob }],
+ to: [{ email: ENT.alice }],
+ subject,
+ bodyValues: {
+ t: { value: 'See the files.' },
+ a: { value: 'first copy' },
+ b: { value: 'second copy' },
+ c: { value: 'notes' },
+ },
+ bodyStructure: {
+ type: 'multipart/mixed',
+ subParts: [
+ { partId: 't', type: 'text/plain' },
+ { partId: 'a', type: 'text/plain', name: 'same.txt', disposition: 'attachment' },
+ { partId: 'b', type: 'text/plain', name: 'same.txt', disposition: 'attachment' },
+ { partId: 'c', type: 'text/plain', name: 'notes.txt', disposition: 'attachment' },
+ ],
+ },
+ } },
+ }, '0']]);
+ if (!res.methodResponses[0][1].created?.m) throw new Error(JSON.stringify(res.methodResponses[0][1]));
+}
+
+/** Stubs the picker on the page, with a folder that records what it is given. */
+async function stubDirectoryPicker(page: Page, opts: { failOn?: string; cancel?: boolean } = {}): Promise {
+ await page.evaluate(({ failOn, cancel }) => {
+ const files = new Map();
+ (window as unknown as { __folderFiles: Map }).__folderFiles = files;
+ (window as unknown as { __pickerCalls: number }).__pickerCalls = 0;
+ (window as unknown as { showDirectoryPicker: unknown }).showDirectoryPicker = async () => {
+ (window as unknown as { __pickerCalls: number }).__pickerCalls += 1;
+ if (cancel) throw new DOMException('closed', 'AbortError');
+ return {
+ name: 'Saved mail',
+ async getFileHandle(name: string, options?: { create?: boolean }) {
+ if (!files.has(name) && !options?.create) throw new DOMException('missing', 'NotFoundError');
+ return {
+ createWritable: async () => ({
+ write: async (data: Blob) => {
+ if (name === failOn) throw new DOMException('Disk full', 'QuotaExceededError');
+ files.set(name, await data.text());
+ },
+ close: async () => {},
+ abort: async () => {},
+ }),
+ };
+ },
+ async removeEntry(name: string) { files.delete(name); },
+ };
+ };
+ }, opts);
+}
+
+const folderFiles = (page: Page) => page.evaluate(() => [...(window as unknown as { __folderFiles: Map }).__folderFiles.entries()]);
+
+function watchAuthCalls(context: BrowserContext): string[] {
+ const calls: string[] = [];
+ context.on('request', (request) => {
+ const url = new URL(request.url());
+ if (request.method() !== 'PUT') return;
+ if (url.pathname === '/api/auth/token' || url.pathname === '/api/auth/session') {
+ calls.push(`${request.frame().page().url()} ${url.pathname}${url.search}`);
+ }
+ });
+ return calls;
+}
+
+async function openPopout(page: Page, trigger: () => Promise): Promise {
+ const [popup] = await Promise.all([page.context().waitForEvent('page'), trigger()]);
+ await popup.waitForLoadState('domcontentloaded');
+ return popup;
+}
+
+async function popOutMessage(page: Page, subject: string): Promise {
+ await openInMain(page, subject);
+ const button = page.locator('[data-testid="viewer-open-in-window"]').first();
+ await expect(button).toBeVisible({ timeout: 30_000 });
+ const popup = await openPopout(page, () => button.click());
+ await expect(popup.locator('[data-popout-window="message"][data-popout-status="ready"]')).toBeVisible({ timeout: 60_000 });
+ return popup;
+}
+
+async function draftsCalled(subject: string): Promise> {
+ const alice = await jmapAs(ENT.alice);
+ const drafts = await alice.mailboxByRole('drafts');
+ const res = await alice.request([
+ ['Email/query', { accountId: alice.accountId, filter: { inMailbox: drafts!.id, subject } }, '0'],
+ ['Email/get', { accountId: alice.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject'] }, '1'],
+ ]);
+ return res.methodResponses[1][1].list;
+}
+
+test.beforeAll(() => requireEntEnv());
+
+test.describe('the manifest and the title bar', () => {
+ test('the manifest asks for the overlay and falls back to standalone', async ({ request, baseURL }) => {
+ const res = await request.get(`${baseURL ?? process.env.IT_WEBMAIL_URL}/manifest.webmanifest`);
+ const manifest = await res.json();
+ expect(manifest.display).toBe('standalone');
+ expect(manifest.display_override).toEqual(['window-controls-overlay', 'standalone']);
+ });
+
+ test('without an overlay nothing changes', async ({ browser }) => {
+ const page = await appPage(browser);
+ await expect(page.locator('[data-window-title-bar]')).toHaveCount(0);
+ const padding = await page.evaluate(() => {
+ const root = document.querySelector('[class*="--app-top-inset"]') as HTMLElement;
+ return { padding: getComputedStyle(root).paddingTop, top: root.getBoundingClientRect().top };
+ });
+ expect(padding).toEqual({ padding: '0px', top: 0 });
+ await page.context().close();
+ });
+
+ test('with one, the app starts below a drag strip that leaves the controls free', async ({ browser }) => {
+ const page = await appPage(browser, OVERLAY);
+ const strip = page.locator('[data-window-title-bar]');
+ await expect(strip).toBeVisible();
+ const box = await strip.boundingBox();
+ expect(box).toMatchObject({ x: 0, y: 0, height: 40 });
+ expect(box!.width).toBe(1440 - 140);
+ const region = await strip.evaluate((el) => {
+ const style = getComputedStyle(el) as CSSStyleDeclaration & { appRegion?: string; webkitAppRegion?: string };
+ return style.appRegion ?? style.webkitAppRegion;
+ });
+ expect(region).toBe('drag');
+ // The window title, in small text.
+ await expect(strip).toContainText(/\S/);
+ const rail = await page.evaluate(() => {
+ const root = document.querySelector('[class*="--app-top-inset"]') as HTMLElement;
+ return { padding: getComputedStyle(root).paddingTop };
+ });
+ expect(rail.padding).toBe('40px');
+ await shot(page, 'pwa-windows-title-bar');
+ // A full-page dialog keeps clear of it too.
+ await page.context().close();
+ });
+});
+
+test.describe('save all attachments to a folder', () => {
+ const SUBJECT = `Files ${RUN}`;
+
+ test.beforeAll(() => messageWithAttachments(SUBJECT));
+
+ test('writes every file, numbers the same name, and says how many', async ({ browser }) => {
+ const page = await appPage(browser);
+ await stubDirectoryPicker(page);
+ await openInMain(page, SUBJECT);
+ await page.locator('[data-testid="attachments-save-to-folder"]').first().click();
+ await expect(toast(page, 'Saved 3 attachments to Saved mail.')).toBeVisible({ timeout: 30_000 });
+ const files = new Map(await folderFiles(page));
+ expect([...files.keys()].sort()).toEqual(['notes.txt', 'same (1).txt', 'same.txt']);
+ expect(files.get('notes.txt')).toContain('notes');
+ expect([files.get('same.txt'), files.get('same (1).txt')].map((text) => text!.trim()).sort()).toEqual(['first copy', 'second copy']);
+ await page.context().close();
+ });
+
+ test('a write failing midway says how many were saved', async ({ browser }) => {
+ const page = await appPage(browser);
+ await stubDirectoryPicker(page, { failOn: 'notes.txt' });
+ await openInMain(page, SUBJECT);
+ await page.locator('[data-testid="attachments-save-to-folder"]').first().click();
+ await expect(toast(page, 'Saved 2 of 3 attachments to Saved mail.')).toBeVisible({ timeout: 30_000 });
+ await page.context().close();
+ });
+
+ test('closing the picker does nothing', async ({ browser }) => {
+ const page = await appPage(browser);
+ await stubDirectoryPicker(page, { cancel: true });
+ await openInMain(page, SUBJECT);
+ await page.locator('[data-testid="attachments-save-to-folder"]').first().click();
+ await expect.poll(() => page.evaluate(() => (window as unknown as { __pickerCalls: number }).__pickerCalls)).toBe(1);
+ await expect(toast(page, /Saved \d/)).toHaveCount(0);
+ await page.context().close();
+ });
+
+ test('is gone when the organisation blocks downloads', async ({ browser }) => {
+ const admin = await adminContext(browser);
+ const page = await appPage(browser);
+ await stubDirectoryPicker(page);
+ await withPolicy(admin, { organisation: { devices: { noDownloads: true } } }, async () => {
+ await page.reload();
+ await stubDirectoryPicker(page);
+ await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 });
+ await openInMain(page, SUBJECT);
+ await expect(page.getByTestId('attachments-downloads-off').first()).toBeVisible({ timeout: 30_000 });
+ await expect(page.locator('[data-testid="attachments-save-to-folder"]')).toHaveCount(0);
+ });
+ await admin.close();
+ await page.context().close();
+ });
+});
+
+test.describe('pop-out windows', () => {
+ test('paints from the server HTML: its own frame, no spinner, no rail, the theme set before the first paint', async ({ browser }) => {
+ const context = await browser.newContext(contextOptions());
+ for (const path of ['/en/popout/compose', '/en/popout/message/x1']) {
+ const res = await context.request.get(path);
+ expect(res.status(), path).toBe(200);
+ const html = await res.text();
+ expect(html, path).toContain('data-popout-skeleton');
+ expect(html, path).toContain('data-popout-status="loading"');
+ expect(html, path).not.toContain('animate-spin');
+ expect(html, path).not.toContain('data-sk-rail');
+ expect(html, path).not.toContain('data-boot-skeleton=');
+ expect(html.indexOf('theme-storage'), path).toBeLessThan(html.indexOf('data-popout-window'));
+ }
+ await context.close();
+ });
+
+ test('a browser tab offers none of it', async ({ browser }) => {
+ const subject = `Tab ${RUN}`;
+ await deliverToAlice(subject);
+ const page = await appPage(browser);
+ await openInMain(page, subject);
+ await expect(page.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 30_000 });
+ await expect(page.locator('[data-testid="viewer-open-in-window"]')).toHaveCount(0);
+ await page.keyboard.press('c');
+ await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible' });
+ await expect(page.locator('[data-testid="composer-open-in-window"]')).toHaveCount(0);
+ await page.context().close();
+ });
+
+ test('the bridge counts too: display-mode is `browser` in Electron', async ({ browser }) => {
+ const subject = `Bridge ${RUN}`;
+ await deliverToAlice(subject);
+ const page = await appPage(browser, BRIDGE);
+ await openInMain(page, subject);
+ await expect(page.locator('[data-testid="viewer-open-in-window"]').first()).toBeVisible({ timeout: 30_000 });
+ await page.context().close();
+ });
+
+ test('a message opens alone, on the main window\'s session, with no rail and no token renewal', async ({ browser }) => {
+ const subject = `Popout ${RUN}`;
+ await deliverToAlice(subject);
+ const page = await appPage(browser, STANDALONE);
+ const calls = watchAuthCalls(page.context());
+ const popup = await popOutMessage(page, subject);
+ await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 });
+ await expect(popup.locator('nav')).toHaveCount(0);
+ expect(popup.url()).toContain('/popout/message/');
+ await expect(popup).toHaveTitle(new RegExp(subject));
+ expect(calls.filter((call) => call.includes('/popout/'))).toEqual([]);
+ await shot(popup, 'pwa-windows-popout-message');
+ await page.context().close();
+ });
+
+ test('opening the same message again focuses its window', async ({ browser }) => {
+ const subject = `Twice ${RUN}`;
+ await deliverToAlice(subject);
+ const page = await appPage(browser, STANDALONE);
+ const popup = await popOutMessage(page, subject);
+ const opened: Page[] = [];
+ page.context().on('page', (p) => opened.push(p));
+ const url = popup.url();
+ let reloaded = false;
+ popup.on('framenavigated', () => { reloaded = true; });
+ await page.locator('[data-testid="viewer-open-in-window"]').first().click();
+ await popup.waitForTimeout(1000);
+ expect(opened).toEqual([]);
+ expect(reloaded).toBe(false);
+ expect(popup.url()).toBe(url);
+ await page.context().close();
+ });
+
+ test('a session with nothing to restore still works, and survives in the list', async ({ browser }) => {
+ const subject = `Forgotten ${RUN}`;
+ await deliverToAlice(subject);
+ const page = await appPage(browser, STANDALONE);
+ await page.evaluate(() => {
+ const registry = JSON.parse(localStorage.getItem('account-registry')!);
+ for (const account of registry.state.accounts) account.rememberMe = false;
+ localStorage.setItem('account-registry', JSON.stringify(registry));
+ });
+ await page.context().clearCookies({ name: 'jmap_session' });
+ const popup = await popOutMessage(page, subject);
+ await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 });
+ const accounts = await page.evaluate(() => JSON.parse(localStorage.getItem('account-registry')!).state.accounts.length);
+ expect(accounts).toBeGreaterThan(0);
+ await expect(accountSwitcher(page)).toBeVisible();
+ await page.context().close();
+ });
+
+ test('a reload with the main window gone restores from the cookies, without evicting', async ({ browser }) => {
+ const subject = `Alone ${RUN}`;
+ await deliverToAlice(subject);
+ const page = await appPage(browser, STANDALONE);
+ const popup = await popOutMessage(page, subject);
+ const before = await popup.evaluate(() => localStorage.getItem('account-registry'));
+ await page.close();
+ await popup.reload();
+ await expect(popup.locator('[data-popout-window="message"][data-popout-status="ready"]')).toBeVisible({ timeout: 60_000 });
+ await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 });
+ expect(await popup.evaluate(() => localStorage.getItem('account-registry'))).toBe(before);
+ await popup.context().close();
+ });
+
+ test('a message deleted or moved while open says so', async ({ browser }) => {
+ const subject = `Gone ${RUN}`;
+ const emailId = await deliverToAlice(subject);
+ const page = await appPage(browser, STANDALONE);
+ const popup = await popOutMessage(page, subject);
+ await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 });
+
+ const alice = await jmapAs(ENT.alice);
+ const archive = await alice.mailboxByRole('archive');
+ if (archive) {
+ await alice.moveEmail(emailId, archive.id);
+ await popup.evaluate(() => window.dispatchEvent(new Event('focus')));
+ await expect(popup.locator('[data-popout-moved]')).toContainText('moved to', { timeout: 30_000 });
+ // A move keeps the id: the message stays readable.
+ await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible();
+ }
+
+ await alice.request([['Email/set', { accountId: alice.accountId, destroy: [emailId] }, '0']]);
+ await popup.evaluate(() => window.dispatchEvent(new Event('focus')));
+ await expect(popup.getByText('This message is gone')).toBeVisible({ timeout: 30_000 });
+ await page.context().close();
+ });
+
+ test('deleting from the pop-out closes it', async ({ browser }) => {
+ const subject = `Delete ${RUN}`;
+ await deliverToAlice(subject);
+ const page = await appPage(browser, STANDALONE);
+ const popup = await popOutMessage(page, subject);
+ await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 });
+ const closed = popup.waitForEvent('close');
+ await popup.getByRole('button', { name: /^Delete$/ }).first().click();
+ await closed;
+ // The main window's list follows.
+ await expect(row(page, subject)).toHaveCount(0, { timeout: 30_000 });
+ const alice = await jmapAs(ENT.alice);
+ const trash = await alice.mailboxByRole('trash');
+ await expect.poll(async () => Object.keys((await alice.findEmailBySubject(subject))?.mailboxIds ?? {}), { intervals: POLL }).toEqual([trash!.id]);
+ });
+
+ test('state follows across windows without a reload', async ({ browser }) => {
+ const subject = `Sync ${RUN}`;
+ await deliverToAlice(subject);
+ const page = await appPage(browser, STANDALONE);
+ const popup = await popOutMessage(page, subject);
+ await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 });
+ let navigated = 0;
+ popup.on('framenavigated', () => { navigated += 1; });
+
+ // Theme.
+ await page.evaluate(() => {
+ const stored = JSON.parse(localStorage.getItem('theme-storage') ?? '{"state":{"theme":"light"},"version":1}');
+ stored.state.theme = 'dark';
+ localStorage.setItem('theme-storage', JSON.stringify(stored));
+ });
+ await expect(popup.locator('html')).toHaveClass(/dark/, { timeout: 10_000 });
+
+ // Language.
+ await page.evaluate(() => localStorage.setItem('locale-storage', JSON.stringify({ state: { locale: 'de' }, version: 0 })));
+ await expect(popup.locator('html')).toHaveAttribute('lang', 'de', { timeout: 15_000 });
+ await page.evaluate(() => localStorage.setItem('locale-storage', JSON.stringify({ state: { locale: 'en' }, version: 0 })));
+ await expect(popup.locator('html')).toHaveAttribute('lang', 'en', { timeout: 15_000 });
+
+ // Read state: from the pop-out to the main window's list. The main window
+ // looks at another message: the one it has open it would mark read again.
+ await expect(row(page, subject)).toHaveAttribute('data-unread', 'false', { timeout: 30_000 });
+ const other = `Sync other ${RUN}`;
+ await deliverToAlice(other);
+ await openInMain(page, other);
+ await popup.getByTitle('Mark as unread').first().click();
+ await expect(row(page, subject)).toHaveAttribute('data-unread', 'true', { timeout: 30_000 });
+
+ // And from the main window to the pop-out.
+ await row(page, subject).click();
+ await expect(row(page, subject)).toHaveAttribute('data-unread', 'false', { timeout: 30_000 });
+ await expect(popup.getByTitle('Mark as unread').first()).toBeVisible({ timeout: 30_000 });
+ expect(navigated).toBe(0);
+ await page.context().close();
+ });
+});
+
+test.describe('a composer in its own window', () => {
+ async function popOutComposer(page: Page, subject: string): Promise {
+ await page.keyboard.press('c');
+ await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15_000 });
+ const input = page.locator('[data-testid="composer-to"] input').first();
+ await input.click();
+ await input.fill(ENT.bob);
+ await input.press('Enter');
+ await page.locator('[data-testid="composer-subject"]').first().fill(subject);
+ await page.locator('[data-testid="email-composer"] [contenteditable="true"]').first().fill(`Body of ${subject}`);
+ const popup = await openPopout(page, () => page.locator('[data-testid="composer-open-in-window"]').first().click());
+ await expect(popup.locator('[data-popout-window="compose"][data-popout-status="ready"]')).toBeVisible({ timeout: 60_000 });
+ await expect(popup.locator('[data-testid="composer-subject"]').first()).toHaveValue(subject, { timeout: 30_000 });
+ return popup;
+ }
+
+ test('the draft moves over under one draft id, and the window closes after sending', async ({ browser }) => {
+ const subject = `Moved ${RUN}`;
+ const page = await appPage(browser, STANDALONE);
+ const popup = await popOutComposer(page, subject);
+
+ // Gone from the main window, with no "continue draft" left behind.
+ await expect(page.locator('[data-testid="email-composer"]')).toHaveCount(0);
+ await expect(popup.locator('[data-testid="composer-to"]')).toContainText(ENT.bob);
+ await expect(popup.locator('[data-testid="email-composer"] [contenteditable="true"]').first()).toContainText(`Body of ${subject}`);
+ await expect(popup.locator('nav')).toHaveCount(0);
+ await expect.poll(async () => (await draftsCalled(subject)).length, { timeout: 15_000, intervals: POLL }).toBe(1);
+ const [first] = await draftsCalled(subject);
+ await shot(popup, 'pwa-windows-popout-composer');
+
+ // Further edits replace that draft, they don't start another.
+ await popup.locator('[data-testid="composer-subject"]').first().fill(`${subject} edited`);
+ await expect.poll(async () => (await draftsCalled(`${subject} edited`)).length, { timeout: 15_000, intervals: POLL }).toBe(1);
+ await expect.poll(async () => (await draftsCalled(subject)).map((draft) => draft.id).includes(first.id), { timeout: 15_000, intervals: POLL }).toBe(false);
+
+ const closed = popup.waitForEvent('close');
+ await popup.locator('[data-testid="composer-send"]').first().click();
+ await closed;
+
+ const bob = await jmapAs(ENT.bob);
+ await bob.waitForEmail(`${subject} edited`, { timeoutMs: 60_000 });
+ await expect.poll(async () => (await draftsCalled(`${subject} edited`)).length, { timeout: 20_000, intervals: POLL }).toBe(0);
+ // The main window says it went, since the pop-out can't any more.
+ await expect(toast(page, 'Email sent')).toBeVisible({ timeout: 30_000 });
+ await page.context().close();
+ });
+
+ test('closing the window with unsaved text saves the draft', async ({ browser }) => {
+ const subject = `Unsaved ${RUN}`;
+ const page = await appPage(browser, STANDALONE);
+ const popup = await popOutComposer(page, subject);
+ await expect.poll(async () => (await draftsCalled(subject)).length, { timeout: 15_000, intervals: POLL }).toBe(1);
+ const typed = `${subject} typed`;
+ await popup.locator('[data-testid="composer-subject"]').first().fill(typed);
+ // Inside the 2 s of the autosave, so only the close can save it. No prompt: the main window takes it.
+ let prompted = false;
+ popup.on('dialog', (dialog) => { prompted = true; void dialog.accept(); });
+ const closed = popup.waitForEvent('close');
+ await popup.close({ runBeforeUnload: true });
+ await closed;
+ expect(prompted).toBe(false);
+ await expect.poll(async () => (await draftsCalled(typed)).length, { timeout: 20_000, intervals: POLL }).toBe(1);
+ // It replaced the version the window started from.
+ await expect.poll(async () => (await draftsCalled(subject)).filter((draft) => draft.subject === subject).length, { timeout: 20_000, intervals: POLL }).toBe(0);
+ await page.context().close();
+ });
+
+ test('with no main window left it asks, and staying lets the save finish', async ({ browser }) => {
+ const subject = `Asked ${RUN}`;
+ const page = await appPage(browser, STANDALONE);
+ const popup = await popOutComposer(page, subject);
+ await expect.poll(async () => (await draftsCalled(subject)).length, { timeout: 15_000, intervals: POLL }).toBe(1);
+ await page.close();
+ const typed = `${subject} typed`;
+ await popup.locator('[data-testid="composer-subject"]').first().fill(typed);
+ const asked = new Promise((resolve) => {
+ popup.once('dialog', (dialog) => {
+ expect(dialog.type()).toBe('beforeunload');
+ void dialog.dismiss();
+ resolve();
+ });
+ });
+ await popup.close({ runBeforeUnload: true });
+ await asked;
+ expect(popup.isClosed()).toBe(false);
+ await expect.poll(async () => (await draftsCalled(typed)).length, { timeout: 20_000, intervals: POLL }).toBe(1);
+ });
+
+ test('a blocked window leaves the composer where it is', async ({ browser }) => {
+ const subject = `Blocked ${RUN}`;
+ const page = await appPage(browser, STANDALONE);
+ await page.evaluate(() => { window.open = () => null; });
+ await page.keyboard.press('c');
+ await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15_000 });
+ await page.locator('[data-testid="composer-subject"]').first().fill(subject);
+ await page.locator('[data-testid="composer-open-in-window"]').first().click();
+ await expect(toast(page, /blocked the new window/)).toBeVisible();
+ await expect(page.locator('[data-testid="composer-subject"]').first()).toHaveValue(subject);
+ await page.context().close();
+ });
+});
+
+test.describe('what a window must not lose', () => {
+ test('text whose save fails in the main window lands in its "Continue draft" slot', async ({ browser }) => {
+ const subject = `Refused ${RUN}`;
+ const page = await appPage(browser, STANDALONE);
+ await page.keyboard.press('c');
+ await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15_000 });
+ await page.locator('[data-testid="composer-subject"]').first().fill(subject);
+ const popup = await openPopout(page, () => page.locator('[data-testid="composer-open-in-window"]').first().click());
+ await expect(popup.locator('[data-testid="composer-subject"]').first()).toHaveValue(subject, { timeout: 60_000 });
+ await expect.poll(async () => (await draftsCalled(subject)).length, { timeout: 15_000, intervals: POLL }).toBe(1);
+ // Give the pop-out time to see the main window answer its ping.
+ await popup.waitForTimeout(500);
+ const typed = `${subject} typed`;
+ await popup.locator('[data-testid="composer-subject"]').first().fill(typed);
+ // The server stops taking saves, for the main window's rescue too.
+ await page.context().route('**/jmap/**', async (route) => {
+ if ((route.request().postData() ?? '').includes('Email/set')) await route.abort();
+ else await route.continue();
+ });
+ let prompted = false;
+ popup.on('dialog', (dialog) => { prompted = true; void dialog.accept(); });
+ const closed = popup.waitForEvent('close');
+ await popup.close({ runBeforeUnload: true });
+ await closed;
+ expect(prompted).toBe(false);
+ await expect(toast(page, /save/i)).toBeVisible({ timeout: 30_000 });
+ await expect(page.getByText('Continue draft').first()).toBeVisible({ timeout: 30_000 });
+ await expect(page.getByText(typed).first()).toBeVisible();
+ await page.context().unroute('**/jmap/**');
+ await page.context().close();
+ });
+
+ test('editing a draft from a message window opens a live composer, and only then closes', async ({ browser }) => {
+ const subject = `Edit ${RUN}`;
+ const alice = await jmapAs(ENT.alice);
+ const draftId = await alice.createDraft(subject, ENT.bob);
+ const page = await appPage(browser, STANDALONE);
+ const drafts = await alice.mailboxByRole('drafts');
+ await page.goto(`/mail/folder/${drafts!.id}`);
+ await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 });
+ await expect(row(page, subject)).toBeVisible({ timeout: 60_000 });
+ const message = await openPopout(page, async () => {
+ await row(page, subject).click({ button: 'right' });
+ await page.getByTestId('ctx-open-window').click();
+ });
+ await expect(message.locator('[data-popout-window="message"][data-popout-status="ready"]')).toBeVisible({ timeout: 60_000 });
+ const closed = message.waitForEvent('close');
+ const composer = await openPopout(message, async () => {
+ await message.getByRole('button', { name: /^Edit/ }).first().click();
+ });
+ await expect(composer.locator('[data-testid="composer-subject"]').first()).toHaveValue(subject, { timeout: 60_000 });
+ await closed;
+ // Live: it still saves over the same draft, with the window that opened it gone.
+ await composer.locator('[data-testid="composer-subject"]').first().fill(`${subject} edited`);
+ await expect.poll(async () => (await draftsCalled(`${subject} edited`)).length, { timeout: 20_000, intervals: POLL }).toBe(1);
+ await expect.poll(async () => (await draftsCalled(subject)).filter((draft) => draft.id === draftId).length, { timeout: 20_000, intervals: POLL }).toBe(0);
+ await page.context().close();
+ });
+});
+
+test.describe('the attachment bar', () => {
+ test('stays one row in a narrow pane, with both buttons in reach', async ({ browser }) => {
+ const subject = `Narrow ${RUN}`;
+ await messageWithAttachments(subject);
+ const page = await appPage(browser);
+ await stubDirectoryPicker(page);
+ await openInMain(page, subject);
+ const save = page.locator('[data-testid="attachments-save-to-folder"]').first();
+ await expect(save).toBeVisible({ timeout: 30_000 });
+ await page.setViewportSize({ width: 900, height: 900 });
+ const pane = page.locator('[data-testid="reader-attachments"]').first();
+ await expect(pane).toBeVisible();
+ const row = save.locator('xpath=..');
+ const rowBox = await row.boundingBox();
+ const saveBox = await save.boundingBox();
+ expect(rowBox!.height).toBeLessThan(48);
+ expect(saveBox!.x + saveBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width + 1);
+ await expect(save).toHaveAttribute('aria-label', /folder/i);
+ await page.context().close();
+ });
+});
+
+test.describe('the desktop app\'s behaviour', () => {
+ test('plain-text mail can be selected, list rows can\'t', async ({ browser }) => {
+ const subject = `Selectable ${RUN}`;
+ await deliverToAlice(subject);
+ const page = await appPage(browser, BRIDGE);
+ await openInMain(page, subject);
+ const body = page.locator('[data-plugin-source="email-body"]').first();
+ await expect(body).toBeVisible({ timeout: 30_000 });
+ expect(await body.evaluate((el) => getComputedStyle(el).userSelect)).toBe('text');
+ expect(await row(page, subject).evaluate((el) => getComputedStyle(el).userSelect)).toBe('none');
+ // A real selection, the way a user would make one.
+ await body.click({ clickCount: 3 });
+ expect(await page.evaluate(() => window.getSelection()?.toString() ?? '')).toContain(`Body of ${subject}`);
+ await page.context().close();
+ });
+
+ test('hangs on the bridge: a browser gets none of it', async ({ browser }) => {
+ const plain = await appPage(browser);
+ expect(await plain.locator('html').getAttribute('data-desktop')).toBeNull();
+ const button = plain.locator('button').first();
+ expect(await button.evaluate((el) => getComputedStyle(el).userSelect)).not.toBe('none');
+ await plain.context().close();
+
+ const app = await appPage(browser, BRIDGE);
+ expect(await app.locator('html').getAttribute('data-desktop')).toBe('');
+ const chrome = app.locator('button').first();
+ expect(await chrome.evaluate((el) => getComputedStyle(el).userSelect)).toBe('none');
+ expect(await app.evaluate(() => getComputedStyle(document.documentElement).overscrollBehaviorY)).toBe('none');
+ await app.context().close();
+ });
+});
diff --git a/lib/__tests__/desktop-window-sync.test.ts b/lib/__tests__/desktop-window-sync.test.ts
new file mode 100644
index 000000000..260c8f788
--- /dev/null
+++ b/lib/__tests__/desktop-window-sync.test.ts
@@ -0,0 +1,158 @@
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { desktopBootScript } from '@/lib/desktop/boot';
+import { startWindowSync } from '@/lib/desktop/window-sync';
+import { useLocaleStore } from '@/stores/locale-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { useThemeStore } from '@/stores/theme-store';
+
+/** What another window of this browser writing the store looks like here. */
+function writeFromAnotherWindow(key: string, state: Record) {
+ const current = JSON.parse(localStorage.getItem(key) ?? '{"state":{},"version":0}');
+ const value = JSON.stringify({ ...current, state: { ...current.state, ...state } });
+ localStorage.setItem(key, value);
+ window.dispatchEvent(new StorageEvent('storage', { key, newValue: value }));
+}
+
+const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+describe('state across windows', () => {
+ let stop: () => void = () => {};
+
+ beforeEach(() => {
+ useThemeStore.setState({ theme: 'light' });
+ useLocaleStore.setState({ locale: 'en' });
+ });
+
+ afterEach(() => {
+ stop();
+ stop = () => {};
+ delete window.bulwarkDesktop;
+ window.history.replaceState(null, '', '/');
+ localStorage.clear();
+ document.documentElement.classList.remove('dark', 'light');
+ });
+
+ function asPopout() {
+ window.history.replaceState(null, '', '/en/popout/compose');
+ stop = startWindowSync();
+ }
+
+ it('follows a theme change without a reload, down to the page', async () => {
+ asPopout();
+ writeFromAnotherWindow('theme-storage', { theme: 'dark' });
+ await settle();
+ expect(useThemeStore.getState().theme).toBe('dark');
+ expect(document.documentElement.classList.contains('dark')).toBe(true);
+ });
+
+ it('follows a theme pack change too, which rehydrating alone does not put on the page', async () => {
+ asPopout();
+ const pack = useThemeStore.getState().installedThemes.find((theme) => theme.css);
+ expect(pack).toBeTruthy();
+ writeFromAnotherWindow('theme-storage', { theme: 'light', activeThemeId: pack!.id });
+ await settle();
+ expect(document.querySelector('style[id*="theme"]')?.textContent).toContain(pack!.css);
+ });
+
+ it('follows a language change without a reload', async () => {
+ asPopout();
+ writeFromAnotherWindow('locale-storage', { locale: 'de' });
+ await settle();
+ expect(useLocaleStore.getState().locale).toBe('de');
+ });
+
+ it('follows a settings change without a reload', async () => {
+ asPopout();
+ const before = useSettingsStore.getState().plainTextMode;
+ writeFromAnotherWindow('settings-storage', { plainTextMode: !before });
+ await settle();
+ expect(useSettingsStore.getState().plainTextMode).toBe(!before);
+ });
+
+ it('also runs in the desktop app\'s main window', async () => {
+ window.bulwarkDesktop = { windows: {} };
+ stop = startWindowSync();
+ writeFromAnotherWindow('locale-storage', { locale: 'fr' });
+ await settle();
+ expect(useLocaleStore.getState().locale).toBe('fr');
+ });
+
+ it('leaves a browser tab as it was', async () => {
+ stop = startWindowSync();
+ writeFromAnotherWindow('locale-storage', { locale: 'fr' });
+ await settle();
+ expect(useLocaleStore.getState().locale).toBe('en');
+ });
+});
+
+describe('the app-like behaviour', () => {
+ const css = readFileSync(join(process.cwd(), 'app/desktop.css'), 'utf8');
+ const rules = css.replace(/\/\*[\s\S]*?\*\//g, '').match(/[^{}]+\{[^}]*\}/g) ?? [];
+
+ it('hangs entirely on html[data-desktop], so browsers do not change', () => {
+ expect(rules.length).toBeGreaterThan(0);
+ for (const rule of rules) {
+ const selector = rule.slice(0, rule.indexOf('{'));
+ // Every selector of the list; commas inside :is() don't split it.
+ const parts = selector.split(/,(?![^(]*\))/);
+ for (const part of parts) expect(part.trim(), part).toMatch(/^html\[data-desktop\]/);
+ }
+ });
+
+ it('covers UI chrome and what the spec names', () => {
+ expect(css).toMatch(/user-select: none/);
+ expect(css).toMatch(/cursor: default/);
+ expect(css).toMatch(/overscroll-behavior: none/);
+ expect(css).toMatch(/-webkit-user-drag: none/);
+ });
+
+ it('keeps fields selectable and what the app drags draggable', () => {
+ expect(css).toMatch(/:is\(input, textarea, select, \[contenteditable="true"\][^{]*\{\s*-webkit-user-select: text;\s*user-select: text;/);
+ expect(css).toMatch(/\[draggable="true"\]\s*\{\s*-webkit-user-drag: element;/);
+ });
+
+ it('does not switch selection off for the whole page, so message content stays selectable', () => {
+ expect(css).not.toMatch(/html\[data-desktop\]( body)?\s*\{[^}]*user-select: none/);
+ });
+
+ it('matches list rows only, not the plain-text body that carries the same data attribute', () => {
+ // email-viewer.tsx and thread-conversation-view.tsx put data-email-id on the body.
+ for (const file of ['components/email/email-viewer.tsx', 'components/email/thread-conversation-view.tsx']) {
+ const source = readFileSync(join(process.cwd(), file), 'utf8').replace(/\r\n/g, '\n');
+ expect(source, file).toMatch(/data-plugin-source="email-body"\n\s*data-email-id=/);
+ }
+ expect(css).not.toContain('data-email-id');
+ });
+
+ it('keeps a plain-text body selectable wherever it sits', () => {
+ expect(css).toMatch(/\[data-plugin-source="email-body"\],\s*html\[data-desktop\] \[data-plugin-source="email-body"\] \*\s*\{\s*-webkit-user-select: text;\s*user-select: text;/);
+ });
+
+ function runBoot() {
+ new Function(desktopBootScript())();
+ }
+
+ afterEach(() => {
+ delete window.bulwarkDesktop;
+ document.documentElement.removeAttribute('data-desktop');
+ });
+
+ it('marks the page before the first paint when the bridge is there', () => {
+ window.bulwarkDesktop = {};
+ runBoot();
+ expect(document.documentElement.hasAttribute('data-desktop')).toBe(true);
+ });
+
+ it('does not mark a browser', () => {
+ runBoot();
+ expect(document.documentElement.hasAttribute('data-desktop')).toBe(false);
+ });
+
+ it('is in the root layout\'s boot script', () => {
+ const layout = readFileSync(join(process.cwd(), 'app/(main)/layout.tsx'), 'utf8');
+ expect(layout).toContain('${desktopBootScript()}');
+ expect(layout).toContain('../desktop.css');
+ });
+});
diff --git a/lib/__tests__/fixtures/fake-broadcast-channel.ts b/lib/__tests__/fixtures/fake-broadcast-channel.ts
new file mode 100644
index 000000000..fd683cbe9
--- /dev/null
+++ b/lib/__tests__/fixtures/fake-broadcast-channel.ts
@@ -0,0 +1,26 @@
+/**
+ * A same-process BroadcastChannel for tests: what one instance posts reaches
+ * every other open instance of that name, on a later task, never the sender.
+ */
+export class FakeBroadcastChannel {
+ static open = new Set();
+ static reset() {
+ FakeBroadcastChannel.open.clear();
+ }
+ onmessage: ((event: MessageEvent) => void) | null = null;
+ constructor(public name: string) {
+ FakeBroadcastChannel.open.add(this);
+ }
+ postMessage(data: unknown) {
+ const copy = JSON.parse(JSON.stringify(data));
+ for (const other of [...FakeBroadcastChannel.open]) {
+ if (other === this || other.name !== this.name) continue;
+ setTimeout(() => {
+ if (FakeBroadcastChannel.open.has(other)) other.onmessage?.({ data: copy } as MessageEvent);
+ }, 0);
+ }
+ }
+ close() {
+ FakeBroadcastChannel.open.delete(this);
+ }
+}
diff --git a/lib/__tests__/lite-scripts.test.ts b/lib/__tests__/lite-scripts.test.ts
index 8e103daa4..5bf2edb5d 100644
--- a/lib/__tests__/lite-scripts.test.ts
+++ b/lib/__tests__/lite-scripts.test.ts
@@ -42,6 +42,7 @@ import {
inlineScriptHashes,
installStalwartFetchShim,
isReservedStalwartSegment,
+ LITE_REMOVED_PATHS,
listZipEntries,
makeBuildId,
normalizeBasePath,
@@ -352,6 +353,8 @@ describe('prepare / postbuild / verify against a fake checkout', () => {
expect(plan).toContain('proxy.ts');
expect(plan).toContain('app/api');
expect(plan).toContain('app/(main)/admin');
+ // A static export can't list every message id; Lite doesn't offer pop-outs.
+ expect(LITE_REMOVED_PATHS).toContain('app/(main)/[locale]/popout');
expect(plan).not.toContain('app/(main)/[locale]/mail/[[...segments]]/page.tsx');
expect(plan).not.toContain('scripts/lite/smoke/lite.spec.ts');
} finally {
diff --git a/lib/__tests__/popout-windows.test.ts b/lib/__tests__/popout-windows.test.ts
new file mode 100644
index 000000000..0832f163e
--- /dev/null
+++ b/lib/__tests__/popout-windows.test.ts
@@ -0,0 +1,351 @@
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { FakeBroadcastChannel } from './fixtures/fake-broadcast-channel';
+import { isPopoutPath, mainWindowStorage } from '@/lib/popout/window-role';
+
+// Pop-out windows: which paths are one, when they are on offer, how they open,
+// and how a draft moves to one.
+
+function stubDisplay({ standalone = true, fine = true }: { standalone?: boolean; fine?: boolean } = {}) {
+ vi.stubGlobal('matchMedia', (query: string) => ({
+ matches: query.includes('pointer: fine') ? fine : query.includes('display-mode: standalone') ? standalone : false,
+ addEventListener() {},
+ removeEventListener() {},
+ }));
+ window.matchMedia = globalThis.matchMedia;
+}
+
+beforeEach(() => {
+ FakeBroadcastChannel.reset();
+ vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel);
+ Object.defineProperty(navigator, 'locks', { value: { request: vi.fn() }, configurable: true });
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ delete window.bulwarkDesktop;
+ Object.defineProperty(navigator, 'locks', { value: undefined, configurable: true });
+ window.history.replaceState(null, '', '/');
+});
+
+describe('isPopoutPath', () => {
+ it('knows the two routes, with or without locale and mount prefix', () => {
+ for (const path of ['/popout/compose', '/en/popout/compose', '/zh-TW/popout/message/abc', '/webmail/de/popout/message/abc', '/popout/compose/']) {
+ expect(isPopoutPath(path), path).toBe(true);
+ }
+ });
+
+ it('does not take other pages for one', () => {
+ // A folder in Files can have any name, and a mail folder too.
+ for (const path of ['/', '/mail', '/files/popout/compose', '/en/mail/folder/popout', '/popout', '/popout/message', '/popout/message/a/b', '/en/files/popout/message/x', '/files/en/popout/compose', '/some/folder/popout/compose']) {
+ expect(isPopoutPath(path), path).toBe(false);
+ }
+ });
+});
+
+describe('isPopoutPath under a mount named like a page (NEXT_PUBLIC_BASE_PATH)', () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.resetModules();
+ });
+
+ it('takes the mount off first', async () => {
+ vi.stubEnv('NEXT_PUBLIC_BASE_PATH', '/mail');
+ vi.resetModules();
+ const { isPopoutPath: underMail } = await import('@/lib/popout/window-role');
+ expect(underMail('/mail/popout/compose')).toBe(true);
+ expect(underMail('/mail/de/popout/message/abc')).toBe(true);
+ // Under that mount, its files page stays a files page.
+ expect(underMail('/mail/files/popout/compose')).toBe(false);
+ });
+});
+
+describe('mainWindowStorage', () => {
+ it('lets a main window write, and a pop-out only read', () => {
+ localStorage.setItem('account-registry', 'before');
+ const storage = mainWindowStorage();
+
+ storage.setItem('account-registry', 'main');
+ expect(localStorage.getItem('account-registry')).toBe('main');
+
+ window.history.replaceState(null, '', '/en/popout/compose');
+ expect(storage.getItem('account-registry')).toBe('main');
+ storage.setItem('account-registry', 'popout');
+ storage.removeItem('account-registry');
+ expect(localStorage.getItem('account-registry')).toBe('main');
+ });
+});
+
+describe('popoutsAvailable', () => {
+ async function available() {
+ const { popoutsAvailable } = await import('@/lib/popout/support');
+ return popoutsAvailable();
+ }
+
+ it('is off in a browser tab: the app behaves as it always has', async () => {
+ stubDisplay({ standalone: false });
+ expect(await available()).toBe(false);
+ });
+
+ it('is on in an installed app on a device with a pointer', async () => {
+ stubDisplay();
+ expect(await available()).toBe(true);
+ });
+
+ it('is off in an installed app on a touch device, where window.open leaves the app', async () => {
+ stubDisplay({ fine: false });
+ expect(await available()).toBe(false);
+ });
+
+ it('asks the bridge too: display-mode is `browser` in Electron', async () => {
+ stubDisplay({ standalone: false });
+ window.bulwarkDesktop = { windows: {} };
+ expect(await available()).toBe(true);
+ });
+
+ it('is off without Web Locks or BroadcastChannel, which keep the session safe across windows', async () => {
+ stubDisplay();
+ Object.defineProperty(navigator, 'locks', { value: undefined, configurable: true });
+ expect(await available()).toBe(false);
+ Object.defineProperty(navigator, 'locks', { value: { request: vi.fn() }, configurable: true });
+ vi.stubGlobal('BroadcastChannel', undefined);
+ expect(await available()).toBe(false);
+ });
+
+ it('is off where sessions can\'t be sealed: a page not in a secure context has no crypto.subtle', async () => {
+ stubDisplay();
+ expect(await available()).toBe(true);
+ vi.stubGlobal('crypto', {});
+ expect(await available()).toBe(false);
+ });
+
+ it('is off in Lite, which has no such route', async () => {
+ vi.resetModules();
+ vi.doMock('@/lib/lite', async (original) => ({ ...await original(), IS_LITE: true }));
+ stubDisplay();
+ const { popoutsAvailable } = await import('@/lib/popout/support');
+ expect(popoutsAvailable()).toBe(false);
+ vi.doUnmock('@/lib/lite');
+ vi.resetModules();
+ });
+});
+
+describe('opening windows', () => {
+ it('opens a message as a popup in the app, with its login and account', async () => {
+ const { openMessageWindow } = await import('@/lib/popout/open');
+ const open = vi.spyOn(window, 'open').mockReturnValue({ focus: vi.fn(), closed: false } as unknown as Window);
+ expect(openMessageWindow({ emailId: 'm/1', localAccountId: 'login-b', ownerAccountId: 'group' })).toBe(true);
+ const [url, name, features] = open.mock.calls[0];
+ expect(url).toBe('/popout/message/m%2F1?account=login-b&owner=group');
+ expect(name).toBe('bulwark-message-m_1');
+ expect(features).toMatch(/^popup=yes,width=\d+,height=\d+,left=\d+,top=\d+$/);
+ // In an installed app noopener sends the window to the system browser.
+ expect(features).not.toContain('noopener');
+ });
+
+ it('focuses the window a message already has, without loading it again', async () => {
+ const { openMessageWindow } = await import('@/lib/popout/open');
+ const focus = vi.fn();
+ const open = vi.spyOn(window, 'open').mockReturnValue({ focus, closed: false } as unknown as Window);
+ openMessageWindow({ emailId: 'twice' });
+ openMessageWindow({ emailId: 'twice' });
+ expect(open).toHaveBeenCalledTimes(1);
+ expect(focus).toHaveBeenCalledTimes(2);
+ });
+
+ it('opens it again once that window was closed', async () => {
+ const { openMessageWindow } = await import('@/lib/popout/open');
+ const first = { focus: vi.fn(), closed: false };
+ const open = vi.spyOn(window, 'open').mockReturnValue(first as unknown as Window);
+ openMessageWindow({ emailId: 'reopened' });
+ first.closed = true;
+ openMessageWindow({ emailId: 'reopened' });
+ expect(open).toHaveBeenCalledTimes(2);
+ });
+
+ it('leaves it to the bridge when the bridge opens windows, under the same name', async () => {
+ const { openMessageWindow } = await import('@/lib/popout/open');
+ const bridgeOpen = vi.fn();
+ window.bulwarkDesktop = { windows: { open: bridgeOpen } };
+ const open = vi.spyOn(window, 'open');
+ expect(openMessageWindow({ emailId: 'bridged' })).toBe(true);
+ expect(openMessageWindow({ emailId: 'bridged' })).toBe(true);
+ expect(open).not.toHaveBeenCalled();
+ expect(bridgeOpen.mock.calls[0][1]).toMatchObject({ name: 'bulwark-message-bridged' });
+ expect(bridgeOpen.mock.calls[1][1].name).toBe(bridgeOpen.mock.calls[0][1].name);
+ });
+
+ it('says false when a blocker stops the window', async () => {
+ const { openComposerWindow, openMessageWindow } = await import('@/lib/popout/open');
+ vi.spyOn(window, 'open').mockReturnValue(null);
+ expect(openMessageWindow({ emailId: 'blocked' })).toBe(false);
+ expect(openComposerWindow()).toBeNull();
+ });
+});
+
+describe('moving a draft to a pop-out', () => {
+ const payload = { mode: 'reply' as const, initialData: { draftId: 'saved-3', subject: 'Hi' } as never };
+ const settle = (ms = 5) => new Promise((resolve) => setTimeout(resolve, ms));
+
+ // The module keeps its channel while a handoff is pending.
+ beforeEach(() => vi.resetModules());
+
+ it('hands the draft to the window that asks, once it is ready, under the same draft id', async () => {
+ const { createComposeHandoff, receiveComposeHandoff } = await import('@/lib/popout/compose-handoff');
+ const handoff = createComposeHandoff()!;
+ const received = receiveComposeHandoff(handoff.id);
+ await settle();
+ // The pop-out is open and waiting; the save hasn't finished.
+ let done = false;
+ void received.then(() => { done = true; });
+ await settle();
+ expect(done).toBe(false);
+
+ void handoff.complete(payload);
+ await expect(received).resolves.toMatchObject({ mode: 'reply', initialData: { draftId: 'saved-3' } });
+ });
+
+ it('lets the sender go only once the composer is on screen there', async () => {
+ const { acknowledgeComposeHandoff, createComposeHandoff, receiveComposeHandoff } = await import('@/lib/popout/compose-handoff');
+ const handoff = createComposeHandoff()!;
+ void receiveComposeHandoff(handoff.id);
+ await settle();
+ let shown: boolean | undefined;
+ void handoff.complete(payload).then((result) => { shown = result; });
+ await settle();
+ expect(shown).toBeUndefined();
+ acknowledgeComposeHandoff(handoff.id);
+ await settle();
+ expect(shown).toBe(true);
+ });
+
+ it('tells the sender when the composer never shows, and it can still call it off', async () => {
+ const { createComposeHandoff, receiveComposeHandoff, watchHandoffCancel } = await import('@/lib/popout/compose-handoff');
+ const handoff = createComposeHandoff()!;
+ void receiveComposeHandoff(handoff.id);
+ await settle();
+ const cancelled = vi.fn();
+ const stop = watchHandoffCancel(handoff.id, cancelled);
+ await expect(handoff.complete(payload, 20)).resolves.toBe(false);
+ handoff.cancel();
+ await settle();
+ expect(cancelled).toHaveBeenCalledTimes(1);
+ stop();
+ });
+
+ it('keeps delivering when the message window that opened it is still there', async () => {
+ const { createComposeHandoff, receiveComposeHandoff } = await import('@/lib/popout/compose-handoff');
+ const handoff = createComposeHandoff()!;
+ // The draft is ready before the new window asks for it.
+ void handoff.complete(payload);
+ await settle();
+ await expect(receiveComposeHandoff(handoff.id)).resolves.toMatchObject({ mode: 'reply' });
+ });
+
+ it('tells the pop-out when the move is called off', async () => {
+ const { createComposeHandoff, receiveComposeHandoff } = await import('@/lib/popout/compose-handoff');
+ const handoff = createComposeHandoff()!;
+ const received = receiveComposeHandoff(handoff.id);
+ await settle();
+ handoff.cancel();
+ await expect(received).resolves.toBeNull();
+ });
+
+ it('gives up when nothing comes in time', async () => {
+ const { receiveComposeHandoff } = await import('@/lib/popout/compose-handoff');
+ await expect(receiveComposeHandoff('nobody-has-this', 10)).resolves.toBeNull();
+ });
+
+ it('starts a reload of the pop-out from what it holds, with no opener needed', async () => {
+ const { rememberComposePayload, receiveComposeHandoff } = await import('@/lib/popout/compose-handoff');
+ rememberComposePayload('kept', payload);
+ FakeBroadcastChannel.reset();
+ vi.stubGlobal('BroadcastChannel', undefined);
+ await expect(receiveComposeHandoff('kept')).resolves.toMatchObject({ initialData: { draftId: 'saved-3' } });
+ });
+});
+
+describe('asking a main window for the session', () => {
+ it('takes the first answer and goes on without one after the timeout', async () => {
+ const { openPopoutChannel, requestWindowSession } = await import('@/lib/popout/channel');
+ const { sealFor } = await import('@/lib/popout/sealed');
+ await expect(requestWindowSession({}, 10)).resolves.toBeNull();
+
+ const main = openPopoutChannel((message) => {
+ if (message.type === 'session-request') {
+ void sealFor(message.publicKey, { activeAccountId: 'a', sessions: [] })
+ .then((sealed) => main?.post({ type: 'session-answer', requestId: message.requestId, sealed }));
+ }
+ });
+ await expect(requestWindowSession({}, 1000)).resolves.toEqual({ activeAccountId: 'a', sessions: [] });
+ main?.close();
+ });
+
+ it('puts no credential on the channel: a listener hears neither the header nor the refused one', async () => {
+ const { openPopoutChannel, requestWindowSession } = await import('@/lib/popout/channel');
+ const { sealFor } = await import('@/lib/popout/sealed');
+ const secret = 'Bearer very-secret-token';
+ const refused = 'Bearer refused-secret-token';
+ const heard: unknown[] = [];
+ const eavesdropper = openPopoutChannel((message) => heard.push(message));
+ const main = openPopoutChannel((message) => {
+ if (message.type !== 'session-request') return;
+ void sealFor(message.publicKey, { activeAccountId: 'a', sessions: [{ accountId: 'a', authHeader: secret }] })
+ .then((sealed) => main?.post({ type: 'session-answer', requestId: message.requestId, sealed }));
+ });
+ const answer = await requestWindowSession({ accountId: 'a', rejectedAuthHeader: refused }, 1000);
+ expect(answer?.sessions[0].authHeader).toBe(secret);
+ expect(heard.length).toBeGreaterThanOrEqual(2);
+ expect(JSON.stringify(heard)).not.toContain('secret');
+ main?.close();
+ eavesdropper?.close();
+ });
+
+ it('a listener can\'t open an answer meant for another window', async () => {
+ const { createSessionOpener, sealFor } = await import('@/lib/popout/sealed');
+ const asker = await createSessionOpener();
+ const other = await createSessionOpener();
+ const sealed = await sealFor(asker.publicKey, { secret: true });
+ await expect(asker.open(sealed)).resolves.toEqual({ secret: true });
+ await expect(other.open(sealed)).rejects.toThrow();
+ });
+});
+
+describe('text a closing pop-out could not save', () => {
+ it('waits for the mail view, marked unsaved, oldest first', async () => {
+ vi.resetModules();
+ sessionStorage.clear();
+ const { keepRescuedDraft, onRescuedDraft, takeRescuedDraft } = await import('@/lib/popout/rescued-drafts');
+ const told = vi.fn();
+ const stop = onRescuedDraft(told);
+ keepRescuedDraft({ subject: 'First' } as never);
+ keepRescuedDraft({ subject: 'Second' } as never);
+ stop();
+ expect(told).toHaveBeenCalledTimes(2);
+ expect(takeRescuedDraft()).toMatchObject({ subject: 'First', unsaved: true });
+ expect(takeRescuedDraft()).toMatchObject({ subject: 'Second' });
+ expect(takeRescuedDraft()).toBeNull();
+ });
+
+ it('survives a reload of the main window', async () => {
+ vi.resetModules();
+ sessionStorage.clear();
+ const first = await import('@/lib/popout/rescued-drafts');
+ first.keepRescuedDraft({ subject: 'Kept' } as never);
+ vi.resetModules();
+ const second = await import('@/lib/popout/rescued-drafts');
+ expect(second.takeRescuedDraft()).toMatchObject({ subject: 'Kept' });
+ });
+});
+
+describe('every display-mode check knows the overlay', () => {
+ it('goes through lib/standalone-display.ts', () => {
+ for (const file of ['components/protocol/mailto-protocol-client.tsx', 'components/protocol/webcal-protocol-client.tsx', 'components/protocol/protocol-launch-handler-provider.tsx', 'lib/protocol-handlers/session.ts']) {
+ const source = readFileSync(join(process.cwd(), file), 'utf8');
+ expect(source, file).not.toContain('display-mode: standalone');
+ expect(source, file).toContain('isStandaloneDisplayMode');
+ }
+ });
+});
diff --git a/lib/__tests__/save-to-folder.test.ts b/lib/__tests__/save-to-folder.test.ts
new file mode 100644
index 000000000..f2090a813
--- /dev/null
+++ b/lib/__tests__/save-to-folder.test.ts
@@ -0,0 +1,171 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { canPickFolder, createUniqueNamer, isPickerCancelled, saveFilesToFolder, type FolderHandle } from '@/lib/attachments/save-to-folder';
+
+class FakeFolder implements FolderHandle {
+ name = 'Downloads';
+ files = new Map();
+ failWriteOf: string | null = null;
+ removed: string[] = [];
+ refused = new Set();
+
+ async getFileHandle(name: string, options?: { create?: boolean }) {
+ // What Windows and Chromium's File System Access refuse.
+ const stem = name.split('.')[0].toLowerCase();
+ if (/^(con|prn|aux|nul|com\d|lpt\d)$/.test(stem)
+ || /\.(lnk|url|scf|local)$/i.test(name)
+ || name.toLowerCase() === 'desktop.ini'
+ || new TextEncoder().encode(name).length > 255
+ || this.refused.has(name)) {
+ throw new TypeError(`Name is not allowed: ${name}`);
+ }
+ if (!this.files.has(name)) {
+ if (!options?.create) throw Object.assign(new Error('missing'), { name: 'NotFoundError' });
+ this.files.set(name, '');
+ }
+ return {
+ createWritable: async () => ({
+ write: async (data: unknown) => {
+ if (this.failWriteOf === name) throw Object.assign(new Error('disk full'), { name: 'QuotaExceededError' });
+ this.files.set(name, String(data));
+ },
+ close: async () => {},
+ abort: async () => {},
+ }),
+ };
+ }
+
+ async removeEntry(name: string) {
+ this.removed.push(name);
+ this.files.delete(name);
+ }
+}
+
+const item = (name: string, content: string | null = name) => ({ name, read: async () => content as unknown as BufferSource | null });
+const always = () => true;
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ delete (window as unknown as { showDirectoryPicker?: unknown }).showDirectoryPicker;
+});
+
+describe('saveFilesToFolder', () => {
+ it('writes each file and says how many', async () => {
+ const folder = new FakeFolder();
+ const result = await saveFilesToFolder(folder, [item('a.txt'), item('b.txt')], always);
+ expect(result).toEqual({ folder: 'Downloads', total: 2, saved: 2, skipped: 0, stopped: null });
+ expect(folder.files.get('a.txt')).toBe('a.txt');
+ });
+
+ it('gives same-named attachments their own files', async () => {
+ const folder = new FakeFolder();
+ await saveFilesToFolder(folder, [item('scan.pdf', 'one'), item('scan.pdf', 'two'), item('scan.pdf', 'three')], always);
+ expect([...folder.files.entries()]).toEqual([['scan.pdf', 'one'], ['scan (1).pdf', 'two'], ['scan (2).pdf', 'three']]);
+ });
+
+ it('never overwrites a file already in the folder', async () => {
+ const folder = new FakeFolder();
+ folder.files.set('report.pdf', 'precious');
+ await saveFilesToFolder(folder, [item('report.pdf', 'new')], always);
+ expect(folder.files.get('report.pdf')).toBe('precious');
+ expect(folder.files.get('report (1).pdf')).toBe('new');
+ });
+
+ it('stops at a failed write, keeps what is saved, and leaves no empty file', async () => {
+ const folder = new FakeFolder();
+ folder.failWriteOf = 'b.txt';
+ const result = await saveFilesToFolder(folder, [item('a.txt'), item('b.txt'), item('c.txt')], always);
+ expect(result).toMatchObject({ saved: 1, total: 3, stopped: 'write' });
+ expect([...folder.files.keys()]).toEqual(['a.txt']);
+ expect(folder.removed).toEqual(['b.txt']);
+ });
+
+ it('skips a file that cannot be fetched and goes on, as the zip does', async () => {
+ const folder = new FakeFolder();
+ const broken = { name: 'gone.bin', read: async () => { throw new Error('404'); } };
+ const result = await saveFilesToFolder(folder, [item('a.txt'), broken, item('c.txt')], always);
+ expect(result).toMatchObject({ saved: 2, skipped: 1, stopped: null });
+ });
+
+ it('asks the policy before every file, and a policy turning on stops the rest', async () => {
+ const folder = new FakeFolder();
+ let asked = 0;
+ // On for the first file's two checks, then off.
+ const allowed = () => ++asked <= 2;
+ const read = vi.fn(async () => 'x' as unknown as BufferSource);
+ const result = await saveFilesToFolder(folder, [{ name: 'a', read }, { name: 'b', read }], allowed);
+ expect(result).toMatchObject({ saved: 1, stopped: 'policy' });
+ expect(read).toHaveBeenCalledTimes(1);
+ expect([...folder.files.keys()]).toEqual(['a']);
+ });
+
+ it('writes nothing fetched while the policy turned on', async () => {
+ const folder = new FakeFolder();
+ let on = true;
+ const read = vi.fn(async () => { on = false; return 'x' as unknown as BufferSource; });
+ const result = await saveFilesToFolder(folder, [{ name: 'a', read }], () => on);
+ expect(result).toMatchObject({ saved: 0, stopped: 'policy' });
+ expect(folder.files.size).toBe(0);
+ });
+
+ it('keeps a sender\'s path out of the file name', async () => {
+ const folder = new FakeFolder();
+ await saveFilesToFolder(folder, [item('../../evil.exe'), item('C:\\Windows\\win.ini'), item('a:b?.txt'), item('...')], always);
+ expect([...folder.files.keys()]).toEqual(['evil.exe', 'win.ini', 'a_b_.txt', 'attachment']);
+ });
+});
+
+describe('names a file system or the shell keeps for itself', () => {
+ it('renames them, so the attachment is still saved', async () => {
+ const folder = new FakeFolder();
+ const names = ['CON', 'NUL.txt', 'com1.tar.gz', 'desktop.ini', 'x.lnk', 'x.url', 'x.scf', 'X.LNK'];
+ const result = await saveFilesToFolder(folder, names.map((name) => item(name)), always);
+ expect(result).toMatchObject({ saved: names.length, skipped: 0, stopped: null });
+ expect([...folder.files.keys()]).toEqual(['_CON', '_NUL.txt', '_com1.tar.gz', '_desktop.ini', 'x.lnk.txt', 'x.url.txt', 'x.scf.txt', 'X.LNK.txt']);
+ });
+
+ it('leaves names that only look like them', async () => {
+ const folder = new FakeFolder();
+ await saveFilesToFolder(folder, ['context.txt', 'console.log', 'nullable.ts', 'lnk.pdf'].map((name) => item(name)), always);
+ expect([...folder.files.keys()]).toEqual(['context.txt', 'console.log', 'nullable.ts', 'lnk.pdf']);
+ });
+
+ it('shortens a name over the limit, in bytes, keeping the extension and whole characters', async () => {
+ const folder = new FakeFolder();
+ const long = `${'ü'.repeat(200)}.pdf`;
+ const result = await saveFilesToFolder(folder, [item(long), item(long)], always);
+ expect(result.saved).toBe(2);
+ const [first, second] = [...folder.files.keys()];
+ expect(first.endsWith('.pdf')).toBe(true);
+ expect(first).not.toContain('\uFFFD');
+ expect(new TextEncoder().encode(first).length).toBeLessThanOrEqual(255);
+ expect(new TextEncoder().encode(second).length).toBeLessThanOrEqual(255);
+ expect(second.endsWith(' (1).pdf')).toBe(true);
+ });
+
+ it('skips one name the folder still refuses and goes on to the rest', async () => {
+ const folder = new FakeFolder();
+ folder.refused.add('odd.bin');
+ const result = await saveFilesToFolder(folder, [item('a.txt'), item('odd.bin'), item('c.txt')], always);
+ expect(result).toMatchObject({ saved: 2, skipped: 1, stopped: null });
+ expect([...folder.files.keys()]).toEqual(['a.txt', 'c.txt']);
+ });
+});
+
+describe('the helpers', () => {
+ it('sees the picker only where the browser has it', () => {
+ expect(canPickFolder()).toBe(false);
+ (window as unknown as { showDirectoryPicker: unknown }).showDirectoryPicker = () => {};
+ expect(canPickFolder()).toBe(true);
+ });
+
+ it('tells a closed picker from a failure', () => {
+ expect(isPickerCancelled(Object.assign(new Error('x'), { name: 'AbortError' }))).toBe(true);
+ expect(isPickerCancelled(Object.assign(new Error('x'), { name: 'SecurityError' }))).toBe(false);
+ expect(isPickerCancelled(null)).toBe(false);
+ });
+
+ it('numbers zip entries like before', () => {
+ const name = createUniqueNamer();
+ expect(['a.txt', 'a.txt', 'a.txt', '', ''].map(name)).toEqual(['a.txt', 'a (1).txt', 'a (2).txt', 'attachment', 'attachment (1)']);
+ });
+});
diff --git a/lib/__tests__/sw-offline-shell.test.ts b/lib/__tests__/sw-offline-shell.test.ts
index 094fd3e5e..a032751e4 100644
--- a/lib/__tests__/sw-offline-shell.test.ts
+++ b/lib/__tests__/sw-offline-shell.test.ts
@@ -65,6 +65,45 @@ describe('service worker with offline mode on', () => {
expect(await deep!.text()).toBe('mail page');
});
+ it('keeps pop-out pages, and starts a message it never saw from the page kept for it', async () => {
+ const caches = new FakeCaches();
+ await shellOn(caches, true);
+ let online = true;
+ const fetchMock = vi.fn(async (url: string) => {
+ if (!online) throw new TypeError('Failed to fetch');
+ return sameOrigin(`${new URL(typeof url === 'string' ? url : (url as Request).url).pathname}`);
+ });
+ const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: fetchMock as never });
+ await settle();
+ for (const path of ['/popout/compose', '/popout/message/_', '/']) {
+ const page = await worker.request({ url: `${ORIGIN}${path}`, mode: 'navigate' });
+ expect(page, path).not.toBeNull();
+ }
+ await settle();
+
+ online = false;
+ const message = await worker.request({ url: `${ORIGIN}/popout/message/never-seen`, mode: 'navigate' });
+ expect(await message!.text()).toBe('/popout/message/_');
+ const compose = await worker.request({ url: `${ORIGIN}/popout/compose?handoff=x`, mode: 'navigate' });
+ expect(await compose!.text()).toBe('/popout/compose');
+ });
+
+ it('never puts the whole app into a pop-out\'s window', async () => {
+ const caches = new FakeCaches();
+ await shellOn(caches, true);
+ let online = true;
+ const fetchMock = vi.fn(async () => {
+ if (!online) throw new TypeError('Failed to fetch');
+ return sameOrigin('the whole app');
+ });
+ const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: fetchMock });
+ await settle();
+ await worker.request({ url: `${ORIGIN}/`, mode: 'navigate' });
+ await settle();
+ online = false;
+ await expect(worker.request({ url: `${ORIGIN}/popout/compose`, mode: 'navigate' })).rejects.toThrow('Failed to fetch');
+ });
+
it('serves a chunk of an older build from that build\'s cache, and keeps new ones per build', async () => {
const caches = new FakeCaches();
await shellOn(caches, true);
diff --git a/lib/__tests__/token-lock.test.ts b/lib/__tests__/token-lock.test.ts
new file mode 100644
index 000000000..b3ff36106
--- /dev/null
+++ b/lib/__tests__/token-lock.test.ts
@@ -0,0 +1,122 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { tokenLockName, withTokenLock } from '@/lib/auth/token-lock';
+
+/** Web Locks with one holder at a time and waiters in order, like the browser's. */
+function fakeLocks() {
+ const queues = new Map>();
+ const held = new Set();
+ const names: string[] = [];
+ return {
+ names,
+ request(name: string, options: { signal?: AbortSignal; ifAvailable?: boolean }, callback: (lock: unknown) => unknown) {
+ names.push(name);
+ if (options.ifAvailable && held.has(name)) return Promise.resolve(callback(null));
+ const previous = queues.get(name) ?? Promise.resolve();
+ const run = new Promise((resolve, reject) => {
+ let aborted = false;
+ options.signal?.addEventListener('abort', () => {
+ aborted = true;
+ reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
+ });
+ void previous.catch(() => {}).then(() => {
+ if (aborted) return;
+ held.add(name);
+ Promise.resolve(callback({})).then(resolve, reject).finally(() => held.delete(name));
+ });
+ });
+ queues.set(name, run.catch(() => {}));
+ return run;
+ },
+ };
+}
+
+function stubLocks(locks: unknown) {
+ Object.defineProperty(navigator, 'locks', { value: locks, configurable: true });
+}
+
+afterEach(() => {
+ stubLocks(undefined);
+ vi.restoreAllMocks();
+});
+
+describe('withTokenLock', () => {
+ it('runs one renewal per slot at a time, so the second sends the token the first wrote', async () => {
+ stubLocks(fakeLocks());
+ const order: string[] = [];
+ let releaseFirst = () => {};
+ const first = withTokenLock(1, () => new Promise((resolve) => {
+ order.push('first starts');
+ releaseFirst = () => {
+ order.push('first ends');
+ resolve('a');
+ };
+ }));
+ const second = withTokenLock(1, async () => {
+ order.push('second starts');
+ return 'b';
+ });
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ expect(order).toEqual(['first starts']);
+ releaseFirst();
+ await expect(Promise.all([first, second])).resolves.toEqual(['a', 'b']);
+ expect(order).toEqual(['first starts', 'first ends', 'second starts']);
+ });
+
+ it('keeps slots apart: each login has its own refresh token', async () => {
+ const locks = fakeLocks();
+ stubLocks(locks);
+ await withTokenLock(0, async () => 'x');
+ await withTokenLock(2, async () => 'y');
+ expect(new Set(locks.names)).toEqual(new Set([tokenLockName(0), tokenLockName(2)]));
+ expect(tokenLockName(0)).not.toBe(tokenLockName(2));
+ });
+
+ it('runs at once without Web Locks', async () => {
+ await expect(withTokenLock(1, async () => 'done')).resolves.toBe('done');
+ });
+
+ it('goes ahead when another window holds the lock too long, rather than never renewing', async () => {
+ stubLocks(fakeLocks());
+ void withTokenLock(1, () => new Promise(() => {}));
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ await expect(withTokenLock(1, async () => 'went ahead', { waitMs: 10 })).resolves.toBe('went ahead');
+ });
+
+ it('says when it had to wait for another window, and when it did not', async () => {
+ stubLocks(fakeLocks());
+ const turns: boolean[] = [];
+ let release = () => {};
+ const first = withTokenLock(1, ({ contended }) => {
+ turns.push(contended);
+ return new Promise((resolve) => { release = resolve; });
+ });
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ const second = withTokenLock(1, async ({ contended }) => { turns.push(contended); });
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ release();
+ await Promise.all([first, second]);
+ expect(turns).toEqual([false, true]);
+ await withTokenLock(1, async ({ contended }) => { turns.push(contended); });
+ expect(turns).toEqual([false, true, false]);
+ });
+
+ it('does nothing when the caller calls it off during the wait: a sign-out', async () => {
+ stubLocks(fakeLocks());
+ void withTokenLock(1, () => new Promise(() => {}));
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ const signOut = new AbortController();
+ const work = vi.fn(async () => 'renewed');
+ const queued = withTokenLock(1, work, { signal: signOut.signal });
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ signOut.abort();
+ await expect(queued).rejects.toThrow();
+ expect(work).not.toHaveBeenCalled();
+ });
+
+ it('hands the work\'s own error to the caller and does not run it twice', async () => {
+ stubLocks(fakeLocks());
+ const work = vi.fn(async () => { throw new TypeError('Failed to fetch'); });
+ await expect(withTokenLock(1, work)).rejects.toThrow('Failed to fetch');
+ expect(work).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/lib/__tests__/window-title-bar.test.tsx b/lib/__tests__/window-title-bar.test.tsx
new file mode 100644
index 000000000..b0d4bb11b
--- /dev/null
+++ b/lib/__tests__/window-title-bar.test.tsx
@@ -0,0 +1,130 @@
+import { act, cleanup, render } from '@testing-library/react';
+import { readdirSync, readFileSync, statSync } from 'node:fs';
+import { join } from 'node:path';
+import { afterEach, describe, expect, it } from 'vitest';
+import { WindowTitleBar } from '@/components/layout/window-title-bar';
+import { TITLEBAR_INSET_VAR, readTitlebarArea } from '@/lib/window-controls-overlay';
+
+class FakeOverlay extends EventTarget {
+ visible = true;
+ rect = { x: 0, y: 0, width: 867, height: 40 };
+ getTitlebarAreaRect() {
+ return this.rect;
+ }
+ change(next: Partial) {
+ Object.assign(this, next);
+ this.dispatchEvent(new Event('geometrychange'));
+ }
+}
+
+function installOverlay(overlay: FakeOverlay | undefined) {
+ Object.defineProperty(navigator, 'windowControlsOverlay', { value: overlay, configurable: true });
+}
+
+const inset = () => document.documentElement.style.getPropertyValue(TITLEBAR_INSET_VAR);
+const strip = () => document.querySelector('[data-window-title-bar]');
+
+afterEach(() => {
+ cleanup();
+ installOverlay(undefined);
+ document.documentElement.style.removeProperty(TITLEBAR_INSET_VAR);
+});
+
+describe('the title bar', () => {
+ it('shows nothing and touches nothing without the API', () => {
+ installOverlay(undefined);
+ expect(readTitlebarArea()).toBeNull();
+ render( );
+ expect(strip()).toBeNull();
+ expect(inset()).toBe('');
+ });
+
+ it('shows nothing when the overlay is there but hidden', () => {
+ const overlay = new FakeOverlay();
+ overlay.visible = false;
+ installOverlay(overlay);
+ render( );
+ expect(strip()).toBeNull();
+ expect(inset()).toBe('');
+ });
+
+ it('draws the strip next to the controls and pushes the page down', () => {
+ installOverlay(new FakeOverlay());
+ document.title = 'Inbox - Mail';
+ render( );
+ const bar = strip()!;
+ expect(bar.style.width).toBe('867px');
+ expect(bar.style.height).toBe('40px');
+ expect(bar.textContent).toBe('Inbox - Mail');
+ expect(bar.classList.contains('window-title-bar')).toBe(true);
+ expect(inset()).toBe('40px');
+ });
+
+ it('stays out of the accessibility tree and the tab order', () => {
+ installOverlay(new FakeOverlay());
+ render( );
+ expect(strip()!.getAttribute('aria-hidden')).toBe('true');
+ expect(strip()!.querySelector('a, button, input, [tabindex]')).toBeNull();
+ });
+
+ it('follows geometrychange, and takes everything back when the overlay goes', () => {
+ const overlay = new FakeOverlay();
+ installOverlay(overlay);
+ render( );
+ act(() => overlay.change({ rect: { x: 72, y: 0, width: 500, height: 32 } }));
+ expect(strip()!.style.left).toBe('72px');
+ expect(inset()).toBe('32px');
+ act(() => overlay.change({ visible: false }));
+ expect(strip()).toBeNull();
+ expect(inset()).toBe('');
+ });
+
+ it('follows the window title as the surface changes it', async () => {
+ installOverlay(new FakeOverlay());
+ document.title = 'First';
+ render( );
+ await act(async () => {
+ document.title = 'Second';
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ expect(strip()!.textContent).toBe('Second');
+ });
+});
+
+describe('the page roots and the stylesheet', () => {
+ const root = process.cwd();
+ const css = readFileSync(join(root, 'app/globals.css'), 'utf8');
+
+ it('is 0 without an overlay, and exactly the old safe area then', () => {
+ expect(css).toContain('--titlebar-inset: env(titlebar-area-height, 0px);');
+ expect(css).toContain('--app-top-inset: max(env(safe-area-inset-top), var(--titlebar-inset));');
+ });
+
+ it('does not key on the display-mode media query, which is false in Electron', () => {
+ expect(css).not.toMatch(/display-mode:\s*window-controls-overlay/);
+ });
+
+ it('makes the strip the part that drags the window', () => {
+ expect(css).toMatch(/\.window-title-bar\s*\{[^}]*app-region:\s*drag/);
+ });
+
+ function tsxFiles(dir: string): string[] {
+ return readdirSync(dir).flatMap((name) => {
+ const path = join(dir, name);
+ if (name === '__tests__' || name === 'node_modules') return [];
+ return statSync(path).isDirectory() ? tsxFiles(path) : name.endsWith('.tsx') ? [path] : [];
+ });
+ }
+
+ it('leaves no page root or top bar on the bare safe area, skeletons included', () => {
+ const left = [...tsxFiles(join(root, 'components')), ...tsxFiles(join(root, 'app'))]
+ .filter((path) => /pt-\[(?:calc\([^\]]*)?env\(safe-area-inset-top/.test(readFileSync(path, 'utf8')));
+ expect(left).toEqual([]);
+ });
+
+ it('gives each skeleton the same top inset as the page it stands in for', () => {
+ for (const file of ['components/boot-skeleton/shell.tsx', 'components/boot-skeleton/pro-skeleton.tsx', 'components/boot-skeleton/admin-skeleton.tsx', 'components/mail/mail-app.tsx']) {
+ expect(readFileSync(join(root, file), 'utf8'), file).toContain('pt-[var(--app-top-inset)]');
+ }
+ });
+});
diff --git a/lib/attachments/save-to-folder.ts b/lib/attachments/save-to-folder.ts
new file mode 100644
index 000000000..2c075b06c
--- /dev/null
+++ b/lib/attachments/save-to-folder.ts
@@ -0,0 +1,215 @@
+/**
+ * "Save all attachments to a folder", with the File System Access API.
+ *
+ * These writes don't pass saveBlobAs (lib/download-guard.ts), the backstop of
+ * the device policies, so the caller's `allowed` is asked before each file:
+ * a policy that turns on while the files are written stops the rest.
+ *
+ * The folder handle is an interface, not the DOM type, so the desktop bridge
+ * or a test can supply its own.
+ */
+
+export interface WritableFile {
+ write(data: Blob | BufferSource): Promise;
+ close(): Promise;
+ abort?(): Promise;
+}
+
+export interface FolderFile {
+ createWritable(): Promise;
+}
+
+export interface FolderHandle {
+ name: string;
+ getFileHandle(name: string, options?: { create?: boolean }): Promise;
+ removeEntry?(name: string): Promise;
+}
+
+type DirectoryPicker = (options?: { mode?: 'read' | 'readwrite'; id?: string; startIn?: string }) => Promise;
+
+/** Whether this browser can ask for a folder. Without it the zip download is all there is. */
+export function canPickFolder(): boolean {
+ return typeof window !== 'undefined' && typeof (window as unknown as { showDirectoryPicker?: unknown }).showDirectoryPicker === 'function';
+}
+
+/**
+ * Asks where to save. Call it straight from the click, before any await: the
+ * browser only shows the picker for a user action. Rejects with an AbortError
+ * when the user closes the picker (see isPickerCancelled).
+ */
+export function pickFolder(): Promise {
+ const picker = (window as unknown as { showDirectoryPicker: DirectoryPicker }).showDirectoryPicker;
+ return picker.call(window, { mode: 'readwrite', id: 'bulwark-attachments', startIn: 'downloads' });
+}
+
+export function isPickerCancelled(error: unknown): boolean {
+ return (error as { name?: unknown } | null)?.name === 'AbortError';
+}
+
+/** The name's nth alternative: " (n)" before the extension, so duplicates stay recognisable. */
+function numberedName(base: string, n: number): string {
+ if (n === 0) return base;
+ const dot = base.lastIndexOf('.');
+ const stem = dot > 0 ? base.slice(0, dot) : base;
+ const ext = dot > 0 ? base.slice(dot) : '';
+ return `${stem} (${n})${ext}`;
+}
+
+/** Names unique within one batch, for the zip's entries. */
+export function createUniqueNamer(fallback = 'attachment'): (raw: string) => string {
+ const used = new Set();
+ return (raw) => {
+ const base = raw || fallback;
+ let n = 0;
+ while (used.has(numberedName(base, n))) n++;
+ const name = numberedName(base, n);
+ used.add(name);
+ return name;
+ };
+}
+
+/** Names Windows keeps for devices, with any extension: NUL.txt is NUL. */
+const RESERVED_STEM = /^(con|prn|aux|nul|clock\$|conin\$|conout\$|com[0-9¹²³]|lpt[0-9¹²³])$/i;
+/** Names and extensions the shell acts on, which browsers refuse to create. */
+const SHELL_NAMES = new Set(['desktop.ini', 'thumbs.db', 'autorun.inf']);
+const SHELL_EXTENSIONS = new Set(['.lnk', '.url', '.scf', '.local', '.pif', '.shb', '.cpl', '.inf']);
+/** A file system's limit is 255 bytes. Below it, leaving room for " (n)". */
+const MAX_NAME_BYTES = 240;
+
+function truncateName(name: string): string {
+ const encoder = new TextEncoder();
+ if (encoder.encode(name).length <= MAX_NAME_BYTES) return name;
+ const dot = name.lastIndexOf('.');
+ const ext = dot > 0 && encoder.encode(name.slice(dot)).length <= 32 ? name.slice(dot) : '';
+ let room = MAX_NAME_BYTES - encoder.encode(ext).length;
+ let stem = '';
+ // By code point, so a character is never cut in half.
+ for (const char of ext ? name.slice(0, dot) : name) {
+ room -= encoder.encode(char).length;
+ if (room < 0) break;
+ stem += char;
+ }
+ return stem.trimEnd() + ext;
+}
+
+/**
+ * A name the folder will take: no path parts a sender could smuggle in, no
+ * characters a file system refuses, and nothing Windows or the shell treats as
+ * special. Renamed, so the attachment is still saved.
+ */
+function safeFileName(raw: string, fallback: string): string {
+ const leaf = raw.split(/[\\/]/).pop() ?? '';
+ // eslint-disable-next-line no-control-regex
+ const cleaned = leaf.replace(/[<>:"|?*\x00-\x1f]/g, '_').replace(/[. ]+$/, '').trim();
+ if (!cleaned || cleaned === '.' || cleaned === '..') return fallback;
+ const lower = cleaned.toLowerCase();
+ const stem = lower.split('.')[0];
+ const dot = lower.lastIndexOf('.');
+ let safe = cleaned;
+ if (RESERVED_STEM.test(stem) || SHELL_NAMES.has(lower)) safe = `_${cleaned}`;
+ else if (dot > 0 && SHELL_EXTENSIONS.has(lower.slice(dot))) safe = `${cleaned}.txt`;
+ return truncateName(safe);
+}
+
+/** The folder or the browser refused this name, which says nothing about the next file. */
+function isNameRefused(error: unknown): boolean {
+ const kind = (error as { name?: unknown } | null)?.name;
+ return kind === 'TypeError' || kind === 'SecurityError';
+}
+
+async function existsInFolder(folder: FolderHandle, name: string): Promise {
+ try {
+ await folder.getFileHandle(name);
+ return true;
+ } catch (error) {
+ const kind = (error as { name?: unknown } | null)?.name;
+ if (kind === 'NotFoundError') return false;
+ // A sub-folder of that name: taken as well.
+ if (kind === 'TypeMismatchError') return true;
+ throw error;
+ }
+}
+
+/** The first of the name's alternatives neither this batch nor the folder has. */
+async function freeFileName(folder: FolderHandle, base: string, claimed: ReadonlySet): Promise {
+ let n = 0;
+ while (claimed.has(numberedName(base, n)) || await existsInFolder(folder, numberedName(base, n))) n++;
+ return numberedName(base, n);
+}
+
+export interface FolderSaveItem {
+ name: string;
+ /** The file's content, or null when it has none to save. Fetched only when its turn comes. */
+ read: () => Promise;
+}
+
+export interface FolderSaveResult {
+ folder: string;
+ total: number;
+ saved: number;
+ /** Files that couldn't be read from the server, or whose name was refused; the rest still went. */
+ skipped: number;
+ /** Why the run ended early, when it did. */
+ stopped: 'policy' | 'write' | null;
+}
+
+/**
+ * Writes the files one at a time. A file that can't be fetched or whose name is
+ * refused is skipped, like in the zip. A write that fails stops the run, because
+ * the next one would fail the same way (disk full, permission gone), and the
+ * result says how many made it. A file already in the folder is never overwritten.
+ */
+export async function saveFilesToFolder(
+ folder: FolderHandle,
+ items: readonly FolderSaveItem[],
+ allowed: () => boolean,
+): Promise {
+ const result: FolderSaveResult = { folder: folder.name, total: items.length, saved: 0, skipped: 0, stopped: null };
+ const claimed = new Set();
+
+ for (const item of items) {
+ if (!allowed()) {
+ result.stopped = 'policy';
+ return result;
+ }
+
+ let data: Blob | BufferSource | null;
+ try {
+ data = await item.read();
+ } catch {
+ data = null;
+ }
+ if (data === null) {
+ result.skipped += 1;
+ continue;
+ }
+ // The policy may have changed during the fetch, and nothing is on disk yet.
+ if (!allowed()) {
+ result.stopped = 'policy';
+ return result;
+ }
+
+ let name: string | null = null;
+ let writable: WritableFile | null = null;
+ try {
+ name = await freeFileName(folder, safeFileName(item.name, 'attachment'), claimed);
+ claimed.add(name);
+ const file = await folder.getFileHandle(name, { create: true });
+ writable = await file.createWritable();
+ await writable.write(data);
+ await writable.close();
+ result.saved += 1;
+ } catch (error) {
+ await writable?.abort?.().catch(() => {});
+ // getFileHandle created an empty file; don't leave it looking saved.
+ if (name) await folder.removeEntry?.(name).catch(() => {});
+ if (isNameRefused(error)) {
+ result.skipped += 1;
+ continue;
+ }
+ result.stopped = 'write';
+ return result;
+ }
+ }
+ return result;
+}
diff --git a/lib/auth/token-lock.ts b/lib/auth/token-lock.ts
new file mode 100644
index 000000000..6a4b6906e
--- /dev/null
+++ b/lib/auth/token-lock.ts
@@ -0,0 +1,78 @@
+/**
+ * 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(name: string, options: { signal?: AbortSignal; ifAvailable?: boolean }, callback: (lock: unknown) => T | Promise): Promise;
+}
+
+/** 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;
+
+const NOT_HELD = Symbol('not held');
+
+export interface TokenLockTurn {
+ /**
+ * Another window held the lock first, so it may have renewed already. A
+ * renewal that was going to force one should take the cached token instead:
+ * an identity provider can refuse a second one this soon (Rauthy `nbf`, #552).
+ */
+ contended: boolean;
+}
+
+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(
+ slot: number,
+ work: (turn: TokenLockTurn) => Promise,
+ options: { waitMs?: number; signal?: AbortSignal } = {},
+): Promise {
+ const locks = lockManager();
+ if (!locks) return work({ contended: false });
+
+ const name = tokenLockName(slot);
+ let started = false;
+ const run = (contended: boolean) => {
+ started = true;
+ return work({ contended });
+ };
+
+ const gaveUp = new AbortController();
+ const forwardAbort = () => gaveUp.abort();
+ options.signal?.addEventListener('abort', forwardAbort);
+ let timer: ReturnType | undefined;
+ try {
+ const first = await locks.request(name, { ifAvailable: true }, (lock) => (lock ? run(false) : NOT_HELD));
+ if (first !== NOT_HELD) return first as T;
+ timer = setTimeout(() => gaveUp.abort(), options.waitMs ?? TOKEN_LOCK_WAIT_MS);
+ return await locks.request(name, { signal: gaveUp.signal }, () => {
+ clearTimeout(timer);
+ return run(true);
+ });
+ } catch (error) {
+ // An error from the work goes to the caller, who decides what it means. So
+ // does a caller calling it off (a sign-out). Only the lock itself failing,
+ // or the wait running out, runs the work.
+ if (started || options.signal?.aborted) throw error;
+ return run(true);
+ } finally {
+ clearTimeout(timer);
+ options.signal?.removeEventListener('abort', forwardAbort);
+ }
+}
diff --git a/lib/desktop/boot.ts b/lib/desktop/boot.ts
new file mode 100644
index 000000000..01f68c7a6
--- /dev/null
+++ b/lib/desktop/boot.ts
@@ -0,0 +1,8 @@
+/**
+ * Runs in the inline boot script, before the first paint. The preload bridge
+ * exists before any page script, so app/desktop.css holds from the first
+ * frame. Nothing happens in a browser, which has no bridge.
+ */
+export function desktopBootScript(): string {
+ return `if (window.bulwarkDesktop) document.documentElement.setAttribute('data-desktop', '');`;
+}
diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts
new file mode 100644
index 000000000..a134358ca
--- /dev/null
+++ b/lib/desktop/types.ts
@@ -0,0 +1,26 @@
+/**
+ * The desktop app's preload bridge as the webmail sees it. Feature checks ask
+ * `window.bulwarkDesktop?. `; in a browser the object isn't there.
+ *
+ * Only what the webmail uses so far. The desktop shell fills it in.
+ */
+
+export interface DesktopWindowsBridge {
+ /**
+ * Opens a pop-out window itself. A `name` that is already open focuses that
+ * window and doesn't load `url` again: one window per message. Left out, the
+ * page uses window.open with a features string, which the shell's
+ * window-open handler sees too.
+ */
+ open?: (url: string, options: { name: string; width: number; height: number }) => void;
+}
+
+export interface BulwarkDesktopBridge {
+ windows?: DesktopWindowsBridge;
+}
+
+declare global {
+ interface Window {
+ bulwarkDesktop?: BulwarkDesktopBridge;
+ }
+}
diff --git a/lib/desktop/window-sync.ts b/lib/desktop/window-sync.ts
new file mode 100644
index 000000000..c89514680
--- /dev/null
+++ b/lib/desktop/window-sync.ts
@@ -0,0 +1,42 @@
+/**
+ * Keeps what the windows of the app share up to date without a reload: theme,
+ * language and settings. Each is a persisted store, so a change in one window
+ * reaches the others as a `storage` event. Read state and drafts go through
+ * lib/popout/channel.ts, and each window keeps its own account (see
+ * local-data/desktop-offline-decisions.md).
+ *
+ * Only in pop-outs and the desktop app: tabs of a browser keep today's
+ * behaviour, where a change shows at the next load.
+ */
+
+import { useLocaleStore } from '@/stores/locale-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { useThemeStore } from '@/stores/theme-store';
+import { hasDesktopWindows } from '@/lib/popout/support';
+import { isPopoutWindow } from '@/lib/popout/window-role';
+
+export function windowSyncWanted(): boolean {
+ return isPopoutWindow() || hasDesktopWindows();
+}
+
+/** Starts listening; returns the stop. Does nothing where windowSyncWanted says no. */
+export function startWindowSync(): () => void {
+ if (!windowSyncWanted()) return () => {};
+ const onStorage = (event: StorageEvent) => {
+ switch (event.key) {
+ case 'theme-storage':
+ // Rehydrating sets the state; this puts it on the page.
+ void Promise.resolve(useThemeStore.persist.rehydrate()).then(() => useThemeStore.getState().initializeTheme());
+ break;
+ case 'locale-storage':
+ // IntlProvider switches the catalog when the store changes.
+ void useLocaleStore.persist.rehydrate();
+ break;
+ case 'settings-storage':
+ void useSettingsStore.persist.rehydrate();
+ break;
+ }
+ };
+ window.addEventListener('storage', onStorage);
+ return () => window.removeEventListener('storage', onStorage);
+}
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index dad5d15e1..1327f0aec 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -1687,6 +1687,25 @@ export class JMAPClient implements IJMAPClient {
return client;
}
+ /**
+ * A client on a credential another window of this browser holds (a pop-out,
+ * lib/popout): the header as that window sends it, whatever kind it is.
+ */
+ static withSharedAuth(
+ serverUrl: string,
+ username: string,
+ authHeader: string,
+ onTokenRefresh?: () => Promise,
+ ): JMAPClient {
+ const client = new JMAPClient(serverUrl, username, '');
+ client.authHeader = authHeader;
+ if (authHeader.startsWith('Bearer ')) {
+ client.authMode = 'bearer';
+ client.onTokenRefresh = onTokenRefresh;
+ }
+ return client;
+ }
+
updateAccessToken(token: string): void {
this.authHeader = `Bearer ${token}`;
}
diff --git a/lib/offline/shell.ts b/lib/offline/shell.ts
index 295bdc325..fed8fa099 100644
--- a/lib/offline/shell.ts
+++ b/lib/offline/shell.ts
@@ -8,7 +8,7 @@ import { apiFetch, withBasePath } from '@/lib/browser-navigation';
import { currentBuildId, SW_SCOPE } from '@/lib/app-update';
/** The app's pages kept for a start without the network. */
-const SHELL_PAGES = ['/', '/mail', '/settings', '/protocol/mailto', '/protocol/webcal'];
+const SHELL_PAGES = ['/', '/mail', '/settings', '/protocol/mailto', '/protocol/webcal', '/popout/compose', '/popout/message/_'];
/**
* The files of this tab's build. After a deploy the server lists the new
diff --git a/lib/popout/channel.ts b/lib/popout/channel.ts
new file mode 100644
index 000000000..eb3c78134
--- /dev/null
+++ b/lib/popout/channel.ts
@@ -0,0 +1,272 @@
+/**
+ * What the windows of this browser say to each other about pop-outs. A
+ * BroadcastChannel, because a window opened by the desktop bridge has no
+ * opener, and a pop-out outlives a reload of the window that opened it.
+ *
+ * Every same-origin page hears this channel, and that includes privileged
+ * plugin frames (untrusted ones have an opaque origin and don't). So no
+ * credential goes on it in the clear: session answers are sealed for the
+ * window that asked (lib/popout/sealed.ts), and a refused credential is named
+ * by its fingerprint. Draft text does go on it, as it does for the plugins that
+ * see drafts anyway.
+ */
+
+import type { ComposerDraftData } from '@/components/email/email-composer';
+import type { MessagePriority } from '@/lib/message-priority';
+import type { ComposePopoutPayload } from './compose-payload';
+import { canSealSessions, createSessionOpener, credentialFingerprint, type SealedAnswer } from './sealed';
+
+export const POPOUT_CHANNEL = 'bulwark:popout-windows';
+
+/** One connected login, as the window that holds it sees it. */
+export interface SharedAccountSession {
+ accountId: string;
+ serverUrl: string;
+ username: string;
+ authMode: 'basic' | 'token' | 'oauth';
+ authHeader: string;
+ /** The window started offline: it has no credential to share (#523). */
+ offlineOnly?: boolean;
+}
+
+export interface SharedSessionAnswer {
+ activeAccountId: string | null;
+ sessions: SharedAccountSession[];
+}
+
+/** The send's outcome, for the main window to say and to offer Undo. The pop-out has closed by then. */
+export interface PopoutSentNotice {
+ accountId: string | null;
+ scheduled?: boolean;
+ queued?: boolean;
+ filingError?: boolean;
+ rejectedRecipients?: string;
+ /** stores/email-store.ts PendingUndoSend. */
+ pendingUndoSend?: {
+ submissionId: string;
+ emailId?: string;
+ identityId?: string;
+ sendAt: string;
+ isSmime: boolean;
+ localAccountId?: string;
+ submissionAccountId?: string;
+ } | null;
+}
+
+/**
+ * A draft whose window is closing, ready to save. A request started while a
+ * window closes doesn't survive, and saving takes several, so another window
+ * does it. The arguments of JMAPClient.createDraft, as plain data.
+ */
+export interface RescuedDraft {
+ /** The login to save through; absent, the window's active one. */
+ localAccountId?: string;
+ to: string[];
+ subject: string;
+ body: string;
+ cc: string[];
+ bcc: string[];
+ identityId?: string;
+ fromEmail?: string;
+ /** The version this one replaces. */
+ draftId?: string;
+ attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
+ fromName?: string;
+ htmlBody?: string;
+ inReplyTo?: string[];
+ references?: string[];
+ options?: { priority?: MessagePriority; previousDraftAccountId?: string; classification?: string | null };
+ /** The composer's state: where the text goes when the save fails. */
+ state: ComposerDraftData;
+ /** For a reload of that window to ask which draft id came of it. */
+ rescueKey?: string;
+}
+
+export type PopoutMessage =
+ | { type: 'session-request'; requestId: string; publicKey: JsonWebKey; accountId?: string; rejectedFingerprint?: string }
+ | { type: 'session-answer'; requestId: string; sealed: SealedAnswer }
+ | { type: 'handoff-request'; handoffId: string }
+ | { type: 'handoff-answer'; handoffId: string; payload: ComposePopoutPayload | null }
+ | { type: 'handoff-rendered'; handoffId: string }
+ | { type: 'sent'; requestId: string; notice: PopoutSentNotice }
+ | { type: 'sent-ack'; requestId: string }
+ | { type: 'mail-changed'; accountId: string | null }
+ | { type: 'state-change' }
+ | { type: 'draft-rescue'; draft: RescuedDraft }
+ | { type: 'rescue-query'; rescueKey: string }
+ | { type: 'rescue-answer'; rescueKey: string; draftId: string | null }
+ | { type: 'main-ping' }
+ | { type: 'main-pong'; windowId: string; accountIds: string[] }
+ | { type: 'main-gone'; windowId: string }
+ | { type: 'account-removed'; accountId: string };
+
+export interface PopoutChannel {
+ post(message: PopoutMessage): void;
+ close(): void;
+}
+
+/** Null without BroadcastChannel: pop-outs aren't offered then (lib/popout/support.ts). */
+export function openPopoutChannel(onMessage: (message: PopoutMessage) => void): PopoutChannel | null {
+ if (typeof BroadcastChannel === 'undefined') return null;
+ let channel: BroadcastChannel;
+ try {
+ channel = new BroadcastChannel(POPOUT_CHANNEL);
+ } catch {
+ return null;
+ }
+ channel.onmessage = (event: MessageEvent) => {
+ const message = event.data as PopoutMessage | null;
+ if (message && typeof message.type === 'string') onMessage(message);
+ };
+ return {
+ post(message) {
+ try {
+ channel.postMessage(message);
+ } catch {
+ // The channel is closed, or the page is going away.
+ }
+ },
+ close() {
+ channel.close();
+ },
+ };
+}
+
+function newId(): string {
+ return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
+ ? crypto.randomUUID()
+ : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
+}
+
+export { newId as newPopoutId };
+
+/** How long a pop-out waits for a main window before it goes on without one. */
+export const SESSION_ANSWER_MS = 3000;
+
+/**
+ * The session of a window that is signed in. With `accountId`, only a window
+ * holding that login answers, so the first answer is one that can be used.
+ * With `rejectedAuthHeader` the server just refused that credential: the main
+ * window renews it first when it still holds the same one. Null when no main
+ * window answers in time.
+ */
+export async function requestWindowSession(
+ options: { accountId?: string; rejectedAuthHeader?: string } = {},
+ timeoutMs: number = SESSION_ANSWER_MS,
+): Promise {
+ if (!canSealSessions()) return null;
+ const opener = await createSessionOpener();
+ const rejectedFingerprint = options.rejectedAuthHeader ? await credentialFingerprint(options.rejectedAuthHeader) : undefined;
+ return new Promise((resolve) => {
+ const requestId = newId();
+ let timer: ReturnType | null = null;
+ const finish = (answer: SharedSessionAnswer | null) => {
+ if (timer) clearTimeout(timer);
+ channel?.close();
+ resolve(answer);
+ };
+ const channel = openPopoutChannel((message) => {
+ if (message.type !== 'session-answer' || message.requestId !== requestId) return;
+ void opener.open(message.sealed).then(finish, () => finish(null));
+ });
+ if (!channel) {
+ resolve(null);
+ return;
+ }
+ timer = setTimeout(() => finish(null), timeoutMs);
+ channel.post({
+ type: 'session-request',
+ requestId,
+ publicKey: opener.publicKey,
+ ...(options.accountId ? { accountId: options.accountId } : {}),
+ ...(rejectedFingerprint ? { rejectedFingerprint } : {}),
+ });
+ });
+}
+
+/** Tells a main window what was sent. True when one took it over. */
+export function announceSentToMainWindow(notice: PopoutSentNotice, timeoutMs = 500): Promise {
+ return new Promise((resolve) => {
+ const requestId = newId();
+ let timer: ReturnType | null = null;
+ const channel = openPopoutChannel((message) => {
+ if (message.type === 'sent-ack' && message.requestId === requestId) finish(true);
+ });
+ const finish = (taken: boolean) => {
+ if (timer) clearTimeout(timer);
+ channel?.close();
+ resolve(taken);
+ };
+ if (!channel) {
+ resolve(false);
+ return;
+ }
+ timer = setTimeout(() => finish(false), timeoutMs);
+ channel.post({ type: 'sent', requestId, notice });
+ });
+}
+
+/** Tells the main windows that this pop-out changed mail they may be showing. */
+export function announceMailChanged(accountId: string | null): void {
+ const channel = openPopoutChannel(() => {});
+ channel?.post({ type: 'mail-changed', accountId });
+ channel?.close();
+}
+
+/**
+ * The draft id a closing window's text was saved under, for the same window
+ * after a reload, whose own copy still names the version that replaced. Null
+ * when no window saved anything for it.
+ */
+export function askRescuedDraftId(rescueKey: string, timeoutMs = 1500): Promise {
+ return new Promise((resolve) => {
+ let timer: ReturnType | null = null;
+ const channel = openPopoutChannel((message) => {
+ if (message.type === 'rescue-answer' && message.rescueKey === rescueKey) finish(message.draftId);
+ });
+ const finish = (draftId: string | null) => {
+ if (timer) clearTimeout(timer);
+ channel?.close();
+ resolve(draftId);
+ };
+ if (!channel) {
+ resolve(null);
+ return;
+ }
+ timer = setTimeout(() => finish(null), timeoutMs);
+ channel.post({ type: 'rescue-query', rescueKey });
+ });
+}
+
+/** How often a pop-out checks that a main window is there, and how long an answer counts. */
+const MAIN_PING_MS = 4000;
+const MAIN_ALIVE_MS = 10_000;
+
+/**
+ * Keeps track, synchronously, of which main windows are open and which logins
+ * they hold: a window closing has no time to ask. Also hands a closing draft to
+ * one that can save it.
+ */
+export function watchMainWindow(): { alive(accountId?: string): boolean; rescue(draft: RescuedDraft): boolean; stop(): void } {
+ const windows = new Map();
+ const channel = openPopoutChannel((message) => {
+ if (message.type === 'main-pong') windows.set(message.windowId, { at: Date.now(), accountIds: message.accountIds });
+ if (message.type === 'main-gone') windows.delete(message.windowId);
+ });
+ channel?.post({ type: 'main-ping' });
+ const timer = setInterval(() => channel?.post({ type: 'main-ping' }), MAIN_PING_MS);
+ const alive = (accountId?: string) => [...windows.values()].some((entry) =>
+ Date.now() - entry.at < MAIN_ALIVE_MS && (!accountId || entry.accountIds.includes(accountId)));
+ return {
+ alive,
+ rescue(draft) {
+ if (!channel || !alive(draft.localAccountId)) return false;
+ channel.post({ type: 'draft-rescue', draft });
+ return true;
+ },
+ stop() {
+ clearInterval(timer);
+ channel?.close();
+ },
+ };
+}
diff --git a/lib/popout/compose-handoff.ts b/lib/popout/compose-handoff.ts
new file mode 100644
index 000000000..aad0dee63
--- /dev/null
+++ b/lib/popout/compose-handoff.ts
@@ -0,0 +1,174 @@
+/**
+ * Moves a composer into a pop-out. The window has to open inside the click,
+ * but the draft is only ready once it is saved, so the opening window keeps
+ * a pending handoff that the pop-out asks for by id and waits on. The opening
+ * window lets go of its copy only once the pop-out says the composer is on
+ * screen, with the text in it.
+ */
+
+import { openPopoutChannel, newPopoutId, type PopoutChannel } from './channel';
+import type { ComposePopoutPayload } from './compose-payload';
+
+/** A handoff nobody asked for after this long is dropped. Above the time a save can take. */
+const PENDING_TTL_MS = 120_000;
+/** How long a pop-out waits for its draft. */
+export const HANDOFF_WAIT_MS = 60_000;
+/** How often a pop-out asks again: the request may cross a reload of the window that opened it. */
+const HANDOFF_RETRY_MS = 1000;
+/** How long the opening window waits for the composer to be on screen before it keeps the draft. */
+export const HANDOFF_RENDER_WAIT_MS = 30_000;
+
+const STORAGE_PREFIX = 'bulwark-popout-compose:';
+
+interface Pending {
+ requested: boolean;
+ /** Undefined until the opening window decided; null means it won't come. */
+ payload?: ComposePopoutPayload | null;
+ rendered?: (shown: boolean) => void;
+ renderTimer?: ReturnType;
+ timer: ReturnType;
+}
+
+const pending = new Map();
+let channel: PopoutChannel | null = null;
+
+function deliver(handoffId: string, entry: Pending): void {
+ if (!entry.requested || entry.payload === undefined) return;
+ channel?.post({ type: 'handoff-answer', handoffId, payload: entry.payload });
+ // Called off: nothing more to hear. Otherwise stay for "it is on screen".
+ if (entry.payload === null) forget(handoffId);
+}
+
+function forget(handoffId: string): void {
+ const entry = pending.get(handoffId);
+ if (entry) {
+ clearTimeout(entry.timer);
+ if (entry.renderTimer) clearTimeout(entry.renderTimer);
+ entry.rendered?.(false);
+ }
+ pending.delete(handoffId);
+ if (pending.size === 0) {
+ channel?.close();
+ channel = null;
+ }
+}
+
+export interface ComposeHandoff {
+ id: string;
+ /** Hands the draft over. True once the composer is on screen in the other window; false if that doesn't happen in time. */
+ complete(payload: ComposePopoutPayload, waitMs?: number): Promise;
+ cancel(): void;
+}
+
+/** Null without BroadcastChannel. */
+export function createComposeHandoff(): ComposeHandoff | null {
+ channel ??= openPopoutChannel((message) => {
+ if (message.type === 'handoff-request') {
+ const entry = pending.get(message.handoffId);
+ if (!entry) return;
+ entry.requested = true;
+ deliver(message.handoffId, entry);
+ } else if (message.type === 'handoff-rendered') {
+ const entry = pending.get(message.handoffId);
+ if (!entry) return;
+ entry.rendered?.(true);
+ entry.rendered = undefined;
+ forget(message.handoffId);
+ }
+ });
+ if (!channel) return null;
+
+ const id = newPopoutId();
+ pending.set(id, { requested: false, timer: setTimeout(() => forget(id), PENDING_TTL_MS) });
+ return {
+ id,
+ complete(payload, waitMs = HANDOFF_RENDER_WAIT_MS) {
+ const entry = pending.get(id);
+ if (!entry) return Promise.resolve(false);
+ return new Promise((resolve) => {
+ entry.rendered = resolve;
+ entry.renderTimer = setTimeout(() => {
+ entry.rendered = undefined;
+ resolve(false);
+ }, waitMs);
+ entry.payload = payload;
+ deliver(id, entry);
+ });
+ },
+ cancel() {
+ const entry = pending.get(id);
+ // Also when the draft was delivered already: the pop-out is still listening.
+ if (!entry) return;
+ entry.payload = null;
+ entry.requested = true;
+ deliver(id, entry);
+ },
+ };
+}
+
+/** Keeps the draft with this window, so a reload starts from it. */
+export function rememberComposePayload(handoffId: string, payload: ComposePopoutPayload): void {
+ try {
+ sessionStorage.setItem(STORAGE_PREFIX + handoffId, JSON.stringify(payload));
+ } catch {
+ // Full or unavailable: a reload then starts from the saved draft's first state.
+ }
+}
+
+/** The draft this window holds from before a reload. */
+export function rememberedComposePayload(handoffId: string): ComposePopoutPayload | null {
+ try {
+ const raw = sessionStorage.getItem(STORAGE_PREFIX + handoffId);
+ return raw ? (JSON.parse(raw) as ComposePopoutPayload) : null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * The pop-out's side. Null when the opening window called it off, or nothing
+ * came in time.
+ */
+export function receiveComposeHandoff(handoffId: string, timeoutMs: number = HANDOFF_WAIT_MS): Promise {
+ const remembered = rememberedComposePayload(handoffId);
+ if (remembered) return Promise.resolve(remembered);
+
+ return new Promise((resolve) => {
+ let retry: ReturnType | null = null;
+ let timer: ReturnType | null = null;
+ const finish = (payload: ComposePopoutPayload | null) => {
+ if (retry) clearInterval(retry);
+ if (timer) clearTimeout(timer);
+ listener?.close();
+ resolve(payload);
+ };
+ const listener = openPopoutChannel((message) => {
+ if (message.type !== 'handoff-answer' || message.handoffId !== handoffId) return;
+ if (message.payload) rememberComposePayload(handoffId, message.payload);
+ finish(message.payload);
+ });
+ if (!listener) {
+ resolve(null);
+ return;
+ }
+ const ask = () => listener.post({ type: 'handoff-request', handoffId });
+ timer = setTimeout(() => finish(null), timeoutMs);
+ retry = setInterval(ask, HANDOFF_RETRY_MS);
+ ask();
+ });
+}
+
+/** The composer with the text is on screen here: the opening window can let go. */
+export function acknowledgeComposeHandoff(handoffId: string): void {
+ const sender = openPopoutChannel(() => {});
+ sender?.post({ type: 'handoff-rendered', handoffId });
+ sender?.close();
+}
+
+/** Calls `onCancel` if the opening window takes the draft back. Returns the stop. */
+export function watchHandoffCancel(handoffId: string, onCancel: () => void): () => void {
+ const listener = openPopoutChannel((message) => {
+ if (message.type === 'handoff-answer' && message.handoffId === handoffId && message.payload === null) onCancel();
+ });
+ return () => listener?.close();
+}
diff --git a/lib/popout/compose-payload.ts b/lib/popout/compose-payload.ts
new file mode 100644
index 000000000..714bfc1ea
--- /dev/null
+++ b/lib/popout/compose-payload.ts
@@ -0,0 +1,23 @@
+/**
+ * What a composer needs to start in another window. Plain data: it crosses a
+ * BroadcastChannel and is kept in the pop-out's sessionStorage over a reload.
+ * Attachments are blob references, already on the server.
+ */
+
+import type { ComposerDraftData } from '@/components/email/email-composer';
+import type { ComposePrefill } from '@/lib/plugin-compose-gate';
+import type { EmailWriteSource } from '@/stores/email-store';
+import type { ProComposerMode, ProReplyContext } from '@/stores/pro-tab-store';
+
+export interface ComposePopoutPayload {
+ mode: ProComposerMode;
+ replyTo?: ProReplyContext;
+ initialDraftText?: string;
+ initialData?: ComposerDraftData | null;
+ prefill?: ComposePrefill | null;
+ /** The message replied to or forwarded, for its $answered / $forwarded flag. */
+ sourceEmailId?: string | null;
+ sourceEmailSource?: EmailWriteSource | null;
+ /** The mailbox a new message was started from; picks the identity (see the composer's prop). */
+ composeFromAccountEmail?: string;
+}
diff --git a/lib/popout/open.ts b/lib/popout/open.ts
new file mode 100644
index 000000000..e1d51ea37
--- /dev/null
+++ b/lib/popout/open.ts
@@ -0,0 +1,100 @@
+/**
+ * Opens pop-out windows: one message, or a composer. Shared with the desktop
+ * app's quick compose, which only has to call openComposerWindow.
+ */
+
+import { appPath } from '@/lib/deep-links';
+import '@/lib/desktop/types';
+import { createComposeHandoff, type ComposeHandoff } from './compose-handoff';
+import type { ComposePopoutPayload } from './compose-payload';
+
+const SIZES = {
+ message: { width: 960, height: 820 },
+ compose: { width: 760, height: 780 },
+} as const;
+
+type PopoutKind = keyof typeof SIZES;
+
+/** Offset from the opening window, so the new one doesn't hide it exactly. */
+const CASCADE_PX = 48;
+
+export function popoutFeatures(kind: PopoutKind): string {
+ const { width, height } = SIZES[kind];
+ const left = Math.round((window.screenX || 0) + CASCADE_PX);
+ const top = Math.round((window.screenY || 0) + CASCADE_PX);
+ return `popup=yes,width=${width},height=${height},left=${left},top=${top}`;
+}
+
+/** Windows this page opened. window.open with a name would load the address in it again. */
+const openedWindows = new Map();
+
+/** Whether a window opened. False when a pop-up blocker said no: the caller stays as it is. */
+function openWindow(url: string, name: string, kind: PopoutKind): Window | boolean {
+ const bridge = window.bulwarkDesktop?.windows;
+ if (bridge?.open) {
+ bridge.open(url, { name, ...SIZES[kind] });
+ return true;
+ }
+ const existing = openedWindows.get(name);
+ if (existing && !existing.closed) return existing;
+ // Not noopener: in an installed app that is what sends a window to the
+ // system browser (lib/standalone-display.ts).
+ const opened = window.open(url, name, popoutFeatures(kind));
+ if (!opened) return false;
+ openedWindows.set(name, opened);
+ return opened;
+}
+
+function focused(opened: Window | boolean): boolean {
+ if (opened && typeof opened !== 'boolean') opened.focus?.();
+ return !!opened;
+}
+
+function query(params: Record): string {
+ const search = new URLSearchParams();
+ for (const [key, value] of Object.entries(params)) if (value) search.set(key, value);
+ const text = search.toString();
+ return text ? `?${text}` : '';
+}
+
+export interface MessageWindowTarget {
+ emailId: string;
+ /** The login the message is read through. */
+ localAccountId?: string | null;
+ /** The JMAP account holding it, for a shared or group folder. */
+ ownerAccountId?: string | null;
+}
+
+export function messagePopoutUrl(target: MessageWindowTarget): string {
+ return appPath(`/popout/message/${encodeURIComponent(target.emailId)}`)
+ + query({ account: target.localAccountId, owner: target.ownerAccountId });
+}
+
+/** The same message twice focuses the same window, without loading it again. */
+export function openMessageWindow(target: MessageWindowTarget): boolean {
+ return focused(openWindow(messagePopoutUrl(target), `bulwark-message-${target.emailId.replace(/\W/g, '_')}`, 'message'));
+}
+
+export interface ComposerWindow {
+ /** Hands the draft over. True once the composer is on screen there; until then the sender keeps its copy. */
+ handOver(payload: ComposePopoutPayload): Promise;
+ /** Calls it off: the pop-out closes. */
+ cancel(): void;
+}
+
+/**
+ * Opens an empty pop-out composer that waits for its draft. Call it straight
+ * from the click: a browser only opens a window for a user action. Null when
+ * no window opened.
+ */
+export function openComposerWindow(localAccountId?: string | null): ComposerWindow | null {
+ const handoff: ComposeHandoff | null = createComposeHandoff();
+ if (!handoff) return null;
+ const url = appPath('/popout/compose') + query({ handoff: handoff.id, account: localAccountId });
+ const opened = openWindow(url, `bulwark-compose-${handoff.id}`, 'compose');
+ if (!focused(opened)) {
+ handoff.cancel();
+ return null;
+ }
+ return { handOver: handoff.complete, cancel: handoff.cancel };
+}
diff --git a/lib/popout/rescued-drafts.ts b/lib/popout/rescued-drafts.ts
new file mode 100644
index 000000000..98a5f7c4d
--- /dev/null
+++ b/lib/popout/rescued-drafts.ts
@@ -0,0 +1,55 @@
+/**
+ * Text from a closing pop-out that couldn't be saved. It waits here, kept over
+ * a reload, until the mail view can put it in its "Continue draft" slot.
+ */
+
+import type { ComposerDraftData } from '@/components/email/email-composer';
+
+const STORAGE_KEY = 'bulwark-rescued-drafts';
+
+const listeners = new Set<() => void>();
+
+function read(): ComposerDraftData[] {
+ try {
+ const raw = sessionStorage.getItem(STORAGE_KEY);
+ const list: unknown = raw ? JSON.parse(raw) : [];
+ return Array.isArray(list) ? (list as ComposerDraftData[]) : [];
+ } catch {
+ return [];
+ }
+}
+
+function write(list: ComposerDraftData[]): void {
+ try {
+ if (list.length === 0) sessionStorage.removeItem(STORAGE_KEY);
+ else sessionStorage.setItem(STORAGE_KEY, JSON.stringify(list));
+ } catch {
+ // Full or unavailable: it stays for as long as this page does, below.
+ }
+}
+
+let memory: ComposerDraftData[] | null = null;
+
+export function keepRescuedDraft(draft: ComposerDraftData): void {
+ memory = [...(memory ?? read()), { ...draft, unsaved: true }];
+ write(memory);
+ for (const listener of [...listeners]) listener();
+}
+
+/** The oldest waiting draft, taken off the list; null when there is none. */
+export function takeRescuedDraft(): ComposerDraftData | null {
+ const list = memory ?? read();
+ if (list.length === 0) return null;
+ const [first, ...rest] = list;
+ memory = rest;
+ write(rest);
+ return first;
+}
+
+/** Calls `listener` when a draft comes in. Returns the unsubscribe. */
+export function onRescuedDraft(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
diff --git a/lib/popout/sealed.ts b/lib/popout/sealed.ts
new file mode 100644
index 000000000..42fa55e5c
--- /dev/null
+++ b/lib/popout/sealed.ts
@@ -0,0 +1,75 @@
+/**
+ * Credentials between windows, sealed for the window that asked. The request
+ * carries a throwaway public key, and the answer can only be opened with the
+ * private key, which is not extractable and never leaves that window. So a
+ * listener on the channel, such as a privileged plugin frame, hears no
+ * credential go by.
+ *
+ * A BroadcastChannel has no way to hand over a MessagePort, and a window the
+ * desktop bridge opens has no opener to send one through.
+ */
+
+export function canSealSessions(): boolean {
+ return typeof crypto !== 'undefined' && !!crypto.subtle;
+}
+
+const ECDH = { name: 'ECDH', namedCurve: 'P-256' } as const;
+const AES = { name: 'AES-GCM', length: 256 } as const;
+
+function toBase64(bytes: ArrayBuffer | Uint8Array): string {
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
+ let binary = '';
+ for (let i = 0; i < view.length; i++) binary += String.fromCharCode(view[i]);
+ return btoa(binary);
+}
+
+function fromBase64(text: string): Uint8Array {
+ const binary = atob(text);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ return bytes;
+}
+
+export interface SealedAnswer {
+ publicKey: JsonWebKey;
+ iv: string;
+ data: string;
+}
+
+export interface SessionOpener {
+ publicKey: JsonWebKey;
+ open(sealed: SealedAnswer): Promise;
+}
+
+async function deriveKey(privateKey: CryptoKey, peer: JsonWebKey): Promise {
+ const publicKey = await crypto.subtle.importKey('jwk', peer, ECDH, false, []);
+ return crypto.subtle.deriveKey({ name: 'ECDH', public: publicKey }, privateKey, AES, false, ['encrypt', 'decrypt']);
+}
+
+/** The asking window's side: a key to send along, and what opens the answer. */
+export async function createSessionOpener(): Promise {
+ const pair = await crypto.subtle.generateKey(ECDH, false, ['deriveKey']);
+ const publicKey = await crypto.subtle.exportKey('jwk', pair.publicKey);
+ return {
+ publicKey,
+ async open(sealed: SealedAnswer): Promise {
+ const key = await deriveKey(pair.privateKey, sealed.publicKey);
+ const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: fromBase64(sealed.iv) }, key, fromBase64(sealed.data));
+ return JSON.parse(new TextDecoder().decode(plain)) as T;
+ },
+ };
+}
+
+/** The answering window's side. */
+export async function sealFor(requester: JsonWebKey, value: unknown): Promise {
+ const pair = await crypto.subtle.generateKey(ECDH, false, ['deriveKey']);
+ const key = await deriveKey(pair.privateKey, requester);
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const data = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(JSON.stringify(value)));
+ return { publicKey: await crypto.subtle.exportKey('jwk', pair.publicKey), iv: toBase64(iv), data: toBase64(data) };
+}
+
+/** Says which credential the server refused without saying what it is. */
+export async function credentialFingerprint(header: string): Promise {
+ return toBase64(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(header)));
+}
diff --git a/lib/popout/support.ts b/lib/popout/support.ts
new file mode 100644
index 000000000..f06f22aeb
--- /dev/null
+++ b/lib/popout/support.ts
@@ -0,0 +1,28 @@
+/**
+ * Whether "Open in new window" is on offer: an installed app or the desktop
+ * app, on a device with windows. In a browser tab, a tab does the same job
+ * and the app behaves as it always has.
+ */
+
+import { IS_LITE } from '@/lib/lite';
+import { isStandaloneDisplayMode } from '@/lib/standalone-display';
+import '@/lib/desktop/types';
+import { canSealSessions } from './sealed';
+
+export function hasDesktopWindows(): boolean {
+ return typeof window !== 'undefined' && !!window.bulwarkDesktop?.windows;
+}
+
+/** Display mode is `browser` in Electron popups too, hence the bridge check. */
+export function popoutsAvailable(): boolean {
+ // Lite has no such route: a static export can't list every message id.
+ if (IS_LITE || typeof window === 'undefined') return false;
+ // Sharing the session and renewing tokens safely across windows need these.
+ if (typeof BroadcastChannel === 'undefined' || !navigator.locks || !canSealSessions()) return false;
+ if (hasDesktopWindows()) return true;
+ if (!isStandaloneDisplayMode()) return false;
+ // A home-screen app on a phone or tablet has no second window: window.open
+ // leaves for the browser there.
+ if ((window.navigator as Navigator & { standalone?: boolean }).standalone === true) return false;
+ return typeof window.matchMedia === 'function' && window.matchMedia('(pointer: fine)').matches;
+}
diff --git a/lib/popout/window-role.ts b/lib/popout/window-role.ts
new file mode 100644
index 000000000..de20ee6cf
--- /dev/null
+++ b/lib/popout/window-role.ts
@@ -0,0 +1,68 @@
+/**
+ * Whether this window is a pop-out (`/[locale]/popout/...`): a single message
+ * or a composer in its own window, without the rail or the sidebar.
+ *
+ * A pop-out is a guest of the main window's session. It never evicts an
+ * account, never starts a token timer and doesn't write the account list:
+ * stores/auth-store.ts and stores/account-store.ts ask here.
+ */
+
+import { withBasePath } from '@/lib/browser-navigation';
+import { locales } from '@/i18n/routing';
+
+const BUILD_BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, '');
+
+/** Path segments that are pages of their own, so a files path like /files/popout/compose isn't taken for a pop-out. */
+const OTHER_SECTIONS = new Set(['mail', 'calendar', 'contacts', 'files', 'settings', 'apps', 'pro', 'admin', 'protocol', 'setup', 'connector', 'login', 'auth', 'plugins']);
+
+export function isPopoutPath(pathname: string): boolean {
+ // The mount comes off first: an app served under /mail isn't on its mail page.
+ const inApp = BUILD_BASE_PATH && (pathname === BUILD_BASE_PATH || pathname.startsWith(`${BUILD_BASE_PATH}/`))
+ ? pathname.slice(BUILD_BASE_PATH.length)
+ : pathname;
+ const segments = inApp.split('/').filter(Boolean);
+ const at = segments.indexOf('popout');
+ if (at < 0) return false;
+ // Before it: nothing, or a locale, with a mount only a reverse proxy knows ahead of that.
+ const before = segments.slice(0, at);
+ if (before.length > 0 && !(locales as readonly string[]).includes(before[before.length - 1])) return false;
+ if (before.some((segment) => OTHER_SECTIONS.has(segment))) return false;
+ const rest = segments.slice(at + 1);
+ return (rest.length === 1 && rest[0] === 'compose') || (rest.length === 2 && rest[0] === 'message');
+}
+
+export function isPopoutWindow(): boolean {
+ return typeof window !== 'undefined' && isPopoutPath(window.location.pathname);
+}
+
+/**
+ * localStorage for the stores a pop-out shares with the main window, without
+ * its writes: a pop-out on account B would otherwise switch the main window
+ * to B at its next reload, and write back a list older than the main
+ * window's. Throws on the server like a bare `localStorage` does, which
+ * createJSONStorage takes as "no storage".
+ */
+export function mainWindowStorage(): Pick {
+ if (typeof localStorage === 'undefined') throw new Error('No localStorage here');
+ return {
+ getItem: (key) => localStorage.getItem(key),
+ setItem: (key, value) => {
+ if (!isPopoutWindow()) localStorage.setItem(key, value);
+ },
+ removeItem: (key) => {
+ if (!isPopoutWindow()) localStorage.removeItem(key);
+ },
+ };
+}
+
+/**
+ * Closes the pop-out. A script may only close a window a script opened, so a
+ * pop-out address opened by hand goes to the app instead.
+ */
+export function closePopoutWindow(): void {
+ if (typeof window === 'undefined') return;
+ window.close();
+ window.setTimeout(() => {
+ if (!window.closed) window.location.replace(withBasePath('/'));
+ }, 300);
+}
diff --git a/lib/protocol-handlers/session.ts b/lib/protocol-handlers/session.ts
index da6941939..ba9d39365 100644
--- a/lib/protocol-handlers/session.ts
+++ b/lib/protocol-handlers/session.ts
@@ -1,3 +1,4 @@
+import { isStandaloneDisplayMode } from "@/lib/standalone-display";
import type { ParsedMailto } from "./mailto";
import type { ParsedWebcal } from "./webcal";
import { matchSurface } from "@/lib/deep-links";
@@ -136,9 +137,7 @@ function getMailtoClientPriority(info: ProtocolClientInfo): number {
}
function getDefaultProtocolClientInfo(): ProtocolClientInfo {
- const nav = navigator as Navigator & { standalone?: boolean };
- const standalone = window.matchMedia?.("(display-mode: standalone)").matches || nav.standalone === true;
- return { path: window.location.pathname, standalone, clientId: BROWSER_CLIENT_ID };
+ return { path: window.location.pathname, standalone: isStandaloneDisplayMode(), clientId: BROWSER_CLIENT_ID };
}
async function requestMailtoViaServiceWorker(value: ParsedMailto, timeoutMs: number): Promise {
diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts
index fbdd44507..af06ead2b 100644
--- a/lib/sign-out-cleanup.ts
+++ b/lib/sign-out-cleanup.ts
@@ -38,6 +38,7 @@ const SIGNED_OUT_STORAGE_KEYS = [
];
const SIGNED_OUT_SIGNAL = 'bulwark:signed-out';
+export { SIGNED_OUT_SIGNAL as SIGNED_OUT_KEY };
// Stores the auth store can't import (they import it) clear themselves here.
const signedOutCleanups = new Set<() => void>();
diff --git a/lib/window-controls-overlay.ts b/lib/window-controls-overlay.ts
new file mode 100644
index 000000000..481e2e2b1
--- /dev/null
+++ b/lib/window-controls-overlay.ts
@@ -0,0 +1,68 @@
+/**
+ * Window Controls Overlay: an installed app (and the desktop app's
+ * titleBarOverlay window) draws the page up to the top of the window and lays
+ * the native minimise, maximise and close buttons over it.
+ *
+ * Asks navigator.windowControlsOverlay, never the media query:
+ * `(display-mode: window-controls-overlay)` is false in Electron with
+ * titleBarOverlay, while the API and env(titlebar-area-*) work there.
+ *
+ * app/globals.css gives --titlebar-inset its first value from
+ * env(titlebar-area-height, 0px), so the page has the right top padding from
+ * the first paint, before any script runs. This module keeps the variable in
+ * step with the API afterwards, which is what a browser or bridge without the
+ * env() values relies on.
+ */
+
+export interface TitlebarArea {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+interface OverlayLike extends EventTarget {
+ visible: boolean;
+ getTitlebarAreaRect?: () => { x: number; y: number; width: number; height: number };
+}
+
+export const TITLEBAR_INSET_VAR = '--titlebar-inset';
+
+function overlay(): OverlayLike | null {
+ if (typeof navigator === 'undefined') return null;
+ const value = (navigator as Navigator & { windowControlsOverlay?: OverlayLike }).windowControlsOverlay;
+ return value ?? null;
+}
+
+/** The strip the page may draw in next to the window's controls, or null when there is no overlay. */
+export function readTitlebarArea(): TitlebarArea | null {
+ const current = overlay();
+ if (!current?.visible) return null;
+ const rect = current.getTitlebarAreaRect?.();
+ if (!rect || !(rect.height > 0)) return null;
+ return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
+}
+
+/**
+ * Sets --titlebar-inset for this area. With no area the inline value goes, so
+ * the stylesheet's value (0 without an overlay) is what counts again.
+ */
+export function applyTitlebarInset(area: TitlebarArea | null, root: HTMLElement = document.documentElement): void {
+ if (area) root.style.setProperty(TITLEBAR_INSET_VAR, `${area.height}px`);
+ else root.style.removeProperty(TITLEBAR_INSET_VAR);
+}
+
+/**
+ * Calls `listener` now and whenever the overlay's size or visibility changes
+ * (`geometrychange`). Without the API it calls it once with null and listens
+ * to nothing, so a browser without it runs no extra code. Returns the
+ * unsubscribe.
+ */
+export function watchTitlebarArea(listener: (area: TitlebarArea | null) => void): () => void {
+ listener(readTitlebarArea());
+ const current = overlay();
+ if (!current) return () => {};
+ const onChange = () => listener(readTitlebarArea());
+ current.addEventListener('geometrychange', onChange);
+ return () => current.removeEventListener('geometrychange', onChange);
+}
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 10057fa8c..afe8261d0 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "الخطأ الإملائي أو النحوي التالي",
"previous_issue": "الخطأ الإملائي أو النحوي السابق"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "فتح في نافذة جديدة",
+ "save_all_to_folder": "حفظ الكل في مجلد…",
+ "saved_all": "{count, plural, zero {لم يُحفظ أي مرفق في {folder}.} one {تم حفظ مرفق واحد في {folder}.} two {تم حفظ مرفقين في {folder}.} few {تم حفظ # مرفقات في {folder}.} many {تم حفظ # مرفقًا في {folder}.} other {تم حفظ # مرفق في {folder}.}}",
+ "saved_partial": "تم حفظ {saved} من أصل {total} مرفقات في {folder}.",
+ "save_failed": "تعذر حفظ المرفقات في {folder}.",
+ "popup_blocked": "حظر المتصفح النافذة الجديدة. اسمح بالنوافذ المنبثقة لهذا الموقع وحاول مرة أخرى.",
+ "opening": "جارٍ الفتح…",
+ "session_ended_title": "تم تسجيل خروجك في هذه النافذة",
+ "session_ended_body": "افتح التطبيق وسجّل الدخول، ثم افتح هذه النافذة مرة أخرى.",
+ "open_app": "فتح التطبيق",
+ "close_window": "إغلاق النافذة",
+ "message_gone_title": "لم تعد هذه الرسالة موجودة",
+ "message_gone_body": "تم حذفها أو نقلها إلى حساب آخر.",
+ "message_moved": "تم نقل هذه الرسالة إلى مجلد آخر.",
+ "message_moved_to": "تم نقل هذه الرسالة إلى {folder}.",
+ "window_not_ready": "لم تفتح النافذة الجديدة في الوقت المناسب. تبقى مسودتك هنا."
}
}
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 25c08a852..9feba035d 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "Error d'ortografia o de gramàtica següent",
"previous_issue": "Error d'ortografia o de gramàtica anterior"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "Obre en una finestra nova",
+ "save_all_to_folder": "Desa-ho tot en una carpeta…",
+ "saved_all": "{count, plural, one {S’ha desat # adjunt a {folder}.} other {S’han desat # adjunts a {folder}.}}",
+ "saved_partial": "S’han desat {saved} de {total} adjunts a {folder}.",
+ "save_failed": "No s’han pogut desar els adjunts a {folder}.",
+ "popup_blocked": "El navegador ha blocat la finestra nova. Permet les finestres emergents per a aquest lloc i torna-ho a provar.",
+ "opening": "S’està obrint…",
+ "session_ended_title": "Has tancat la sessió en aquesta finestra",
+ "session_ended_body": "Obre l’aplicació i inicia la sessió, i després torna a obrir aquesta finestra.",
+ "open_app": "Obre l’aplicació",
+ "close_window": "Tanca la finestra",
+ "message_gone_title": "Aquest missatge ja no hi és",
+ "message_gone_body": "S’ha suprimit o s’ha mogut a un altre compte.",
+ "message_moved": "Aquest missatge s’ha mogut a una altra carpeta.",
+ "message_moved_to": "Aquest missatge s’ha mogut a {folder}.",
+ "window_not_ready": "La finestra nova no s’ha obert a temps. L’esborrany es queda aquí."
}
}
diff --git a/locales/cs/common.json b/locales/cs/common.json
index 27c3add15..1410cb212 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "Další pravopisná nebo gramatická chyba",
"previous_issue": "Předchozí pravopisná nebo gramatická chyba"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "Otevřít v novém okně",
+ "save_all_to_folder": "Uložit vše do složky…",
+ "saved_all": "{count, plural, one {# příloha uložena do {folder}.} few {# přílohy uloženy do {folder}.} other {# příloh uloženo do {folder}.}}",
+ "saved_partial": "Uloženo {saved} z {total} příloh do {folder}.",
+ "save_failed": "Přílohy se nepodařilo uložit do {folder}.",
+ "popup_blocked": "Prohlížeč nové okno zablokoval. Povolte vyskakovací okna pro tento web a zkuste to znovu.",
+ "opening": "Otevírání…",
+ "session_ended_title": "V tomto okně jste odhlášeni",
+ "session_ended_body": "Otevřete aplikaci, přihlaste se a potom toto okno otevřete znovu.",
+ "open_app": "Otevřít aplikaci",
+ "close_window": "Zavřít okno",
+ "message_gone_title": "Tato zpráva už tu není",
+ "message_gone_body": "Byla smazána nebo přesunuta do jiného účtu.",
+ "message_moved": "Tato zpráva byla přesunuta do jiné složky.",
+ "message_moved_to": "Tato zpráva byla přesunuta do {folder}.",
+ "window_not_ready": "Nové okno se neotevřelo včas. Koncept zůstává zde."
}
}
diff --git a/locales/da/common.json b/locales/da/common.json
index 0c8a61745..1bc82eb46 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "Næste stave- eller grammatikfejl",
"previous_issue": "Forrige stave- eller grammatikfejl"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "Åbn i nyt vindue",
+ "save_all_to_folder": "Gem alle i en mappe…",
+ "saved_all": "{count, plural, one {# vedhæftet fil gemt i {folder}.} other {# vedhæftede filer gemt i {folder}.}}",
+ "saved_partial": "{saved} af {total} vedhæftede filer gemt i {folder}.",
+ "save_failed": "De vedhæftede filer kunne ikke gemmes i {folder}.",
+ "popup_blocked": "Browseren blokerede det nye vindue. Tillad pop op-vinduer for dette websted, og prøv igen.",
+ "opening": "Åbner…",
+ "session_ended_title": "Du er logget ud i dette vindue",
+ "session_ended_body": "Åbn appen og log ind, og åbn derefter dette vindue igen.",
+ "open_app": "Åbn appen",
+ "close_window": "Luk vindue",
+ "message_gone_title": "Denne besked er væk",
+ "message_gone_body": "Den er slettet eller flyttet til en anden konto.",
+ "message_moved": "Denne besked er flyttet til en anden mappe.",
+ "message_moved_to": "Denne besked er flyttet til {folder}.",
+ "window_not_ready": "Det nye vindue åbnede ikke i tide. Din kladde bliver her."
}
}
diff --git a/locales/de/common.json b/locales/de/common.json
index 96ecf68e1..33658a029 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "Nächster Rechtschreib- oder Grammatikfehler",
"previous_issue": "Vorheriger Rechtschreib- oder Grammatikfehler"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "In neuem Fenster öffnen",
+ "save_all_to_folder": "Alle in einem Ordner speichern…",
+ "saved_all": "{count, plural, one {# Anhang in {folder} gespeichert.} other {# Anhänge in {folder} gespeichert.}}",
+ "saved_partial": "{saved} von {total} Anhängen in {folder} gespeichert.",
+ "save_failed": "Die Anhänge konnten nicht in {folder} gespeichert werden.",
+ "popup_blocked": "Der Browser hat das neue Fenster blockiert. Erlauben Sie Pop-ups für diese Seite und versuchen Sie es noch einmal.",
+ "opening": "Wird geöffnet…",
+ "session_ended_title": "Sie sind in diesem Fenster abgemeldet",
+ "session_ended_body": "Öffnen Sie die App, melden Sie sich an und öffnen Sie dieses Fenster dann noch einmal.",
+ "open_app": "App öffnen",
+ "close_window": "Fenster schließen",
+ "message_gone_title": "Diese Nachricht ist nicht mehr da",
+ "message_gone_body": "Sie wurde gelöscht oder in ein anderes Konto verschoben.",
+ "message_moved": "Diese Nachricht wurde in einen anderen Ordner verschoben.",
+ "message_moved_to": "Diese Nachricht wurde nach {folder} verschoben.",
+ "window_not_ready": "Das neue Fenster wurde nicht rechtzeitig geöffnet. Ihr Entwurf bleibt hier."
}
}
diff --git a/locales/en/common.json b/locales/en/common.json
index cc1a552f1..2f85ebee1 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "Next spelling or grammar issue",
"previous_issue": "Previous spelling or grammar issue"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "Open in new window",
+ "save_all_to_folder": "Save all to a folder…",
+ "saved_all": "{count, plural, one {Saved # attachment to {folder}.} other {Saved # attachments to {folder}.}}",
+ "saved_partial": "Saved {saved} of {total} attachments to {folder}.",
+ "save_failed": "Could not save the attachments to {folder}.",
+ "popup_blocked": "The browser blocked the new window. Allow pop-ups for this site and try again.",
+ "opening": "Opening…",
+ "session_ended_title": "You are signed out in this window",
+ "session_ended_body": "Open the app and sign in, then open this window again.",
+ "open_app": "Open the app",
+ "close_window": "Close window",
+ "message_gone_title": "This message is gone",
+ "message_gone_body": "It was deleted or moved to another account.",
+ "message_moved": "This message was moved to another folder.",
+ "message_moved_to": "This message was moved to {folder}.",
+ "window_not_ready": "The new window did not open in time. Your draft stays here."
}
}
diff --git a/locales/es/common.json b/locales/es/common.json
index e699237b2..2fd90d3ae 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "Siguiente error de ortografía o gramática",
"previous_issue": "Anterior error de ortografía o gramática"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "Abrir en una ventana nueva",
+ "save_all_to_folder": "Guardar todo en una carpeta…",
+ "saved_all": "{count, plural, one {Se guardó # adjunto en {folder}.} other {Se guardaron # adjuntos en {folder}.}}",
+ "saved_partial": "Se guardaron {saved} de {total} adjuntos en {folder}.",
+ "save_failed": "No se pudieron guardar los adjuntos en {folder}.",
+ "popup_blocked": "El navegador bloqueó la ventana nueva. Permite las ventanas emergentes para este sitio e inténtalo de nuevo.",
+ "opening": "Abriendo…",
+ "session_ended_title": "Has cerrado sesión en esta ventana",
+ "session_ended_body": "Abre la aplicación e inicia sesión, y luego vuelve a abrir esta ventana.",
+ "open_app": "Abrir la aplicación",
+ "close_window": "Cerrar ventana",
+ "message_gone_title": "Este mensaje ya no está",
+ "message_gone_body": "Se eliminó o se movió a otra cuenta.",
+ "message_moved": "Este mensaje se movió a otra carpeta.",
+ "message_moved_to": "Este mensaje se movió a {folder}.",
+ "window_not_ready": "La ventana nueva no se abrió a tiempo. Tu borrador se queda aquí."
}
}
diff --git a/locales/et/common.json b/locales/et/common.json
index 4f4621696..9d4420c55 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "Järgmine õigekirja- või grammatikaviga",
"previous_issue": "Eelmine õigekirja- või grammatikaviga"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "Ava uues aknas",
+ "save_all_to_folder": "Salvesta kõik kausta…",
+ "saved_all": "{count, plural, one {# manus salvestati kausta {folder}.} other {# manust salvestati kausta {folder}.}}",
+ "saved_partial": "{total} manusest {saved} salvestati kausta {folder}.",
+ "save_failed": "Manuseid ei saanud kausta {folder} salvestada.",
+ "popup_blocked": "Brauser blokeeris uue akna. Luba selle saidi hüpikaknad ja proovi uuesti.",
+ "opening": "Avamine…",
+ "session_ended_title": "Oled selles aknas välja logitud",
+ "session_ended_body": "Ava rakendus ja logi sisse, seejärel ava see aken uuesti.",
+ "open_app": "Ava rakendus",
+ "close_window": "Sulge aken",
+ "message_gone_title": "Seda kirja enam ei ole",
+ "message_gone_body": "See kustutati või teisaldati teisele kontole.",
+ "message_moved": "See kiri teisaldati teise kausta.",
+ "message_moved_to": "See kiri teisaldati kausta {folder}.",
+ "window_not_ready": "Uus aken ei avanenud õigeks ajaks. Mustand jääb siia."
}
}
diff --git a/locales/fa/common.json b/locales/fa/common.json
index e0f845944..9d50a6570 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "خطای املایی یا دستوری بعدی",
"previous_issue": "خطای املایی یا دستوری قبلی"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "باز کردن در پنجرهٔ جدید",
+ "save_all_to_folder": "ذخیرهٔ همه در یک پوشه…",
+ "saved_all": "{count, plural, one {# پیوست در {folder} ذخیره شد.} other {# پیوست در {folder} ذخیره شد.}}",
+ "saved_partial": "{saved} از {total} پیوست در {folder} ذخیره شد.",
+ "save_failed": "پیوستها در {folder} ذخیره نشدند.",
+ "popup_blocked": "مرورگر پنجرهٔ جدید را مسدود کرد. پنجرههای بازشو را برای این سایت مجاز کنید و دوباره امتحان کنید.",
+ "opening": "در حال باز کردن…",
+ "session_ended_title": "در این پنجره از حساب خارج شدهاید",
+ "session_ended_body": "برنامه را باز کنید و وارد شوید، سپس این پنجره را دوباره باز کنید.",
+ "open_app": "باز کردن برنامه",
+ "close_window": "بستن پنجره",
+ "message_gone_title": "این پیام دیگر وجود ندارد",
+ "message_gone_body": "حذف شده یا به حساب دیگری منتقل شده است.",
+ "message_moved": "این پیام به پوشهٔ دیگری منتقل شد.",
+ "message_moved_to": "این پیام به {folder} منتقل شد.",
+ "window_not_ready": "پنجرهٔ جدید بهموقع باز نشد. پیشنویس شما همینجا میماند."
}
}
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 477a37158..61303cd3b 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "Faute d'orthographe ou de grammaire suivante",
"previous_issue": "Faute d'orthographe ou de grammaire précédente"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "Ouvrir dans une nouvelle fenêtre",
+ "save_all_to_folder": "Tout enregistrer dans un dossier…",
+ "saved_all": "{count, plural, one {# pièce jointe enregistrée dans {folder}.} other {# pièces jointes enregistrées dans {folder}.}}",
+ "saved_partial": "{saved} pièces jointes sur {total} enregistrées dans {folder}.",
+ "save_failed": "Impossible d’enregistrer les pièces jointes dans {folder}.",
+ "popup_blocked": "Le navigateur a bloqué la nouvelle fenêtre. Autorisez les fenêtres pop-up pour ce site et réessayez.",
+ "opening": "Ouverture…",
+ "session_ended_title": "Vous êtes déconnecté dans cette fenêtre",
+ "session_ended_body": "Ouvrez l’application et connectez-vous, puis rouvrez cette fenêtre.",
+ "open_app": "Ouvrir l’application",
+ "close_window": "Fermer la fenêtre",
+ "message_gone_title": "Ce message n’existe plus",
+ "message_gone_body": "Il a été supprimé ou déplacé vers un autre compte.",
+ "message_moved": "Ce message a été déplacé vers un autre dossier.",
+ "message_moved_to": "Ce message a été déplacé vers {folder}.",
+ "window_not_ready": "La nouvelle fenêtre ne s’est pas ouverte à temps. Votre brouillon reste ici."
}
}
diff --git a/locales/he/common.json b/locales/he/common.json
index 810238f1c..a9c364ce4 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -6483,5 +6483,23 @@
"next_issue": "שגיאת האיות או הדקדוק הבאה",
"previous_issue": "שגיאת האיות או הדקדוק הקודמת"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "פתיחה בחלון חדש",
+ "save_all_to_folder": "שמירת הכול בתיקייה…",
+ "saved_all": "{count, plural, one {קובץ מצורף אחד נשמר ב-{folder}.} two {# קבצים מצורפים נשמרו ב-{folder}.} other {# קבצים מצורפים נשמרו ב-{folder}.}}",
+ "saved_partial": "{saved} מתוך {total} קבצים מצורפים נשמרו ב-{folder}.",
+ "save_failed": "לא ניתן לשמור את הקבצים המצורפים ב-{folder}.",
+ "popup_blocked": "הדפדפן חסם את החלון החדש. יש לאפשר חלונות קופצים לאתר הזה ולנסות שוב.",
+ "opening": "בפתיחה…",
+ "session_ended_title": "התנתקת בחלון הזה",
+ "session_ended_body": "יש לפתוח את האפליקציה ולהתחבר, ואז לפתוח את החלון הזה שוב.",
+ "open_app": "פתיחת האפליקציה",
+ "close_window": "סגירת החלון",
+ "message_gone_title": "ההודעה הזו כבר לא קיימת",
+ "message_gone_body": "היא נמחקה או הועברה לחשבון אחר.",
+ "message_moved": "ההודעה הזו הועברה לתיקייה אחרת.",
+ "message_moved_to": "ההודעה הזו הועברה אל {folder}.",
+ "window_not_ready": "החלון החדש לא נפתח בזמן. הטיוטה נשארת כאן."
}
}
diff --git a/locales/hu/common.json b/locales/hu/common.json
index b973a8b4f..af3cf17a4 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "Következő helyesírási vagy nyelvtani hiba",
"previous_issue": "Előző helyesírási vagy nyelvtani hiba"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "Megnyitás új ablakban",
+ "save_all_to_folder": "Összes mentése mappába…",
+ "saved_all": "{count, plural, one {# melléklet mentve ide: {folder}.} other {# melléklet mentve ide: {folder}.}}",
+ "saved_partial": "{total} mellékletből {saved} mentve ide: {folder}.",
+ "save_failed": "A mellékleteket nem sikerült ide menteni: {folder}.",
+ "popup_blocked": "A böngésző letiltotta az új ablakot. Engedélyezze az előugró ablakokat ennél a webhelynél, majd próbálja újra.",
+ "opening": "Megnyitás…",
+ "session_ended_title": "Ebben az ablakban ki van jelentkezve",
+ "session_ended_body": "Nyissa meg az alkalmazást, jelentkezzen be, majd nyissa meg újra ezt az ablakot.",
+ "open_app": "Alkalmazás megnyitása",
+ "close_window": "Ablak bezárása",
+ "message_gone_title": "Ez az üzenet már nincs meg",
+ "message_gone_body": "Törölték, vagy másik fiókba helyezték át.",
+ "message_moved": "Ezt az üzenetet másik mappába helyezték át.",
+ "message_moved_to": "Ezt az üzenetet ide helyezték át: {folder}.",
+ "window_not_ready": "Az új ablak nem nyílt meg időben. A piszkozat itt marad."
}
}
diff --git a/locales/it/common.json b/locales/it/common.json
index 236ae7ca8..b0ec7f41c 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "Errore di ortografia o grammatica successivo",
"previous_issue": "Errore di ortografia o grammatica precedente"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "Apri in una nuova finestra",
+ "save_all_to_folder": "Salva tutto in una cartella…",
+ "saved_all": "{count, plural, one {# allegato salvato in {folder}.} other {# allegati salvati in {folder}.}}",
+ "saved_partial": "{saved} allegati su {total} salvati in {folder}.",
+ "save_failed": "Impossibile salvare gli allegati in {folder}.",
+ "popup_blocked": "Il browser ha bloccato la nuova finestra. Consenti i pop-up per questo sito e riprova.",
+ "opening": "Apertura…",
+ "session_ended_title": "Hai effettuato la disconnessione in questa finestra",
+ "session_ended_body": "Apri l’app e accedi, poi riapri questa finestra.",
+ "open_app": "Apri l’app",
+ "close_window": "Chiudi finestra",
+ "message_gone_title": "Questo messaggio non c’è più",
+ "message_gone_body": "È stato eliminato o spostato in un altro account.",
+ "message_moved": "Questo messaggio è stato spostato in un’altra cartella.",
+ "message_moved_to": "Questo messaggio è stato spostato in {folder}.",
+ "window_not_ready": "La nuova finestra non si è aperta in tempo. La bozza rimane qui."
}
}
diff --git a/locales/ja/common.json b/locales/ja/common.json
index d87945979..4510421dc 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "次のスペルまたは文法の問題",
"previous_issue": "前のスペルまたは文法の問題"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "新しいウィンドウで開く",
+ "save_all_to_folder": "すべてをフォルダーに保存…",
+ "saved_all": "{count, plural, other {# 件の添付ファイルを {folder} に保存しました。}}",
+ "saved_partial": "{total} 件中 {saved} 件の添付ファイルを {folder} に保存しました。",
+ "save_failed": "添付ファイルを {folder} に保存できませんでした。",
+ "popup_blocked": "ブラウザーが新しいウィンドウをブロックしました。このサイトのポップアップを許可して、もう一度お試しください。",
+ "opening": "開いています…",
+ "session_ended_title": "このウィンドウではサインアウトしています",
+ "session_ended_body": "アプリを開いてサインインしてから、このウィンドウをもう一度開いてください。",
+ "open_app": "アプリを開く",
+ "close_window": "ウィンドウを閉じる",
+ "message_gone_title": "このメッセージはもうありません",
+ "message_gone_body": "削除されたか、別のアカウントに移動されました。",
+ "message_moved": "このメッセージは別のフォルダーに移動されました。",
+ "message_moved_to": "このメッセージは {folder} に移動されました。",
+ "window_not_ready": "新しいウィンドウが時間内に開きませんでした。下書きはここに残ります。"
}
}
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 74b87cd03..02308e15f 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "다음 맞춤법 또는 문법 문제",
"previous_issue": "이전 맞춤법 또는 문법 문제"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "새 창에서 열기",
+ "save_all_to_folder": "모두 폴더에 저장…",
+ "saved_all": "{count, plural, other {첨부 파일 #개를 {folder}에 저장했습니다.}}",
+ "saved_partial": "첨부 파일 {total}개 중 {saved}개를 {folder}에 저장했습니다.",
+ "save_failed": "첨부 파일을 {folder}에 저장할 수 없습니다.",
+ "popup_blocked": "브라우저가 새 창을 차단했습니다. 이 사이트의 팝업을 허용한 후 다시 시도하세요.",
+ "opening": "여는 중…",
+ "session_ended_title": "이 창에서 로그아웃되었습니다",
+ "session_ended_body": "앱을 열어 로그인한 다음 이 창을 다시 여세요.",
+ "open_app": "앱 열기",
+ "close_window": "창 닫기",
+ "message_gone_title": "이 메시지는 더 이상 없습니다",
+ "message_gone_body": "삭제되었거나 다른 계정으로 이동되었습니다.",
+ "message_moved": "이 메시지는 다른 폴더로 이동되었습니다.",
+ "message_moved_to": "이 메시지는 {folder}(으)로 이동되었습니다.",
+ "window_not_ready": "새 창이 제때 열리지 않았습니다. 임시 보관 메일은 여기에 남아 있습니다."
}
}
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 54d8e3839..68f0be120 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "Nākamā pareizrakstības vai gramatikas kļūda",
"previous_issue": "Iepriekšējā pareizrakstības vai gramatikas kļūda"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "Atvērt jaunā logā",
+ "save_all_to_folder": "Saglabāt visu mapē…",
+ "saved_all": "{count, plural, zero {# pielikumi saglabāti mapē {folder}.} one {# pielikums saglabāts mapē {folder}.} other {# pielikumi saglabāti mapē {folder}.}}",
+ "saved_partial": "Saglabāti {saved} no {total} pielikumiem mapē {folder}.",
+ "save_failed": "Pielikumus neizdevās saglabāt mapē {folder}.",
+ "popup_blocked": "Pārlūks bloķēja jauno logu. Atļaujiet uznirstošos logus šai vietnei un mēģiniet vēlreiz.",
+ "opening": "Notiek atvēršana…",
+ "session_ended_title": "Šajā logā esat izrakstījies",
+ "session_ended_body": "Atveriet lietotni un pierakstieties, pēc tam atveriet šo logu vēlreiz.",
+ "open_app": "Atvērt lietotni",
+ "close_window": "Aizvērt logu",
+ "message_gone_title": "Šī ziņojuma vairs nav",
+ "message_gone_body": "Tas tika dzēsts vai pārvietots uz citu kontu.",
+ "message_moved": "Šis ziņojums tika pārvietots uz citu mapi.",
+ "message_moved_to": "Šis ziņojums tika pārvietots uz {folder}.",
+ "window_not_ready": "Jaunais logs neatvērās laikus. Melnraksts paliek šeit."
}
}
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 144b20708..9fbfd663a 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "Дараагийн зөв бичих эсвэл хэл зүйн алдаа",
"previous_issue": "Өмнөх зөв бичих эсвэл хэл зүйн алдаа"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "Шинэ цонхонд нээх",
+ "save_all_to_folder": "Бүгдийг хавтсанд хадгалах…",
+ "saved_all": "{count, plural, one {# хавсралтыг {folder} хавтсанд хадгаллаа.} other {# хавсралтыг {folder} хавтсанд хадгаллаа.}}",
+ "saved_partial": "{total} хавсралтаас {saved}-г {folder} хавтсанд хадгаллаа.",
+ "save_failed": "Хавсралтуудыг {folder} хавтсанд хадгалж чадсангүй.",
+ "popup_blocked": "Хөтөч шинэ цонхыг хаасан байна. Энэ сайтад гарч ирэх цонхыг зөвшөөрөөд дахин оролдоно уу.",
+ "opening": "Нээж байна…",
+ "session_ended_title": "Та энэ цонхонд гарсан байна",
+ "session_ended_body": "Аппыг нээж нэвтэрсний дараа энэ цонхыг дахин нээнэ үү.",
+ "open_app": "Аппыг нээх",
+ "close_window": "Цонхыг хаах",
+ "message_gone_title": "Энэ мессеж байхгүй болсон",
+ "message_gone_body": "Устгагдсан эсвэл өөр бүртгэл рүү зөөгдсөн.",
+ "message_moved": "Энэ мессежийг өөр хавтас руу зөөсөн.",
+ "message_moved_to": "Энэ мессежийг {folder} руу зөөсөн.",
+ "window_not_ready": "Шинэ цонх цагтаа нээгдсэнгүй. Таны ноорог энд үлдэнэ."
}
}
diff --git a/locales/nb/common.json b/locales/nb/common.json
index c53df4001..34985ec53 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "Neste stave- eller grammatikkfeil",
"previous_issue": "Forrige stave- eller grammatikkfeil"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "Åpne i nytt vindu",
+ "save_all_to_folder": "Lagre alle i en mappe…",
+ "saved_all": "{count, plural, one {# vedlegg lagret i {folder}.} other {# vedlegg lagret i {folder}.}}",
+ "saved_partial": "{saved} av {total} vedlegg lagret i {folder}.",
+ "save_failed": "Kunne ikke lagre vedleggene i {folder}.",
+ "popup_blocked": "Nettleseren blokkerte det nye vinduet. Tillat popup-vinduer for dette nettstedet og prøv igjen.",
+ "opening": "Åpner…",
+ "session_ended_title": "Du er logget ut i dette vinduet",
+ "session_ended_body": "Åpne appen og logg inn, og åpne deretter dette vinduet på nytt.",
+ "open_app": "Åpne appen",
+ "close_window": "Lukk vindu",
+ "message_gone_title": "Denne meldingen er borte",
+ "message_gone_body": "Den ble slettet eller flyttet til en annen konto.",
+ "message_moved": "Denne meldingen ble flyttet til en annen mappe.",
+ "message_moved_to": "Denne meldingen ble flyttet til {folder}.",
+ "window_not_ready": "Det nye vinduet åpnet seg ikke i tide. Utkastet blir her."
}
}
diff --git a/locales/nl/common.json b/locales/nl/common.json
index eb4b3fdd0..24c7b2a66 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "Volgende spel- of grammaticafout",
"previous_issue": "Vorige spel- of grammaticafout"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "Openen in nieuw venster",
+ "save_all_to_folder": "Alles in een map opslaan…",
+ "saved_all": "{count, plural, one {# bijlage opgeslagen in {folder}.} other {# bijlagen opgeslagen in {folder}.}}",
+ "saved_partial": "{saved} van {total} bijlagen opgeslagen in {folder}.",
+ "save_failed": "De bijlagen konden niet in {folder} worden opgeslagen.",
+ "popup_blocked": "De browser heeft het nieuwe venster geblokkeerd. Sta pop-ups toe voor deze site en probeer het opnieuw.",
+ "opening": "Openen…",
+ "session_ended_title": "Je bent afgemeld in dit venster",
+ "session_ended_body": "Open de app en meld je aan, en open dit venster daarna opnieuw.",
+ "open_app": "App openen",
+ "close_window": "Venster sluiten",
+ "message_gone_title": "Dit bericht is er niet meer",
+ "message_gone_body": "Het is verwijderd of naar een ander account verplaatst.",
+ "message_moved": "Dit bericht is naar een andere map verplaatst.",
+ "message_moved_to": "Dit bericht is verplaatst naar {folder}.",
+ "window_not_ready": "Het nieuwe venster is niet op tijd geopend. Je concept blijft hier."
}
}
diff --git a/locales/pl/common.json b/locales/pl/common.json
index e83fb703c..7223b4ba1 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "Następny błąd pisowni lub gramatyki",
"previous_issue": "Poprzedni błąd pisowni lub gramatyki"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "Otwórz w nowym oknie",
+ "save_all_to_folder": "Zapisz wszystko w folderze…",
+ "saved_all": "{count, plural, one {Zapisano # załącznik w {folder}.} few {Zapisano # załączniki w {folder}.} other {Zapisano # załączników w {folder}.}}",
+ "saved_partial": "Zapisano {saved} z {total} załączników w {folder}.",
+ "save_failed": "Nie udało się zapisać załączników w {folder}.",
+ "popup_blocked": "Przeglądarka zablokowała nowe okno. Zezwól na wyskakujące okna dla tej strony i spróbuj ponownie.",
+ "opening": "Otwieranie…",
+ "session_ended_title": "Wylogowano w tym oknie",
+ "session_ended_body": "Otwórz aplikację i zaloguj się, a potem otwórz to okno ponownie.",
+ "open_app": "Otwórz aplikację",
+ "close_window": "Zamknij okno",
+ "message_gone_title": "Tej wiadomości już nie ma",
+ "message_gone_body": "Została usunięta lub przeniesiona na inne konto.",
+ "message_moved": "Ta wiadomość została przeniesiona do innego folderu.",
+ "message_moved_to": "Ta wiadomość została przeniesiona do {folder}.",
+ "window_not_ready": "Nowe okno nie otworzyło się na czas. Twój szkic zostaje tutaj."
}
}
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 4038fe9ec..13cca1148 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "Próximo erro de ortografia ou gramática",
"previous_issue": "Erro de ortografia ou gramática anterior"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "Abrir em nova janela",
+ "save_all_to_folder": "Guardar tudo numa pasta…",
+ "saved_all": "{count, plural, one {# anexo guardado em {folder}.} other {# anexos guardados em {folder}.}}",
+ "saved_partial": "{saved} de {total} anexos guardados em {folder}.",
+ "save_failed": "Não foi possível guardar os anexos em {folder}.",
+ "popup_blocked": "O navegador bloqueou a nova janela. Permita pop-ups para este site e tente novamente.",
+ "opening": "A abrir…",
+ "session_ended_title": "Terminou a sessão nesta janela",
+ "session_ended_body": "Abra a aplicação e inicie sessão, depois abra esta janela novamente.",
+ "open_app": "Abrir a aplicação",
+ "close_window": "Fechar janela",
+ "message_gone_title": "Esta mensagem já não existe",
+ "message_gone_body": "Foi eliminada ou movida para outra conta.",
+ "message_moved": "Esta mensagem foi movida para outra pasta.",
+ "message_moved_to": "Esta mensagem foi movida para {folder}.",
+ "window_not_ready": "A nova janela não abriu a tempo. O seu rascunho fica aqui."
}
}
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 19d82e690..3bf4a5af9 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "Următoarea greșeală de ortografie sau gramatică",
"previous_issue": "Greșeala de ortografie sau gramatică anterioară"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "Deschide într-o fereastră nouă",
+ "save_all_to_folder": "Salvează tot într-un dosar…",
+ "saved_all": "{count, plural, one {# atașament salvat în {folder}.} few {# atașamente salvate în {folder}.} other {# de atașamente salvate în {folder}.}}",
+ "saved_partial": "{saved} din {total} atașamente salvate în {folder}.",
+ "save_failed": "Atașamentele nu au putut fi salvate în {folder}.",
+ "popup_blocked": "Browserul a blocat fereastra nouă. Permite ferestrele pop-up pentru acest site și încearcă din nou.",
+ "opening": "Se deschide…",
+ "session_ended_title": "Ești deconectat în această fereastră",
+ "session_ended_body": "Deschide aplicația și autentifică-te, apoi deschide din nou această fereastră.",
+ "open_app": "Deschide aplicația",
+ "close_window": "Închide fereastra",
+ "message_gone_title": "Acest mesaj nu mai există",
+ "message_gone_body": "A fost șters sau mutat în alt cont.",
+ "message_moved": "Acest mesaj a fost mutat în alt dosar.",
+ "message_moved_to": "Acest mesaj a fost mutat în {folder}.",
+ "window_not_ready": "Fereastra nouă nu s-a deschis la timp. Ciorna rămâne aici."
}
}
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 78e90fe0e..9ded598e7 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "Следующая орфографическая или грамматическая ошибка",
"previous_issue": "Предыдущая орфографическая или грамматическая ошибка"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "Открыть в новом окне",
+ "save_all_to_folder": "Сохранить всё в папку…",
+ "saved_all": "{count, plural, one {# вложение сохранено в {folder}.} few {# вложения сохранено в {folder}.} many {# вложений сохранено в {folder}.} other {# вложения сохранено в {folder}.}}",
+ "saved_partial": "Сохранено {saved} из {total} вложений в {folder}.",
+ "save_failed": "Не удалось сохранить вложения в {folder}.",
+ "popup_blocked": "Браузер заблокировал новое окно. Разрешите всплывающие окна для этого сайта и повторите попытку.",
+ "opening": "Открытие…",
+ "session_ended_title": "В этом окне вы вышли из системы",
+ "session_ended_body": "Откройте приложение и войдите, затем откройте это окно снова.",
+ "open_app": "Открыть приложение",
+ "close_window": "Закрыть окно",
+ "message_gone_title": "Этого письма больше нет",
+ "message_gone_body": "Оно удалено или перемещено в другую учётную запись.",
+ "message_moved": "Это письмо перемещено в другую папку.",
+ "message_moved_to": "Это письмо перемещено в {folder}.",
+ "window_not_ready": "Новое окно не открылось вовремя. Черновик остаётся здесь."
}
}
diff --git a/locales/sk/common.json b/locales/sk/common.json
index ce7aeea67..9ae6276cd 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "Ďalšia pravopisná alebo gramatická chyba",
"previous_issue": "Predchádzajúca pravopisná alebo gramatická chyba"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "Otvoriť v novom okne",
+ "save_all_to_folder": "Uložiť všetko do priečinka…",
+ "saved_all": "{count, plural, one {# príloha uložená do {folder}.} few {# prílohy uložené do {folder}.} other {# príloh uložených do {folder}.}}",
+ "saved_partial": "Uložených {saved} z {total} príloh do {folder}.",
+ "save_failed": "Prílohy sa nepodarilo uložiť do {folder}.",
+ "popup_blocked": "Prehliadač zablokoval nové okno. Povoľte kontextové okná pre túto stránku a skúste to znova.",
+ "opening": "Otvára sa…",
+ "session_ended_title": "V tomto okne ste odhlásení",
+ "session_ended_body": "Otvorte aplikáciu, prihláste sa a potom toto okno otvorte znova.",
+ "open_app": "Otvoriť aplikáciu",
+ "close_window": "Zavrieť okno",
+ "message_gone_title": "Táto správa tu už nie je",
+ "message_gone_body": "Bola odstránená alebo presunutá do iného účtu.",
+ "message_moved": "Táto správa bola presunutá do iného priečinka.",
+ "message_moved_to": "Táto správa bola presunutá do {folder}.",
+ "window_not_ready": "Nové okno sa neotvorilo včas. Koncept zostáva tu."
}
}
diff --git a/locales/th/common.json b/locales/th/common.json
index c10b05dd9..90aae511b 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -6490,5 +6490,23 @@
"next_issue": "ปัญหาการสะกดหรือไวยากรณ์ถัดไป",
"previous_issue": "ปัญหาการสะกดหรือไวยากรณ์ก่อนหน้า"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "เปิดในหน้าต่างใหม่",
+ "save_all_to_folder": "บันทึกทั้งหมดลงในโฟลเดอร์…",
+ "saved_all": "{count, plural, other {บันทึกไฟล์แนบ # รายการลงใน {folder} แล้ว}}",
+ "saved_partial": "บันทึกไฟล์แนบ {saved} จาก {total} รายการลงใน {folder} แล้ว",
+ "save_failed": "ไม่สามารถบันทึกไฟล์แนบลงใน {folder} ได้",
+ "popup_blocked": "เบราว์เซอร์บล็อกหน้าต่างใหม่ โปรดอนุญาตป๊อปอัปสำหรับเว็บไซต์นี้แล้วลองอีกครั้ง",
+ "opening": "กำลังเปิด…",
+ "session_ended_title": "คุณออกจากระบบในหน้าต่างนี้แล้ว",
+ "session_ended_body": "เปิดแอปและลงชื่อเข้าใช้ จากนั้นเปิดหน้าต่างนี้อีกครั้ง",
+ "open_app": "เปิดแอป",
+ "close_window": "ปิดหน้าต่าง",
+ "message_gone_title": "ไม่มีข้อความนี้แล้ว",
+ "message_gone_body": "ข้อความถูกลบหรือย้ายไปยังบัญชีอื่น",
+ "message_moved": "ข้อความนี้ถูกย้ายไปยังโฟลเดอร์อื่น",
+ "message_moved_to": "ข้อความนี้ถูกย้ายไปยัง {folder}",
+ "window_not_ready": "หน้าต่างใหม่เปิดไม่ทัน แบบร่างของคุณยังอยู่ที่นี่"
}
}
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 239db782d..bfe1c884c 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "Sonraki yazım veya dil bilgisi hatası",
"previous_issue": "Önceki yazım veya dil bilgisi hatası"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "Yeni pencerede aç",
+ "save_all_to_folder": "Tümünü bir klasöre kaydet…",
+ "saved_all": "{count, plural, one {# ek {folder} klasörüne kaydedildi.} other {# ek {folder} klasörüne kaydedildi.}}",
+ "saved_partial": "{total} ekten {saved} tanesi {folder} klasörüne kaydedildi.",
+ "save_failed": "Ekler {folder} klasörüne kaydedilemedi.",
+ "popup_blocked": "Tarayıcı yeni pencereyi engelledi. Bu site için açılır pencerelere izin verip yeniden deneyin.",
+ "opening": "Açılıyor…",
+ "session_ended_title": "Bu pencerede oturumunuz kapalı",
+ "session_ended_body": "Uygulamayı açıp oturum açın, ardından bu pencereyi yeniden açın.",
+ "open_app": "Uygulamayı aç",
+ "close_window": "Pencereyi kapat",
+ "message_gone_title": "Bu ileti artık yok",
+ "message_gone_body": "Silindi veya başka bir hesaba taşındı.",
+ "message_moved": "Bu ileti başka bir klasöre taşındı.",
+ "message_moved_to": "Bu ileti {folder} klasörüne taşındı.",
+ "window_not_ready": "Yeni pencere zamanında açılmadı. Taslağınız burada kalıyor."
}
}
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 8e432c9d7..4f1ce8ea1 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "Наступна орфографічна або граматична помилка",
"previous_issue": "Попередня орфографічна або граматична помилка"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "Відкрити в новому вікні",
+ "save_all_to_folder": "Зберегти все в папку…",
+ "saved_all": "{count, plural, one {# вкладення збережено в {folder}.} few {# вкладення збережено в {folder}.} many {# вкладень збережено в {folder}.} other {# вкладення збережено в {folder}.}}",
+ "saved_partial": "Збережено {saved} із {total} вкладень у {folder}.",
+ "save_failed": "Не вдалося зберегти вкладення в {folder}.",
+ "popup_blocked": "Браузер заблокував нове вікно. Дозвольте спливаючі вікна для цього сайту і спробуйте ще раз.",
+ "opening": "Відкриття…",
+ "session_ended_title": "У цьому вікні ви вийшли з облікового запису",
+ "session_ended_body": "Відкрийте застосунок і увійдіть, а потім відкрийте це вікно знову.",
+ "open_app": "Відкрити застосунок",
+ "close_window": "Закрити вікно",
+ "message_gone_title": "Цього листа більше немає",
+ "message_gone_body": "Його видалено або переміщено в інший обліковий запис.",
+ "message_moved": "Цього листа переміщено в іншу папку.",
+ "message_moved_to": "Цього листа переміщено в {folder}.",
+ "window_not_ready": "Нове вікно не відкрилося вчасно. Чернетка залишається тут."
}
}
diff --git a/locales/vi/common.json b/locales/vi/common.json
index 7584d0ca9..0022c228b 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -6494,5 +6494,23 @@
"next_issue": "Lỗi chính tả hoặc ngữ pháp tiếp theo",
"previous_issue": "Lỗi chính tả hoặc ngữ pháp trước đó"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "Mở trong cửa sổ mới",
+ "save_all_to_folder": "Lưu tất cả vào thư mục…",
+ "saved_all": "{count, plural, other {Đã lưu # tệp đính kèm vào {folder}.}}",
+ "saved_partial": "Đã lưu {saved} trong {total} tệp đính kèm vào {folder}.",
+ "save_failed": "Không thể lưu tệp đính kèm vào {folder}.",
+ "popup_blocked": "Trình duyệt đã chặn cửa sổ mới. Hãy cho phép cửa sổ bật lên với trang này rồi thử lại.",
+ "opening": "Đang mở…",
+ "session_ended_title": "Bạn đã đăng xuất trong cửa sổ này",
+ "session_ended_body": "Hãy mở ứng dụng và đăng nhập, rồi mở lại cửa sổ này.",
+ "open_app": "Mở ứng dụng",
+ "close_window": "Đóng cửa sổ",
+ "message_gone_title": "Thư này không còn nữa",
+ "message_gone_body": "Thư đã bị xóa hoặc chuyển sang tài khoản khác.",
+ "message_moved": "Thư này đã được chuyển sang thư mục khác.",
+ "message_moved_to": "Thư này đã được chuyển đến {folder}.",
+ "window_not_ready": "Cửa sổ mới không mở kịp. Bản nháp của bạn vẫn ở đây."
}
}
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 723e0e907..1a52c4a4c 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "下一個拼字或文法問題",
"previous_issue": "上一個拼字或文法問題"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "在新視窗中開啟",
+ "save_all_to_folder": "全部儲存到資料夾…",
+ "saved_all": "{count, plural, other {已將 # 個附件儲存到 {folder}。}}",
+ "saved_partial": "已將 {total} 個附件中的 {saved} 個儲存到 {folder}。",
+ "save_failed": "無法將附件儲存到 {folder}。",
+ "popup_blocked": "瀏覽器封鎖了新視窗。請允許此網站顯示彈出式視窗,然後再試一次。",
+ "opening": "正在開啟…",
+ "session_ended_title": "你已在此視窗中登出",
+ "session_ended_body": "請開啟應用程式並登入,然後重新開啟此視窗。",
+ "open_app": "開啟應用程式",
+ "close_window": "關閉視窗",
+ "message_gone_title": "這封郵件已不存在",
+ "message_gone_body": "它已被刪除或移到其他帳戶。",
+ "message_moved": "這封郵件已移到其他資料夾。",
+ "message_moved_to": "這封郵件已移到 {folder}。",
+ "window_not_ready": "新視窗未能及時開啟。你的草稿仍保留在這裡。"
}
}
diff --git a/locales/zh/common.json b/locales/zh/common.json
index d7d011bc1..4d7cf20aa 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -6482,5 +6482,23 @@
"next_issue": "下一个拼写或语法问题",
"previous_issue": "上一个拼写或语法问题"
}
+ },
+ "pwaWindows": {
+ "open_in_window": "在新窗口中打开",
+ "save_all_to_folder": "全部保存到文件夹…",
+ "saved_all": "{count, plural, other {已将 # 个附件保存到 {folder}。}}",
+ "saved_partial": "已将 {total} 个附件中的 {saved} 个保存到 {folder}。",
+ "save_failed": "无法将附件保存到 {folder}。",
+ "popup_blocked": "浏览器阻止了新窗口。请允许此网站显示弹出窗口,然后重试。",
+ "opening": "正在打开…",
+ "session_ended_title": "你已在此窗口中退出登录",
+ "session_ended_body": "请打开应用并登录,然后重新打开此窗口。",
+ "open_app": "打开应用",
+ "close_window": "关闭窗口",
+ "message_gone_title": "这封邮件已不存在",
+ "message_gone_body": "它已被删除或移动到其他账户。",
+ "message_moved": "这封邮件已移动到其他文件夹。",
+ "message_moved_to": "这封邮件已移动到 {folder}。",
+ "window_not_ready": "新窗口未能及时打开。你的草稿仍保留在这里。"
}
}
diff --git a/public/sw.js b/public/sw.js
index 2d3d556aa..f46f5fee7 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -108,7 +108,7 @@ if (typeof caches !== "undefined" && caches && typeof caches.open === "function"
readOfflineShell();
}
-const APP_SECTIONS = new Set(["", "mail", "settings", "calendar", "contacts", "files", "pro", "apps", "protocol"]);
+const APP_SECTIONS = new Set(["", "mail", "settings", "calendar", "contacts", "files", "pro", "apps", "protocol", "popout"]);
/** The page path inside the app ("/mail/inbox"), or null for anything that isn't a page of the app. */
function appPagePath(url) {
@@ -138,7 +138,12 @@ function documentKey(url) {
async function cachedDocument(url) {
const path = appPagePath(url) || "/";
const section = "/" + (path.split("/").filter(Boolean)[0] || "");
- const candidates = [documentKey(url), self.location.origin + BASE_PATH + (section === "/" ? "" : section), self.location.origin + BASE_PATH];
+ const appRoot = self.location.origin + BASE_PATH;
+ // A pop-out has no section page, and the start page would put the whole app
+ // into its window. Its own kind of page reads the id from the address bar.
+ const candidates = section === "/popout"
+ ? [documentKey(url), appRoot + (path.startsWith("/popout/message/") ? "/popout/message/_" : "/popout/compose")]
+ : [documentKey(url), appRoot + (section === "/" ? "" : section), appRoot];
// Only looked into when it exists: opening it would create it again
// after the next build's worker deleted it.
if (await caches.has(SHELL_CACHE)) {
diff --git a/scripts/lite/lib.mjs b/scripts/lite/lib.mjs
index 18bdc6cdf..3760b208a 100644
--- a/scripts/lite/lib.mjs
+++ b/scripts/lite/lib.mjs
@@ -41,6 +41,7 @@ export const LITE_REMOVED_PATHS = [
"app/(main)/[...rest]",
"app/(main)/[locale]/[...rest]",
"app/(main)/protocol",
+ "app/(main)/[locale]/popout",
"e2e",
"integration",
// The local store: Lite has no offline mode. A dev checkout may hold these.
diff --git a/stores/__tests__/auth-store-shared-session.test.ts b/stores/__tests__/auth-store-shared-session.test.ts
new file mode 100644
index 000000000..c8bad00ec
--- /dev/null
+++ b/stores/__tests__/auth-store-shared-session.test.ts
@@ -0,0 +1,351 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel';
+
+// A pop-out starts on the main window's session. It must not evict an account
+// (a login without "remember me" has nothing to restore, which checkAuth
+// answers by removing it from the list every window shares), and must not
+// refresh on its own: without a cross-window lock a stale refresh can end in
+// `Replay` on Stalwart 1.0, which revokes the grant.
+
+// Going on without a main window waits out its answer, 3 s on a timer that a busy machine stretches.
+vi.setConfig({ testTimeout: 30_000 });
+
+const made: Array<{ header: string; refresh?: () => Promise }> = [];
+
+vi.mock('@/lib/jmap/client', () => {
+ class JMAPClient {
+ authHeader = '';
+ onTokenRefresh?: () => Promise;
+ constructor(public serverUrl: string, public username: string, public password: string) {
+ this.authHeader = `Basic ${password}`;
+ }
+ static withBearer(serverUrl: string, token: string, username: string, refresh?: () => Promise) {
+ const client = new JMAPClient(serverUrl, username, '');
+ client.authHeader = `Bearer ${token}`;
+ client.onTokenRefresh = refresh;
+ made.push({ header: client.authHeader, refresh });
+ return client;
+ }
+ static withSharedAuth(serverUrl: string, username: string, header: string, refresh?: () => Promise) {
+ const client = new JMAPClient(serverUrl, username, '');
+ client.authHeader = header;
+ client.onTokenRefresh = refresh;
+ made.push({ header, refresh });
+ return client;
+ }
+ async connect() {}
+ getSessionUsername() { return this.username; }
+ getUsername() { return this.username; }
+ async getIdentities() { return [{ id: 'i', email: this.username, name: this.username, mayDelete: false }]; }
+ getAuthHeader() { return this.authHeader; }
+ updateAccessToken(token: string) { this.authHeader = `Bearer ${token}`; }
+ onConnectionChange() {}
+ onRateLimit() {}
+ getRateLimitRemainingMs() { return 0; }
+ supportsContacts() { return false; }
+ supportsPrincipals() { return false; }
+ supportsVacationResponse() { return false; }
+ supportsCalendars() { return false; }
+ supportsSieve() { return false; }
+ disconnect() {}
+ getAccountId() { return 'acct'; }
+ isOfflineOnly() { return false; }
+ }
+ class RateLimitError extends Error {}
+ return { JMAPClient, RateLimitError };
+});
+vi.mock('@/lib/stalwart/principal', () => ({ fetchPrincipalDisplayName: async () => null }));
+
+import { openPopoutChannel, type PopoutChannel } from '@/lib/popout/channel';
+import { credentialFingerprint, sealFor } from '@/lib/popout/sealed';
+import { useAccountStore } from '@/stores/account-store';
+import { SHARED_SESSION_ENDED_EVENT, useAuthStore } from '@/stores/auth-store';
+import { SIGNED_OUT_KEY } from '@/lib/sign-out-cleanup';
+
+const account = (id: string, slot: number, over: Record = {}) => ({
+ id, label: id, serverUrl: 'https://mail.example.test', username: `${id}@example.test`, authMode: 'oauth' as const,
+ rememberMe: true, cookieSlot: slot, displayName: id, email: `${id}@example.test`, isConnected: false, hasError: false,
+ lastLoginAt: 1, avatarColor: '#000', isDefault: slot === 0, ...over,
+});
+
+let fetched: Array<{ url: string; method?: string }>;
+let tokenAnswers: Response[];
+let main: PopoutChannel | null;
+let requests: Array<{ accountId?: string; rejectedFingerprint?: string }>;
+
+function startMainWindow(answer: (request: { accountId?: string; rejectedFingerprint?: string }) => { activeAccountId: string; sessions: unknown[] } | null) {
+ main = openPopoutChannel((message) => {
+ if (message.type !== 'session-request') return;
+ requests.push({ accountId: message.accountId, rejectedFingerprint: message.rejectedFingerprint });
+ const result = answer(message);
+ if (result) void sealFor(message.publicKey, result).then((sealed) => main?.post({ type: 'session-answer', requestId: message.requestId, sealed }));
+ });
+}
+
+const session = (id: string, authHeader = 'Bearer main-token', authMode = 'oauth') => ({
+ accountId: id, serverUrl: 'https://mail.example.test', username: `${id}@example.test`, authMode, authHeader,
+});
+
+const tokenCalls = () => fetched.filter((call) => call.url.includes('/api/auth/token'));
+const sessionCalls = () => fetched.filter((call) => call.url.includes('/api/auth/session'));
+
+beforeEach(() => {
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'], shouldAdvanceTime: true, advanceTimeDelta: 50 });
+ FakeBroadcastChannel.reset();
+ vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel);
+ window.history.replaceState(null, '', '/en/popout/message/abc');
+ made.length = 0;
+ fetched = [];
+ tokenAnswers = [];
+ requests = [];
+ main = null;
+ useAccountStore.setState({ accounts: [account('alice', 0)], activeAccountId: 'alice', defaultAccountId: 'alice' });
+ useAuthStore.setState({ isAuthenticated: false, client: null, activeAccountId: null });
+ vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
+ fetched.push({ url: String(url), method: init?.method });
+ if (String(url).includes('/api/auth/token')) {
+ return tokenAnswers.shift() ?? new Response(JSON.stringify({ access_token: 'cookie-token', expires_in: 3600 }), { status: 200 });
+ }
+ return new Response('{}', { status: 200 });
+ }));
+});
+
+afterEach(() => {
+ main?.close();
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ window.history.replaceState(null, '', '/');
+ localStorage.clear();
+});
+
+describe('startSharedSession', () => {
+ it('uses the main window\'s session and asks no auth route for anything', async () => {
+ startMainWindow(() => ({ activeAccountId: 'alice', sessions: [session('alice')] }));
+ await expect(useAuthStore.getState().startSharedSession()).resolves.toBe('ready');
+ const state = useAuthStore.getState();
+ expect(state.isAuthenticated).toBe(true);
+ expect(state.activeAccountId).toBe('alice');
+ expect(state.client?.getAuthHeader()).toBe('Bearer main-token');
+ expect(tokenCalls()).toEqual([]);
+ expect(sessionCalls()).toEqual([]);
+ });
+
+ it('takes a login without "remember me" from the main window too', async () => {
+ useAccountStore.setState({ accounts: [account('alice', 0, { rememberMe: false, authMode: 'basic' })] });
+ startMainWindow(() => ({ activeAccountId: 'alice', sessions: [session('alice', 'Basic abc', 'basic')] }));
+ await expect(useAuthStore.getState().startSharedSession()).resolves.toBe('ready');
+ expect(useAuthStore.getState().client?.getAuthHeader()).toBe('Basic abc');
+ });
+
+ it('asks for the login it wants, so only a window holding it answers', async () => {
+ useAccountStore.setState({ accounts: [account('alice', 0), account('bob', 1)] });
+ startMainWindow(() => ({ activeAccountId: 'bob', sessions: [session('bob', 'Bearer bob-token')] }));
+ await useAuthStore.getState().startSharedSession({ accountId: 'bob' });
+ expect(requests[0].accountId).toBe('bob');
+ });
+
+ it('opens the login it was asked for, whatever the main window has active', async () => {
+ useAccountStore.setState({ accounts: [account('alice', 0), account('bob', 1)] });
+ startMainWindow(() => ({ activeAccountId: 'alice', sessions: [session('alice'), session('bob', 'Bearer bob-token')] }));
+ await useAuthStore.getState().startSharedSession({ accountId: 'bob' });
+ expect(useAuthStore.getState().activeAccountId).toBe('bob');
+ expect(useAuthStore.getState().client?.getAuthHeader()).toBe('Bearer bob-token');
+ });
+
+ it('never evicts: a login without "remember me" and no main window stays in the list', async () => {
+ useAccountStore.setState({ accounts: [account('alice', 0, { rememberMe: false, authMode: 'basic' })] });
+ await expect(useAuthStore.getState().startSharedSession()).resolves.toBe('no-session');
+ expect(useAccountStore.getState().accounts.map((a) => a.id)).toEqual(['alice']);
+ expect(sessionCalls().filter((call) => call.method === 'DELETE')).toEqual([]);
+ expect(tokenCalls().filter((call) => call.method === 'DELETE')).toEqual([]);
+ });
+
+ it('never evicts on a refused cookie either', async () => {
+ tokenAnswers.push(new Response('{}', { status: 401 }));
+ await expect(useAuthStore.getState().startSharedSession()).resolves.toBe('no-session');
+ expect(useAccountStore.getState().accounts.map((a) => a.id)).toEqual(['alice']);
+ });
+
+ it('restores from the cookies when no main window is open, without forcing a renewal', async () => {
+ await expect(useAuthStore.getState().startSharedSession()).resolves.toBe('ready');
+ expect(useAuthStore.getState().client?.getAuthHeader()).toBe('Bearer cookie-token');
+ const [call] = tokenCalls();
+ expect(call.url).not.toContain('force=true');
+ });
+
+ it('does not write the list or the active account that the main window owns', async () => {
+ useAccountStore.setState({ accounts: [account('alice', 0), account('bob', 1)], activeAccountId: 'alice' });
+ localStorage.setItem('account-registry', 'written by the main window');
+ localStorage.setItem('auth-storage', 'written by the main window');
+ startMainWindow(() => ({ activeAccountId: 'alice', sessions: [session('bob', 'Bearer bob-token')] }));
+ await useAuthStore.getState().startSharedSession({ accountId: 'bob' });
+ expect(localStorage.getItem('account-registry')).toBe('written by the main window');
+ expect(localStorage.getItem('auth-storage')).toBe('written by the main window');
+ });
+
+ it('starts no refresh timer', async () => {
+ startMainWindow(() => ({ activeAccountId: 'alice', sessions: [session('alice')] }));
+ await useAuthStore.getState().startSharedSession();
+ await vi.advanceTimersByTimeAsync(7_200_000);
+ expect(tokenCalls()).toEqual([]);
+ expect(requests).toHaveLength(1);
+ });
+});
+
+describe('a token the server refused in a pop-out', () => {
+ async function started() {
+ startMainWindow((request) => {
+ const header = request.rejectedFingerprint ? 'Bearer renewed-by-main' : 'Bearer main-token';
+ return { activeAccountId: 'alice', sessions: [session('alice', header)] };
+ });
+ await useAuthStore.getState().startSharedSession();
+ return made[made.length - 1].refresh!;
+ }
+
+ it('comes from the main window, which is told which one was refused', async () => {
+ const refresh = await started();
+ await expect(refresh()).resolves.toBe('renewed-by-main');
+ expect(requests[1]).toEqual({ accountId: 'alice', rejectedFingerprint: await credentialFingerprint('Bearer main-token') });
+ expect(tokenCalls()).toEqual([]);
+ });
+
+ it('gives up when the main window could not renew it, without signing anyone out', async () => {
+ startMainWindow(() => ({ activeAccountId: 'alice', sessions: [session('alice')] }));
+ await useAuthStore.getState().startSharedSession();
+ const refresh = made[made.length - 1].refresh!;
+ await expect(refresh()).resolves.toBeNull();
+ expect(tokenCalls()).toEqual([]);
+ expect(useAccountStore.getState().accounts).toHaveLength(1);
+ });
+
+ it('comes through the locked cookie path once no main window is left', async () => {
+ const refresh = await started();
+ main?.close();
+ main = null;
+ const locks = { request: vi.fn((_name: string, _options: object, callback: (lock: unknown) => Promise) => callback({})) };
+ Object.defineProperty(navigator, 'locks', { value: locks, configurable: true });
+ try {
+ await expect(refresh()).resolves.toBe('cookie-token');
+ } finally {
+ Object.defineProperty(navigator, 'locks', { value: undefined, configurable: true });
+ }
+ expect(locks.request.mock.calls[0][0]).toBe('bulwark-token-refresh:0');
+ expect(tokenCalls()).toHaveLength(1);
+ });
+
+ it('forces a renewal only when the cookie holds the refused token', async () => {
+ const refresh = await started();
+ main?.close();
+ main = null;
+ tokenAnswers.push(new Response(JSON.stringify({ access_token: 'main-token', expires_in: 3600 }), { status: 200 }));
+ await expect(refresh()).resolves.toBe('cookie-token');
+ expect(tokenCalls().map((call) => call.url.includes('force=true'))).toEqual([false, true]);
+ });
+
+ it('says so and signs nobody out when the cookie is refused too', async () => {
+ const refresh = await started();
+ main?.close();
+ main = null;
+ tokenAnswers.push(new Response('{}', { status: 401 }));
+ const ended = vi.fn();
+ window.addEventListener(SHARED_SESSION_ENDED_EVENT, ended);
+ await expect(refresh()).resolves.toBeNull();
+ window.removeEventListener(SHARED_SESSION_ENDED_EVENT, ended);
+ expect(ended).toHaveBeenCalledTimes(1);
+ expect(useAccountStore.getState().accounts).toHaveLength(1);
+ expect(useAuthStore.getState().isAuthenticated).toBe(true);
+ });
+});
+
+describe('renewals in a main window', () => {
+ it('take the same lock, so a main window and a pop-out never race', async () => {
+ window.history.replaceState(null, '', '/mail');
+ const locks = { request: vi.fn((_name: string, _options: object, callback: (lock: unknown) => Promise) => callback({})) };
+ Object.defineProperty(navigator, 'locks', { value: locks, configurable: true });
+ try {
+ await useAuthStore.getState().refreshAccessToken({ accountId: 'alice', allowCached: true });
+ } finally {
+ Object.defineProperty(navigator, 'locks', { value: undefined, configurable: true });
+ }
+ expect(locks.request.mock.calls.map((call) => call[0])).toContain('bulwark-token-refresh:0');
+ });
+
+ /** One window holds the lock until `release`; this one queues behind it. */
+ function contendedLocks() {
+ let release: () => void = () => {};
+ const held = new Promise((resolve) => { release = resolve; });
+ const locks = {
+ request: vi.fn((_name: string, options: { ifAvailable?: boolean }, callback: (lock: unknown) => Promise) =>
+ (options.ifAvailable ? Promise.resolve(callback(null)) : held.then(() => callback({})))),
+ };
+ Object.defineProperty(navigator, 'locks', { value: locks, configurable: true });
+ return { release: () => release() };
+ }
+ const clearLocks = () => Object.defineProperty(navigator, 'locks', { value: undefined, configurable: true });
+
+ beforeEach(() => {
+ window.history.replaceState(null, '', '/mail');
+ });
+ afterEach(clearLocks);
+
+ it('force a renewal when nobody else was at it', async () => {
+ const locks = { request: vi.fn((_name: string, _options: object, callback: (lock: unknown) => Promise) => callback({})) };
+ Object.defineProperty(navigator, 'locks', { value: locks, configurable: true });
+ await useAuthStore.getState().refreshAccessToken({ accountId: 'alice' });
+ expect(tokenCalls()[0].url).toContain('force=true');
+ });
+
+ it('take the cached token after waiting for another window: a second renewal this soon can be refused', async () => {
+ const { release } = contendedLocks();
+ const renewal = useAuthStore.getState().refreshAccessToken({ accountId: 'alice' });
+ await vi.advanceTimersByTimeAsync(10);
+ expect(tokenCalls()).toEqual([]);
+ release();
+ await expect(renewal).resolves.toBe('cookie-token');
+ expect(tokenCalls()).toHaveLength(1);
+ expect(tokenCalls()[0].url).not.toContain('force=true');
+ });
+
+ it('sign nobody out when the account left during the wait', async () => {
+ const logout = vi.fn(async () => {});
+ const removeAccount = vi.fn();
+ useAuthStore.setState({ logout: logout as never, removeAccount: removeAccount as never, activeAccountId: 'bob' });
+ useAccountStore.setState({ accounts: [account('alice', 0), account('bob', 1)] });
+ tokenAnswers.push(new Response('{}', { status: 401 }));
+ const { release } = contendedLocks();
+ const renewal = useAuthStore.getState().refreshAccessToken({ accountId: 'alice' });
+ await vi.advanceTimersByTimeAsync(10);
+ useAccountStore.setState({ accounts: [account('bob', 1)] });
+ release();
+ await expect(renewal).resolves.toBeNull();
+ expect(logout).not.toHaveBeenCalled();
+ expect(removeAccount).not.toHaveBeenCalled();
+ });
+
+ it('judge who is active after the wait, not before: a refusal for a background account signs out only that one', async () => {
+ const logout = vi.fn(async () => {});
+ const removeAccount = vi.fn();
+ useAccountStore.setState({ accounts: [account('alice', 0), account('bob', 1)] });
+ useAuthStore.setState({ logout: logout as never, removeAccount: removeAccount as never, activeAccountId: 'alice' });
+ tokenAnswers.push(new Response('{}', { status: 401 }));
+ const { release } = contendedLocks();
+ const renewal = useAuthStore.getState().refreshAccessToken({ accountId: 'alice' });
+ await vi.advanceTimersByTimeAsync(10);
+ // The user switched to bob while it waited. The refused token is alice's.
+ useAuthStore.setState({ activeAccountId: 'bob' });
+ release();
+ await renewal;
+ expect(removeAccount).toHaveBeenCalledWith('alice');
+ expect(logout).not.toHaveBeenCalled();
+ });
+});
+
+describe('everyone signing out in another window', () => {
+ it('leaves a pop-out alone here, which saves its typing first (popout-shell)', () => {
+ const close = vi.spyOn(window, 'close').mockImplementation(() => {});
+ useAuthStore.setState({ isAuthenticated: true });
+ window.dispatchEvent(new StorageEvent('storage', { key: SIGNED_OUT_KEY, newValue: '1' }));
+ expect(close).not.toHaveBeenCalled();
+ close.mockRestore();
+ });
+});
diff --git a/stores/account-store.ts b/stores/account-store.ts
index e69b1bbba..f0ae567c9 100644
--- a/stores/account-store.ts
+++ b/stores/account-store.ts
@@ -1,5 +1,6 @@
import { create } from 'zustand';
-import { persist } from 'zustand/middleware';
+import { createJSONStorage, persist } from 'zustand/middleware';
+import { mainWindowStorage } from '@/lib/popout/window-role';
import { generateAccountId, generateAvatarColor, getMaxAccounts } from '@/lib/account-utils';
import { authHooks } from '@/lib/plugin-hooks';
@@ -250,6 +251,7 @@ export const useAccountStore = create()(
}),
{
name: 'account-registry',
+ storage: createJSONStorage(mainWindowStorage),
partialize: (state) => ({
accounts: state.accounts,
activeAccountId: state.activeAccountId,
diff --git a/stores/auth-store.ts b/stores/auth-store.ts
index 380379382..da1352970 100644
--- a/stores/auth-store.ts
+++ b/stores/auth-store.ts
@@ -1,5 +1,5 @@
import { create } from 'zustand';
-import { persist } from 'zustand/middleware';
+import { createJSONStorage, persist } from 'zustand/middleware';
import { JMAPClient, RateLimitError, RequestTimeoutError } from '@/lib/jmap/client';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { useIdentityStore } from './identity-store';
@@ -30,6 +30,9 @@ import { SESSION_TOO_OLD } from '@/lib/session-policy/constants';
import type { Identity } from '@/lib/jmap/types';
import { authHooks } from '@/lib/plugin-hooks';
import { IS_LITE, IS_LITE_STALWART } from '@/lib/lite';
+import { withTokenLock } from '@/lib/auth/token-lock';
+import { requestWindowSession, type SharedAccountSession } from '@/lib/popout/channel';
+import { closePopoutWindow, isPopoutWindow, mainWindowStorage } from '@/lib/popout/window-role';
import { toAsciiEmail } from '@/lib/idn';
import { flushComposers } from '@/lib/compose-flush-registry';
import { sortIdentities } from '@/lib/identity-order';
@@ -119,6 +122,12 @@ interface AuthState {
removeAccount: (accountId: string) => void;
switchAccount: (accountId: string) => Promise;
checkAuth: () => Promise;
+ /**
+ * Start-up of a pop-out window, in place of checkAuth: takes the session
+ * from a main window, or from the remembered cookies when none is open.
+ * Never evicts an account and starts no token timers.
+ */
+ startSharedSession: (options?: { accountId?: string }) => Promise<'ready' | 'no-session'>;
clearError: () => void;
syncIdentities: () => void;
/**
@@ -604,21 +613,30 @@ async function noteSessionAnswer(res: Response, slot: number | undefined): Promi
* access token. `remember: false` keeps a rotated refresh token in a cookie
* that ends with the browser session.
*/
-async function fetchSlotAccessToken(slot: number, opts: { force?: boolean; remember?: boolean } = {}): Promise {
+function fetchSlotAccessToken(slot: number, opts: { force?: boolean; remember?: boolean } = {}): Promise {
// Renewing would write the tokens of a slot being signed out back.
- if (closingSlots.has(slot)) return jsonResponse({ error: 'signed_out' }, 401);
- if (!IS_LITE) {
- const query = `${opts.force ? '&force=true' : ''}${opts.remember === false ? '&remember=false' : ''}`;
- return trackSessionWrite(slot, (signal) => apiFetch(`/api/auth/token?slot=${slot}${query}`, { method: 'PUT', signal }))
- .then((res) => noteSessionAnswer(res, slot));
- }
- // Like the route's cookie cache: a restore resumes with the token it had,
- // since a renewal before the refresh token's `nbf` is refused (#552).
- if (!opts.force) {
- const cached = readLiteAccessToken(slot);
- if (cached) return jsonResponse({ access_token: cached.accessToken, expires_in: cached.expiresIn });
- }
- return trackSessionWrite(slot, async () => {
+ if (closingSlots.has(slot)) return Promise.resolve(jsonResponse({ error: 'signed_out' }, 401));
+ // Tracked from before the wait, so a sign-out can call off a renewal still
+ // queued on the lock.
+ return trackSessionWrite(slot, (signal) => withTokenLock(slot, async ({ contended }) => {
+ // The slot may have started closing during the wait.
+ if (closingSlots.has(slot)) return jsonResponse({ error: 'signed_out' }, 401);
+ // One at a time across windows: each must send the refresh token the last
+ // answer wrote (lib/auth/token-lock.ts). After waiting for another window,
+ // what it renewed is what this one wanted: forcing a second renewal this
+ // soon can be refused (Rauthy `nbf`, #552).
+ const force = opts.force && !contended;
+ if (!IS_LITE) {
+ const query = `${force ? '&force=true' : ''}${opts.remember === false ? '&remember=false' : ''}`;
+ return apiFetch(`/api/auth/token?slot=${slot}${query}`, { method: 'PUT', signal })
+ .then((res) => noteSessionAnswer(res, slot));
+ }
+ // Like the route's cookie cache: a restore resumes with the token it had,
+ // since a renewal before the refresh token's `nbf` is refused (#552).
+ if (!force) {
+ const cached = readLiteAccessToken(slot);
+ if (cached) return jsonResponse({ access_token: cached.accessToken, expires_in: cached.expiresIn });
+ }
try {
const tokens = await liteRefreshTokens(slot);
return jsonResponse({ access_token: tokens.accessToken, expires_in: tokens.expiresIn });
@@ -626,7 +644,7 @@ async function fetchSlotAccessToken(slot: number, opts: { force?: boolean; remem
if (err instanceof LiteLoginError) return jsonResponse({ error: err.code }, liteErrorStatus(err));
throw err; // network failure - callers treat it as an outage, not a rejection
}
- });
+ }, { signal }));
}
/** PUT /api/auth/session[?slot=N] - the slot's remembered password or access token. */
@@ -950,6 +968,11 @@ export function redirectToLogin(): void {
function navigateToLogin(): void {
if (typeof window === 'undefined') return;
+ // Signing in happens in the main window; a pop-out just goes away.
+ if (isPopoutWindow()) {
+ closePopoutWindow();
+ return;
+ }
const loginPath = getLocaleLoginPath();
if (window.location.pathname === loginPath) return;
@@ -1117,6 +1140,50 @@ function snapshotAndClearForSwitch(activeAccountId: string | null): void {
const refreshTimers = new Map>();
const refreshPromises = new Map>();
+/** Told when a pop-out's session can't be renewed any more and no main window is left to do it. */
+export const SHARED_SESSION_ENDED_EVENT = 'bulwark:shared-session-ended';
+
+function bearerToken(header: string): string | null {
+ return header.startsWith('Bearer ') ? header.slice('Bearer '.length) : null;
+}
+
+/**
+ * A new token for a pop-out, whose credential the server just refused. It asks
+ * the main window first: that one owns the renewal, and usually already has
+ * the next token. Only with no main window left does the pop-out renew, under
+ * the same lock (fetchSlotAccessToken), and it never signs anyone out.
+ */
+async function renewSharedToken(accountId: string): Promise {
+ const client = clients.get(accountId);
+ const account = useAccountStore.getState().getAccountById(accountId);
+ if (!client || !account) return null;
+ const rejected = client.getAuthHeader();
+ try {
+ const answer = await requestWindowSession({ accountId, rejectedAuthHeader: rejected });
+ const shared = answer?.sessions.find((session) => session.accountId === accountId);
+ if (shared) return shared.authHeader === rejected ? null : bearerToken(shared.authHeader);
+
+ let res = await fetchSlotAccessToken(account.cookieSlot, { remember: account.rememberMe });
+ if (res.ok) {
+ let { access_token } = await res.json();
+ // The cached token is the one the server refused.
+ if (`Bearer ${access_token}` === rejected) {
+ res = await fetchSlotAccessToken(account.cookieSlot, { force: true, remember: account.rememberMe });
+ if (res.ok) ({ access_token } = await res.json());
+ }
+ if (res.ok) {
+ await syncStalwartAuthContext(account.serverUrl, account.username, `Bearer ${access_token}`, account.cookieSlot);
+ return access_token;
+ }
+ }
+ if (res.status === 401 && typeof window !== 'undefined') window.dispatchEvent(new Event(SHARED_SESSION_ENDED_EVENT));
+ return null;
+ } catch (error) {
+ debug.error('Token renewal in a pop-out failed:', error);
+ return null;
+ }
+}
+
// Retry backoff for transiently failed token refreshes (#588). The values
// are pseudo-expiries for scheduleRefresh - its "expiry - 60s" math turns
// them into delays of 30s, 1m, 2m and 5m (capped). Consecutive failures
@@ -2174,9 +2241,16 @@ export const useAuthStore = create()(
return refreshPromise;
}
+ // A pop-out is on another window's session: it asks that window first.
+ if (isPopoutWindow()) {
+ if (!accountId) return null;
+ const renewal = renewSharedToken(accountId).finally(() => refreshPromises.delete(accountId));
+ refreshPromises.set(accountId, renewal);
+ return renewal;
+ }
+
const account = accountId ? useAccountStore.getState().getAccountById(accountId) : null;
const slot = account?.cookieSlot ?? 0;
- const isActive = !accountId || accountId === get().activeAccountId;
// The account is being signed out: a renewal now would only undo that.
if (closingSlots.has(slot)) return null;
@@ -2188,6 +2262,12 @@ export const useAuthStore = create()(
// Session restore passes allowCached to reuse a still-valid token.
const res = await fetchSlotAccessToken(slot, { force: !options?.allowCached, remember: account?.rememberMe });
+ // Possibly a long wait for another window's renewal. A slot closing
+ // or an account gone by now says nothing about any session, and a
+ // refusal mustn't sign out whichever account is active by now.
+ if (closingSlots.has(slot) || (account && !useAccountStore.getState().getAccountById(account.id))) return null;
+ const isActive = !accountId || accountId === get().activeAccountId;
+
if (!res.ok) {
// Only a definitive 401 ends the session. Anything else (5xx
// while the server restarts, proxy errors) is an outage - keep
@@ -3280,6 +3360,105 @@ export const useAuthStore = create()(
}
},
+ startSharedSession: async (options) => {
+ set({ isLoading: true, client: null });
+ const accountStore = useAccountStore.getState();
+ const answer = await requestWindowSession(options?.accountId ? { accountId: options.accountId } : {});
+ const sessions = answer?.sessions ?? [];
+
+ // No main window open: what the cookies still hold. A login made
+ // without "Remember me" has nothing there, and that is no reason to
+ // evict it: the list is the main window's business.
+ const restoreFromCookies = async (account: AccountEntry): Promise => {
+ if (account.awaitingSignIn) return null;
+ if (account.authMode === 'oauth') {
+ const res = await fetchSlotAccessToken(account.cookieSlot, { remember: account.rememberMe });
+ if (res.status >= 500) throw new TransientAuthError('Token refresh failed', res.status);
+ if (!res.ok) return null;
+ const { access_token } = await res.json();
+ await syncStalwartAuthContext(account.serverUrl, account.username, `Bearer ${access_token}`, account.cookieSlot);
+ return JMAPClient.withBearer(account.serverUrl, access_token, account.username, () => get().refreshAccessToken({ accountId: account.id }));
+ }
+ if (!account.rememberMe && !IS_LITE) return null;
+ const res = await fetchSlotSession(account.cookieSlot);
+ if (res.status >= 500) throw new TransientAuthError('Session restore failed', res.status);
+ if (!res.ok) return null;
+ const session: RememberedSession = await res.json();
+ return clientForSession(session);
+ };
+
+ const adopt = async (accountId: string, session?: SharedAccountSession): Promise => {
+ const account = accountStore.getAccountById(accountId);
+ if (!account) return null;
+ try {
+ if (session?.offlineOnly) throw new TransientAuthError('The main window is offline', 503);
+ const client = session
+ ? JMAPClient.withSharedAuth(account.serverUrl, account.username, session.authHeader, session.authMode === 'oauth'
+ ? () => get().refreshAccessToken({ accountId })
+ : undefined)
+ : await restoreFromCookies(account);
+ if (!client) return null;
+ bindClientStatusHandlers(client, set, get, accountId);
+ await client.connect();
+ clients.set(accountId, client);
+ accountStore.updateAccount(accountId, { isConnected: true, hasError: false });
+ return client;
+ } catch (error) {
+ debug.error(`A pop-out could not use account ${accountId}:`, error);
+ if (!isTransientAuthError(error)) return null;
+ const offlineClient = await startOfflineClient(accountId, account.serverUrl, account.username);
+ if (offlineClient) clients.set(accountId, offlineClient);
+ return offlineClient;
+ }
+ };
+
+ const targetId = options?.accountId
+ ?? answer?.activeAccountId
+ ?? accountStore.activeAccountId
+ ?? accountStore.getDefaultAccount()?.id
+ ?? null;
+ const account = targetId ? accountStore.getAccountById(targetId) : undefined;
+ const client = account ? await adopt(account.id, sessions.find((session) => session.accountId === account.id)) : null;
+ if (!account || !client) {
+ set({ isLoading: false, isAuthenticated: false, client: null });
+ return 'no-session';
+ }
+
+ // The window's other logins: a draft may live in one (see
+ // ComposerDraftData), and the Pro From picker lists them.
+ const others = sessions.filter((session) => session.accountId !== account.id);
+ if (others.length > 0) {
+ void Promise.allSettled(others.map((session) => adopt(session.accountId, session))).then(() => {
+ set((state) => ({ connectedAccountsRevision: state.connectedAccountsRevision + 1 }));
+ });
+ }
+
+ accountStore.setActiveAccount(account.id);
+ initializeFeatureStores(client);
+ let loaded: ReturnType = { identities: [], primaryIdentity: null };
+ try {
+ loaded = loadIdentities(await client.getIdentities(), account.username);
+ } catch (error) {
+ debug.error('Failed to load identities in a pop-out:', error);
+ }
+ set({
+ isAuthenticated: true,
+ isLoading: false,
+ serverUrl: account.serverUrl,
+ username: account.username,
+ client,
+ ...getClientRateLimitState(client),
+ identities: loaded.identities,
+ primaryIdentity: loaded.primaryIdentity,
+ authMode: account.authMode,
+ rememberMe: account.rememberMe,
+ connectionLost: false,
+ error: null,
+ activeAccountId: account.id,
+ });
+ return 'ready';
+ },
+
getClientForAccount: (accountId: string) => {
return clients.get(accountId);
},
@@ -3301,6 +3480,7 @@ export const useAuthStore = create()(
}),
{
name: 'auth-storage',
+ storage: createJSONStorage(mainWindowStorage),
partialize: (state) => {
// Don't persist unauthenticated state - prevents resurrecting stale sessions
if (!state.isAuthenticated) return {};
@@ -3367,6 +3547,8 @@ useAuthStore.subscribe((state) => {
// gone too, so leave for the login page instead of showing stale mail.
if (typeof window !== 'undefined') {
onSignedOutElsewhere(() => {
+ // A pop-out saves its typing first (components/popout/popout-shell.tsx).
+ if (isPopoutWindow()) return;
if (useAuthStore.getState().isAuthenticated) navigateToLogin();
});
}