From b2931ced3ed784204d9d6d15196111a5eea23e7c Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 3 Nov 2026 10:44:02 +0100
Subject: [PATCH] feat: bring desktop windows back at the next start, open
pop-outs from a warm spare and ask natively before discarding a draft
---
app/(main)/[locale]/popout/spare/page.tsx | 7 ++
.../__tests__/composer-close-guard.test.tsx | 35 +++++-
components/email/email-composer.tsx | 41 ++++++-
.../__tests__/popout-compose-app.test.tsx | 45 ++++++++
components/popout/popout-compose-app.tsx | 64 ++++++++++-
components/popout/popout-spare-app.tsx | 45 ++++++++
.../settings/desktop-window-settings.tsx | 26 +++++
components/settings/general-settings.tsx | 2 +
lib/desktop/preferences.ts | 71 +++++++++++++
lib/desktop/types.ts | 100 +++++++++++++++++-
lib/desktop/window-restore.ts | 40 +++++++
lib/draft-composer-data.ts | 60 +++++++++++
lib/popout/open.ts | 6 ++
lib/popout/window-role.ts | 10 +-
locales/ar/common.json | 15 +++
locales/ca/common.json | 15 +++
locales/cs/common.json | 15 +++
locales/da/common.json | 15 +++
locales/de/common.json | 15 +++
locales/en/common.json | 15 +++
locales/es/common.json | 15 +++
locales/et/common.json | 15 +++
locales/fa/common.json | 15 +++
locales/fr/common.json | 15 +++
locales/he/common.json | 15 +++
locales/hu/common.json | 15 +++
locales/it/common.json | 15 +++
locales/ja/common.json | 15 +++
locales/ko/common.json | 15 +++
locales/lv/common.json | 15 +++
locales/mn/common.json | 15 +++
locales/nb/common.json | 15 +++
locales/nl/common.json | 15 +++
locales/pl/common.json | 15 +++
locales/pt/common.json | 15 +++
locales/ro/common.json | 15 +++
locales/ru/common.json | 15 +++
locales/sk/common.json | 15 +++
locales/th/common.json | 15 +++
locales/tr/common.json | 15 +++
locales/uk/common.json | 15 +++
locales/vi/common.json | 15 +++
locales/zh-TW/common.json | 15 +++
locales/zh/common.json | 15 +++
44 files changed, 987 insertions(+), 15 deletions(-)
create mode 100644 app/(main)/[locale]/popout/spare/page.tsx
create mode 100644 components/popout/popout-spare-app.tsx
create mode 100644 components/settings/desktop-window-settings.tsx
create mode 100644 lib/desktop/preferences.ts
create mode 100644 lib/desktop/window-restore.ts
create mode 100644 lib/draft-composer-data.ts
diff --git a/app/(main)/[locale]/popout/spare/page.tsx b/app/(main)/[locale]/popout/spare/page.tsx
new file mode 100644
index 000000000..1eafe6639
--- /dev/null
+++ b/app/(main)/[locale]/popout/spare/page.tsx
@@ -0,0 +1,7 @@
+import { PopoutSpareApp } from "@/components/popout/popout-spare-app";
+
+// The desktop app's warm spare (components/popout/popout-spare-app.tsx). Not in
+// Lite: scripts/lite removes this tree.
+export default function PopoutSparePage() {
+ return ;
+}
diff --git a/components/email/__tests__/composer-close-guard.test.tsx b/components/email/__tests__/composer-close-guard.test.tsx
index 8ac5e5b82..9ef9370a7 100644
--- a/components/email/__tests__/composer-close-guard.test.tsx
+++ b/components/email/__tests__/composer-close-guard.test.tsx
@@ -1,5 +1,5 @@
import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react';
-import { beforeEach, describe, it, expect, vi } from 'vitest';
+import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
import { useAuthStore } from '@/stores/auth-store';
@@ -235,6 +235,39 @@ describe('composer close guard with a follow-up', () => {
expect(afterClose).not.toHaveBeenCalled();
});
+ describe('in the desktop app', () => {
+ afterEach(() => {
+ window.bulwarkDesktop = undefined;
+ });
+
+ const nativeBridge = (answer: number) => {
+ const confirm = vi.fn(async () => answer);
+ window.bulwarkDesktop = { version: 1, platform: 'win32', dialogs: { confirm } } as never;
+ return confirm;
+ };
+
+ it('asks through the OS, and shows no dialog of its own', async () => {
+ const confirm = nativeBridge(2);
+ const { requestCloseRef, afterClose, onClose } = renderWithCloseRef();
+ act(() => requestCloseRef.current!(afterClose));
+ await waitFor(() => expect(confirm).toHaveBeenCalledTimes(1));
+ expect(confirm.mock.calls[0]).toMatchObject([{ message: 'close_draft_title', buttons: ['save', 'discard', 'cancel'], defaultId: 0 }]);
+ expect(screen.queryByText('close_draft_title')).not.toBeInTheDocument();
+ // The last button is Cancel: nothing happens.
+ await act(async () => {});
+ expect(onClose).not.toHaveBeenCalled();
+ expect(afterClose).not.toHaveBeenCalled();
+ });
+
+ it('Discard discards, and the follow-up runs', async () => {
+ nativeBridge(1);
+ const { requestCloseRef, afterClose, onClose } = renderWithCloseRef();
+ act(() => requestCloseRef.current!(afterClose));
+ await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
+ await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1));
+ });
+ });
+
it('runs the follow-up after discarding', async () => {
const { requestCloseRef, afterClose, onClose } = renderWithCloseRef();
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index d4ff3a9a8..2e25fdb4d 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -166,6 +166,8 @@ import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text";
import { fileStorage } from "@/lib/plugin-storage";
import { usePolicyStore } from "@/stores/policy-store";
import { registerComposeFlush } from "@/lib/compose-flush-registry";
+import { getDesktopBridge } from "@/lib/desktop/bridge";
+import { noteOpenDraft } from "@/lib/desktop/window-restore";
import type { RescuedDraft } from "@/lib/popout/channel";
import { toUnicodeEmail } from "@/lib/idn";
@@ -1936,6 +1938,14 @@ export function EmailComposer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
+ // A composer that comes back with the app: a draft it opened on counts from the start.
+ // Gone with the composer: sent, discarded or closed.
+ useEffect(() => {
+ if (draftIdRef.current) noteOpenDraft(effectiveSessionKey, { draftId: draftIdRef.current, localAccountId: draftLocalAccountIdRef.current, ownerAccountId: draftAccountIdRef.current });
+ return () => noteOpenDraft(effectiveSessionKey, null);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [effectiveSessionKey]);
+
// Auto-save draft to server on page close (best-effort)
const ownWindowRef = useRef(ownWindow);
ownWindowRef.current = ownWindow;
@@ -3005,6 +3015,8 @@ export function EmailComposer({
draftIdRef.current = savedDraftId;
draftAccountIdRef.current = draftAccountId;
draftLocalAccountIdRef.current = savingLogin;
+ // Before a save's promise settles: quitting waits for it, then keeps this list.
+ noteOpenDraft(effectiveSessionKey, { draftId: savedDraftId, localAccountId: savingLogin, ownerAccountId: draftAccountId });
setDraftId(savedDraftId);
setDraftAccountId(draftAccountId);
setDraftLocalAccountId(savingLogin);
@@ -4418,6 +4430,29 @@ export function EmailComposer({
emitClose();
};
+ /** The question before closing or discarding: the OS's own in the desktop app. */
+ const openCloseDialog = (kind: 'discard' | 'close') => {
+ const dialogs = getDesktopBridge()?.dialogs;
+ if (!dialogs) {
+ setCloseDialogKind(kind);
+ setShowCloseDialog(true);
+ return;
+ }
+ const discarding = kind === 'discard';
+ const buttons = discarding ? [t('discard'), t('cancel')] : [tCommon('save'), t('discard'), t('cancel')];
+ void dialogs.confirm({
+ type: 'warning',
+ message: discarding ? t('discard_draft_title') : t('close_draft_title'),
+ detail: discarding ? t('discard_draft_message') : t('close_draft_message'),
+ buttons,
+ defaultId: discarding ? 1 : 0,
+ }).then((choice) => {
+ if (buttons[choice] === t('discard')) handleDiscardAndClose();
+ else if (!discarding && choice === 0) void handleSaveDraftAndClose();
+ else afterCloseRef.current = null;
+ });
+ };
+
// Discard deletes the draft, also one that has no unsaved changes, such as
// a scheduled message reopened with "Cancel and edit". It used to share the
// X button's path, which closed such a composer and kept the draft.
@@ -4429,8 +4464,7 @@ export function EmailComposer({
return;
}
afterCloseRef.current = null;
- setCloseDialogKind('discard');
- setShowCloseDialog(true);
+ openCloseDialog('discard');
};
const handleClose = (afterClose?: () => void) => {
@@ -4442,8 +4476,7 @@ export function EmailComposer({
&& !saveFailedRef.current
&& !attachmentsRef.current.some(att => att.uploading || att.error);
if (isDirtyRef.current && !savedAlready) {
- setCloseDialogKind('close');
- setShowCloseDialog(true);
+ openCloseDialog('close');
} else {
cleanClose();
}
diff --git a/components/popout/__tests__/popout-compose-app.test.tsx b/components/popout/__tests__/popout-compose-app.test.tsx
index 3ff5cf883..2a02faf0f 100644
--- a/components/popout/__tests__/popout-compose-app.test.tsx
+++ b/components/popout/__tests__/popout-compose-app.test.tsx
@@ -67,6 +67,51 @@ function openAt(handoffId: string) {
render();
}
+describe('a composer coming back on its draft', () => {
+ const closeSelf = vi.fn();
+
+ beforeEach(() => {
+ closeSelf.mockClear();
+ window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { closeSelf } } as never;
+ });
+
+ afterEach(() => {
+ window.bulwarkDesktop = undefined;
+ });
+
+ const draft = {
+ id: 'd1', subject: 'Half-written', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ email: 'me@example.org' }],
+ bodyValues: { t: { value: 'Text' } }, textBody: [{ partId: 't', type: 'text/plain' }], htmlBody: [], attachments: [{ blobId: 'b1', name: 'a.pdf', type: 'application/pdf', size: 3 }],
+ };
+
+ const openDraft = (getEmail: ReturnType, search = 'draft=d1&account=alice&owner=group') => {
+ useAuthStore.setState({ client: { getEmail } as never, getClientForAccount: () => undefined } as never);
+ window.history.replaceState(null, '', `/en/popout/compose?${search}`);
+ render();
+ };
+
+ it('reads the draft from where it lives, and opens the composer on it, id and all', async () => {
+ const getEmail = vi.fn(async () => draft);
+ openDraft(getEmail);
+ await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Half-written'));
+ expect(getEmail).toHaveBeenCalledWith('d1', 'group');
+ // Saving goes over this draft, not next to it, and its files stay.
+ expect(composer?.initialData).toMatchObject({ draftId: 'd1', draftAccountId: 'group', draftLocalAccountId: 'alice', to: 'a@example.org' });
+ expect((composer?.initialData as unknown as { attachments: unknown[] }).attachments).toHaveLength(1);
+ });
+
+ it('a draft that has gone since (sent elsewhere) closes the window', async () => {
+ openDraft(vi.fn(async () => null));
+ await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1));
+ expect(screen.queryByTestId('composer')).toBeNull();
+ });
+
+ it('a server that doesn\'t answer closes it too: the draft is safe on the server', async () => {
+ openDraft(vi.fn(async () => { throw new Error('offline'); }));
+ await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1));
+ });
+});
+
describe('PopoutComposeApp', () => {
it('tells the opening window to let go only with the composer on screen', async () => {
const handoff = createComposeHandoff()!;
diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx
index 6f0bebeff..7aaa4f709 100644
--- a/components/popout/popout-compose-app.tsx
+++ b/components/popout/popout-compose-app.tsx
@@ -18,6 +18,8 @@ import { announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watch
import { acknowledgeComposeHandoff, receiveComposeHandoff, rememberComposePayload, rememberedComposePayload, watchHandoffCancel } from "@/lib/popout/compose-handoff";
import type { ComposePopoutPayload } from "@/lib/popout/compose-payload";
import { closePopoutWindow } from "@/lib/popout/window-role";
+import { composerDataFromDraft } from "@/lib/draft-composer-data";
+import { useIdentityStore } from "@/stores/identity-store";
import { PopoutShell, PopoutSkeleton } from "./popout-shell";
/** A warning with no main window left to show it stays this long before the window goes. */
@@ -31,10 +33,60 @@ const EMPTY_PAYLOAD: ComposePopoutPayload = { mode: "compose" };
/** How long a reloaded window waits to hear what became of the text it handed over on the way out. */
const RESCUE_ANSWER_MS = 2000;
-function readLaunch(): { handoffId: string | null; accountId?: string } {
+function readLaunch(): { handoffId: string | null; accountId?: string; draftId?: string; ownerAccountId?: string } {
if (typeof window === "undefined") return { handoffId: null };
const search = new URLSearchParams(window.location.search);
- return { handoffId: search.get("handoff"), accountId: search.get("account") ?? undefined };
+ return {
+ handoffId: search.get("handoff"),
+ accountId: search.get("account") ?? undefined,
+ draftId: search.get("draft") ?? undefined,
+ ownerAccountId: search.get("owner") ?? undefined,
+ };
+}
+
+/**
+ * The composer that was open when the desktop app quit, back on its draft. A draft
+ * that has gone since (sent or deleted elsewhere) means there's nothing to show.
+ */
+function RestoredDraft({ draftId, accountId, ownerAccountId, onReady }: {
+ draftId: string;
+ accountId?: string;
+ ownerAccountId?: string;
+ onReady: (payload: ComposePopoutPayload) => void;
+}) {
+ const t = useTranslations();
+ const tWindows = useTranslations("pwaWindows");
+ const client = useAuthStore((s) => (accountId ? s.getClientForAccount(accountId) : undefined) ?? s.client);
+ const identities = useIdentityStore((s) => s.identities);
+ useEffect(() => {
+ if (!client) return;
+ let cancelled = false;
+ void client.getEmail(draftId, ownerAccountId).then((draft) => {
+ if (cancelled) return;
+ if (!draft) {
+ closePopoutWindow();
+ return;
+ }
+ onReady({
+ mode: "compose",
+ initialData: composerDataFromDraft(draft, {
+ accountId: ownerAccountId,
+ localAccountId: accountId,
+ identities,
+ noSubject: t("email_composer.no_subject"),
+ }),
+ });
+ }).catch((error) => {
+ debug.error("Failed to reopen a draft:", error);
+ if (!cancelled) closePopoutWindow();
+ });
+ return () => {
+ cancelled = true;
+ };
+ // Once per client: the draft doesn't change under a window still opening.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [client, draftId, ownerAccountId]);
+ return ;
}
/** The composer alone, in its own window: sending, autosave and discard go through the shared stores, as in a Pro tab. */
@@ -181,8 +233,8 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload;
/** `/popout/compose`: a draft handed over by another window, or a new message. */
export function PopoutComposeApp() {
const t = useTranslations("pwaWindows");
- const [{ handoffId, accountId }] = useState(readLaunch);
- const [payload, setPayload] = useState(handoffId ? null : EMPTY_PAYLOAD);
+ const [{ handoffId, accountId, draftId, ownerAccountId }] = useState(readLaunch);
+ const [payload, setPayload] = useState(handoffId || draftId ? null : EMPTY_PAYLOAD);
useEffect(() => {
if (!handoffId) return;
@@ -216,7 +268,9 @@ export function PopoutComposeApp() {
return (
- {payload ? : }
+ {payload ?
+ : draftId ?
+ : }
);
}
diff --git a/components/popout/popout-spare-app.tsx b/components/popout/popout-spare-app.tsx
new file mode 100644
index 000000000..26f67104e
--- /dev/null
+++ b/components/popout/popout-spare-app.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { getDesktopBridge } from "@/lib/desktop/bridge";
+import { withBasePath } from "@/lib/browser-navigation";
+import { PopoutComposeApp } from "./popout-compose-app";
+import { PopoutMessageApp } from "./popout-message-app";
+
+/** The message id a pop-out address names. */
+function messageIdOf(pathname: string): string {
+ const segments = pathname.split("/").filter(Boolean);
+ const at = segments.lastIndexOf("message");
+ return at >= 0 && segments[at + 1] ? decodeURIComponent(segments[at + 1]) : "";
+}
+
+/**
+ * The desktop app's warm spare: a hidden window with the pop-out code loaded and
+ * nothing signed in. When a pop-out is wanted the shell gives it an address. The
+ * address bar changes without a load, which is where the pop-outs read their
+ * arguments from, and the right one renders in a window that's already up.
+ */
+export function PopoutSpareApp() {
+ const [target, setTarget] = useState(null);
+
+ useEffect(() => {
+ const spare = getDesktopBridge()?.windows?.spare;
+ if (!spare) {
+ // Someone typed the address, or a browser followed a link.
+ window.location.replace(withBasePath("/"));
+ return;
+ }
+ const stop = spare.onActivate((url) => {
+ const next = new URL(url, window.location.href);
+ window.history.replaceState(null, "", `${next.pathname}${next.search}`);
+ setTarget(next);
+ spare.taken();
+ });
+ spare.ready();
+ return stop;
+ }, []);
+
+ if (!target) return ;
+ if (/\/popout\/compose\/?$/.test(target.pathname)) return ;
+ return ;
+}
diff --git a/components/settings/desktop-window-settings.tsx b/components/settings/desktop-window-settings.tsx
new file mode 100644
index 000000000..3d604728c
--- /dev/null
+++ b/components/settings/desktop-window-settings.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
+import { hasDesktopWindows } from "@/lib/popout/support";
+import { setDesktopPref, useDesktopPrefs } from "@/lib/desktop/preferences";
+import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section";
+
+/** How the desktop app's windows behave. Only there: a browser has nothing to set. */
+export function DesktopWindowSettings() {
+ const t = useTranslations("desktopUi.settings");
+ const prefs = useDesktopPrefs();
+ const [shown, setShown] = useState(false);
+ useEffect(() => setShown(hasDesktopWindows()), []);
+ if (!shown) return null;
+ return (
+
+
+ setDesktopPref("openOnDoubleClick", checked)} />
+
+
+ setDesktopPref("restoreWindows", checked)} />
+
+
+ );
+}
diff --git a/components/settings/general-settings.tsx b/components/settings/general-settings.tsx
index 41d2ba072..7dc969209 100644
--- a/components/settings/general-settings.tsx
+++ b/components/settings/general-settings.tsx
@@ -9,6 +9,7 @@ import { useTour } from '@/components/tour/tour-provider';
import { Button } from '@/components/ui/button';
import { PlayCircle } from '@/components/icons';
import { IS_LITE } from '@/lib/lite';
+import { DesktopWindowSettings } from './desktop-window-settings';
/**
* The General page: language and time, which links the browser hands to
@@ -26,6 +27,7 @@ export function GeneralSettings({ supportsCalendar }: { supportsCalendar: boolea
{!IS_LITE && }
+