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

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

146 lines
5.4 KiB
TypeScript

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<string, unknown>) {
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/);
});
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');
});
});