mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
Merge branch 'feat/desktop-offline' into feat/do-menu-fixes
# Conflicts: # components/desktop/desktop-host.tsx
This commit is contained in:
@@ -738,3 +738,24 @@
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* An invitation's card: when and where, in a box above the body. */
|
||||
.pr-invitation {
|
||||
margin: 4mm 0 0;
|
||||
padding: 2.5mm 3mm;
|
||||
border: 0.3mm solid var(--pr-rule, #bbb);
|
||||
border-radius: 1.5mm;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.pr-invitation-heading {
|
||||
margin: 0;
|
||||
font-size: 8pt;
|
||||
font-weight: 600;
|
||||
color: var(--pr-muted);
|
||||
}
|
||||
|
||||
.pr-invitation .pr-message-meta {
|
||||
font-size: 10pt;
|
||||
color: var(--pr-ink);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { readDesktopPrefs, resetDesktopPrefsCache, setDesktopPref } from '@/lib/
|
||||
import { resetDesktopLanguages } from '@/lib/desktop/languages';
|
||||
import { registerComposeFlush, resetComposeFlushRegistry } from '@/lib/compose-flush-registry';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
import { closeFirstStartNotice, firstStartNoticesWaiting, resetFirstStartNotices } from '@/lib/first-start-notices';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
import { useOfflineStore } from '@/lib/offline/state';
|
||||
@@ -121,7 +122,7 @@ describe('DesktopHost', () => {
|
||||
shell.run('file.newContact');
|
||||
expect(push).toHaveBeenLastCalledWith('/contacts');
|
||||
expect(peekParkedAppCommand()).toBe('new-contact');
|
||||
shell.run('file.settings');
|
||||
shell.run('go.settings');
|
||||
expect(push).toHaveBeenLastCalledWith('/settings');
|
||||
});
|
||||
|
||||
@@ -154,6 +155,13 @@ describe('DesktopHost', () => {
|
||||
expect(push).toHaveBeenLastCalledWith('/contacts');
|
||||
});
|
||||
|
||||
it('Help > Settings opens settings as a page of this window', () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
shell.run('go.settings');
|
||||
expect(push).toHaveBeenLastCalledWith('/settings');
|
||||
});
|
||||
|
||||
it('a link to a folder goes through the router, encoded', () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
@@ -286,8 +294,10 @@ describe('DesktopHost', () => {
|
||||
});
|
||||
|
||||
describe('a language with no checker in the OS', () => {
|
||||
beforeEach(() => resetFirstStartNotices());
|
||||
afterEach(() => resetComposeFlushRegistry());
|
||||
const toasts = () => useToastStore.getState().toasts;
|
||||
// A first-start notice above the message list, not a toast over the composer.
|
||||
const toasts = () => firstStartNoticesWaiting();
|
||||
|
||||
it('the composer asks once, and only a yes opens the downloads', async () => {
|
||||
useToastStore.setState({ toasts: [] });
|
||||
@@ -299,6 +309,7 @@ describe('DesktopHost', () => {
|
||||
expect(toasts()).toHaveLength(0);
|
||||
const stop = registerComposeFlush(async () => {});
|
||||
await waitFor(() => expect(toasts()).toHaveLength(1));
|
||||
expect(useToastStore.getState().toasts).toHaveLength(0);
|
||||
expect(shell.allowDownload).not.toHaveBeenCalled();
|
||||
act(() => toasts()[0].action!.onClick());
|
||||
expect(shell.allowDownload).toHaveBeenCalledTimes(1);
|
||||
@@ -306,6 +317,24 @@ describe('DesktopHost', () => {
|
||||
stop();
|
||||
});
|
||||
|
||||
it("closing it says no, and it doesn't come back", async () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
await act(async () => {});
|
||||
const stop = registerComposeFlush(async () => {});
|
||||
shell.missing('nl-NL');
|
||||
await waitFor(() => expect(toasts()).toHaveLength(1));
|
||||
// Only asked: nothing decided until the user closes it.
|
||||
expect(readDesktopPrefs().dictionaryDownload).toBe('ask');
|
||||
act(() => closeFirstStartNotice(toasts()[0].id));
|
||||
expect(readDesktopPrefs().dictionaryDownload).toBe('no');
|
||||
shell.missing('fr-FR');
|
||||
await act(async () => {});
|
||||
expect(toasts()).toHaveLength(0);
|
||||
expect(shell.allowDownload).not.toHaveBeenCalled();
|
||||
stop();
|
||||
});
|
||||
|
||||
it('never a second time', async () => {
|
||||
useToastStore.setState({ toasts: [] });
|
||||
setDesktopPref('dictionaryDownload', 'no');
|
||||
|
||||
@@ -25,7 +25,7 @@ import { useEmailStore } from "@/stores/email-store";
|
||||
import { useLocaleStore } from "@/stores/locale-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { showFirstStartNotice } from "@/lib/first-start-notices";
|
||||
import { openMailImport } from "@/stores/mail-transfer-store";
|
||||
|
||||
/** The part of the app a path shows, for the menus: Calendar has its own View lines, and no Message menu. */
|
||||
@@ -94,13 +94,13 @@ export function DesktopHost() {
|
||||
routerRef.current.push("/contacts");
|
||||
},
|
||||
"file.import": () => openMailImport(),
|
||||
// One way to Settings: in the main window, as the rail's button opens them.
|
||||
"file.settings": () => routerRef.current.push("/settings"),
|
||||
"go.inbox": () => routerRef.current.push("/"),
|
||||
"go.mail": () => routerRef.current.push("/"),
|
||||
"go.calendar": () => routerRef.current.push("/calendar"),
|
||||
"go.contacts": () => routerRef.current.push("/contacts"),
|
||||
"go.files": () => routerRef.current.push("/files"),
|
||||
// Help > Settings: a page of this window, as the rail's gear opens it.
|
||||
"go.settings": () => routerRef.current.push("/settings"),
|
||||
"help.shortcuts": () => openKeyboardShortcuts(),
|
||||
};
|
||||
return menu.onCommand((id) => {
|
||||
@@ -167,9 +167,10 @@ export function DesktopHost() {
|
||||
noteComposerLanguage("settings", writingLanguage && writingLanguage !== "auto" ? writingLanguage : null);
|
||||
}, [writingLanguage]);
|
||||
|
||||
// A language with no checker in the OS needs a dictionary from Google: the composer
|
||||
// asks once, and never in no-third-party mode. Until a yes, the shell has nowhere to
|
||||
// fetch from at all.
|
||||
// A language with no checker in the OS needs a dictionary from Google: once a composer
|
||||
// is open, a first-start notice asks, never in no-third-party mode, and not again once
|
||||
// closed. Until a yes, the shell has nowhere to fetch from at all. Windows never asks:
|
||||
// it checks spelling itself (the shell's context-menu.ts).
|
||||
useEffect(() => {
|
||||
const spellcheck = getDesktopBridge()?.spellcheck;
|
||||
if (!spellcheck) return;
|
||||
@@ -179,8 +180,6 @@ export function DesktopHost() {
|
||||
const [language] = waiting;
|
||||
if (!language || !composerIsOpen() || !dictionaryDownloadAllowed()) return;
|
||||
if (readDesktopPrefs().dictionaryDownload !== "ask") return;
|
||||
// Asked once, whatever comes of it.
|
||||
setDesktopPref("dictionaryDownload", "no");
|
||||
const translate = tRef.current;
|
||||
let name = language;
|
||||
try {
|
||||
@@ -188,8 +187,9 @@ export function DesktopHost() {
|
||||
} catch {
|
||||
// The tag itself will do.
|
||||
}
|
||||
toast.info(translate("desktopUi.spellcheck.offer", { language: name }), {
|
||||
duration: 0,
|
||||
showFirstStartNotice({
|
||||
id: "dictionary-download",
|
||||
text: translate("desktopUi.spellcheck.offer", { language: name }),
|
||||
action: {
|
||||
label: translate("desktopUi.spellcheck.download"),
|
||||
onClick: () => {
|
||||
@@ -197,6 +197,10 @@ export function DesktopHost() {
|
||||
if (dictionaryDownloadAllowed()) spellcheck.allowDownload();
|
||||
},
|
||||
},
|
||||
// Closed without a yes: asked once, not again.
|
||||
onClose: () => {
|
||||
if (readDesktopPrefs().dictionaryDownload === "ask") setDesktopPref("dictionaryDownload", "no");
|
||||
},
|
||||
});
|
||||
};
|
||||
const heard = (language: string) => {
|
||||
|
||||
@@ -61,6 +61,26 @@ function printCopy(): HTMLElement | null {
|
||||
}
|
||||
|
||||
describe('Ctrl+P on an open message', () => {
|
||||
it('prints an invitation with its card: when, where and who', async () => {
|
||||
const { InvitationPrintSync } = await import('@/lib/print/invitation-print');
|
||||
const card = { heading: 'Calendar Invitation', rows: [['Time', 'Thu 5 Nov, 13:00 to 14:00'], ['Location', 'Room 4'], ['Organized by', 'Anna Becker']] as Array<[string, string]> };
|
||||
const view = render(<>
|
||||
<EmailViewer email={htmlMail()} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />
|
||||
<InvitationPrintSync emailId="e1" print={card} />
|
||||
</>);
|
||||
act(() => { window.dispatchEvent(new Event('beforeprint')); });
|
||||
const box = printCopy()!.querySelector('[data-message-print-invitation]')!;
|
||||
expect(box.textContent).toContain('Calendar Invitation');
|
||||
expect(box.textContent).toContain('Thu 5 Nov, 13:00 to 14:00');
|
||||
expect(box.textContent).toContain('Room 4');
|
||||
expect(box.textContent).toContain('Anna Becker');
|
||||
act(() => { window.dispatchEvent(new Event('afterprint')); });
|
||||
// The card gone (another message opened): the next printout has no box.
|
||||
view.rerender(<EmailViewer email={htmlMail()} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
|
||||
act(() => { window.dispatchEvent(new Event('beforeprint')); });
|
||||
expect(printCopy()!.querySelector('[data-message-print-invitation]')).toBeNull();
|
||||
}, 30_000);
|
||||
|
||||
it('prints the message alone, next to the app', () => {
|
||||
render(<EmailViewer email={htmlMail()} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
|
||||
expect(printCopy()).toBeNull();
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ChevronUp,
|
||||
} from '@/components/icons';
|
||||
import { useTranslations, useFormatter } from 'next-intl';
|
||||
import { InvitationPrintSync } from '@/lib/print/invitation-print';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import { isDocumentRTL } from '@/i18n/direction';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
@@ -939,6 +940,21 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
tabIndex={isCollapsed ? 0 : undefined}
|
||||
aria-expanded={isCollapsed ? false : undefined}
|
||||
>
|
||||
{/* When and where, for the printout of this message: its print copy has no card. */}
|
||||
{summary && (
|
||||
<InvitationPrintSync
|
||||
emailId={email.id}
|
||||
print={{
|
||||
heading: bannerTitle,
|
||||
rows: ([
|
||||
[t('change_title'), summary.title ?? ''],
|
||||
[t('change_time'), summary.start ? formatSchedule(summary) : ''],
|
||||
[t('change_location'), summary.location ?? ''],
|
||||
[t('organizer_label'), summary.organizer ? (summary.organizerEmail && summary.organizerEmail !== summary.organizer ? `${summary.organizer} <${summary.organizerEmail}>` : summary.organizer) : ''],
|
||||
] as Array<[string, string]>).filter(([, value]) => value),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Avatar-style icon */}
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm",
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ReaderAttachments, type AttachmentDragProps } from "./reader-attachment
|
||||
import { editAccountOf, useEditAttachment } from "@/hooks/use-edit-attachment";
|
||||
import { ReaderViewDialog } from "./reader-view-dialog";
|
||||
import { MessagePrint, type MessagePrintFields } from "./message-print";
|
||||
import { useInvitationPrint } from "@/lib/print/invitation-print";
|
||||
import { usePrintListener, usePrinting } from "@/components/print/print-root";
|
||||
import { ReadingMask } from "./reading-mask";
|
||||
import { collectReferencedCids, isEmbeddedInBody, isReportPart, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility";
|
||||
@@ -3792,6 +3793,7 @@ export function EmailViewer({
|
||||
? effectiveEmailContent.html.replace(/<details>/g, '<details open>')
|
||||
: sanitizeEmailHtml(effectiveEmailContent.html),
|
||||
plain,
|
||||
invitation: invitationPrint,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3889,6 +3891,8 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
() => printing && !!viewerRootEl && isShownOnScreen(viewerRootEl),
|
||||
[printing, viewerRootEl],
|
||||
);
|
||||
// An invitation's when and where, from its card.
|
||||
const invitationPrint = useInvitationPrint(email?.id);
|
||||
const printCopyFields = printShown && !devices.noPrint && !isLoading && !!email?.bodyValues
|
||||
? messagePrintFields()
|
||||
: null;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { PrintRoot } from "@/components/print/print-root";
|
||||
import type { InvitationPrint } from "@/lib/print/invitation-print";
|
||||
|
||||
export interface MessagePrintFields {
|
||||
subject: string;
|
||||
@@ -13,6 +14,8 @@ export interface MessagePrintFields {
|
||||
/** Sanitized body HTML without <style>: a re-sanitized HTML body, or the escaped plain-text one. */
|
||||
bodyHtml: string;
|
||||
plain: boolean;
|
||||
/** An invitation's card: what, when, where and who, as the reader shows it. */
|
||||
invitation?: InvitationPrint | null;
|
||||
}
|
||||
|
||||
// The reader's image rule: cap only images that set no max-width of their own.
|
||||
@@ -66,6 +69,19 @@ export function MessagePrint({ fields }: { fields: MessagePrintFields }) {
|
||||
))}
|
||||
</dl>
|
||||
</header>
|
||||
{fields.invitation && fields.invitation.rows.length > 0 && (
|
||||
<section className="pr-invitation" data-message-print-invitation="">
|
||||
<h2 className="pr-invitation-heading">{fields.invitation.heading}</h2>
|
||||
<dl className="pr-message-meta">
|
||||
{fields.invitation.rows.map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<dt>{label}:</dt>
|
||||
<dd dir="auto">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
<div className="pr-message-body" data-message-print-body="" ref={fillBody} />
|
||||
</article>
|
||||
</PrintRoot>
|
||||
|
||||
@@ -50,6 +50,20 @@ vi.mock('@/hooks/use-media-query', () => ({
|
||||
}));
|
||||
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
|
||||
vi.mock('@/components/email/email-list', () => ({ EmailList: () => <div>message list</div> }));
|
||||
// The desktop app's compose windows, for a mail link from another program.
|
||||
const desktopWindows = vi.hoisted(() => ({ on: false, handOver: vi.fn(async () => true), open: vi.fn() }));
|
||||
vi.mock('@/lib/popout/support', async (original) => ({
|
||||
...(await original<object>()),
|
||||
hasDesktopWindows: () => desktopWindows.on,
|
||||
popoutsAvailable: () => desktopWindows.on,
|
||||
}));
|
||||
vi.mock('@/lib/popout/open', async (original) => ({
|
||||
...(await original<object>()),
|
||||
openComposerWindow: (account?: string | null) => {
|
||||
desktopWindows.open(account);
|
||||
return { handOver: desktopWindows.handOver, cancel: vi.fn() };
|
||||
},
|
||||
}));
|
||||
|
||||
const { MailApp } = await import('../mail-app');
|
||||
|
||||
@@ -164,3 +178,52 @@ describe('onMailtoIntercept', () => {
|
||||
expect(screen.queryByText('stub composer')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a mail link from another program in the desktop app', () => {
|
||||
beforeEach(() => {
|
||||
clearAllHooks();
|
||||
composerProps.last = null;
|
||||
desktopWindows.on = true;
|
||||
desktopWindows.open.mockClear();
|
||||
desktopWindows.handOver.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
desktopWindows.on = false;
|
||||
});
|
||||
|
||||
const fromOs = async () => {
|
||||
const { savePendingMailto, notifyPendingMailto } = await import('@/lib/protocol-handlers/session');
|
||||
act(() => {
|
||||
savePendingMailto({ to: ['bob@example.com'], cc: [], bcc: [], subject: 'Hi', body: 'Text', fromOs: true });
|
||||
notifyPendingMailto();
|
||||
});
|
||||
};
|
||||
|
||||
it('opens in a compose window of its own, not in the main window', async () => {
|
||||
render(<MailApp />);
|
||||
await fromOs();
|
||||
await waitFor(() => expect(desktopWindows.handOver).toHaveBeenCalledTimes(1));
|
||||
expect(desktopWindows.handOver).toHaveBeenCalledWith(expect.objectContaining({
|
||||
mode: 'compose',
|
||||
initialData: expect.objectContaining({ to: 'bob@example.com', subject: 'Hi' }),
|
||||
}));
|
||||
await act(() => new Promise((r) => setTimeout(r, 50)));
|
||||
expect(screen.queryByText('stub composer')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('a link inside the app still opens here', async () => {
|
||||
render(<MailApp />);
|
||||
act(() => { requestInternalMailto('mailto:bob@example.com?subject=Hi'); });
|
||||
await screen.findByText('stub composer', {}, { timeout: 5000 });
|
||||
expect(desktopWindows.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a plugin that says no keeps both closed', async () => {
|
||||
emailHooks.onBeforeCompose.register('test', () => false);
|
||||
render(<MailApp />);
|
||||
await fromOs();
|
||||
await act(() => new Promise((r) => setTimeout(r, 100)));
|
||||
expect(desktopWindows.open).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText('stub composer')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,7 +65,8 @@ import { useIsFocusedProTab, usePaneId } from "@/hooks/use-pane-context";
|
||||
import { useKeyboardShortcuts, type GoToMailboxTarget } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useDesktopCommands } from "@/lib/desktop/commands";
|
||||
import { useDesktopPrefs } from "@/lib/desktop/preferences";
|
||||
import { hasDesktopWindows } from "@/lib/popout/support";
|
||||
import { hasDesktopWindows, popoutsAvailable } from "@/lib/popout/support";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import { composerDataFromDraft } from "@/lib/draft-composer-data";
|
||||
import { useUndoShortcut } from "@/hooks/use-undo-shortcut";
|
||||
import { confirmMailRemoval } from "@/lib/mail-removal-confirm";
|
||||
@@ -104,7 +105,8 @@ import type { Attachment, SendEmailResult } from "@/lib/jmap/types";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import { peekListAttachments, requestListAttachments, type ListAttachmentSource, type LoadListAttachments } from "@/lib/list-attachments";
|
||||
import { useSearchHistoryStore } from "@/stores/search-history-store";
|
||||
import { WelcomeBanner } from "@/components/ui/welcome-banner";
|
||||
import { useWelcomeBannerDue, WelcomeBanner } from "@/components/ui/welcome-banner";
|
||||
import { FirstStartNoticeBar } from "@/components/ui/first-start-notice";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { AppMain } from "@/components/layout/app-landmarks";
|
||||
import { MvTopBar } from "@/components/layout/mv-top-bar";
|
||||
@@ -253,6 +255,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// Static Lite build: the route params are empty, read the link from the URL.
|
||||
const linkSegments = useLiteLinkSegments('mail', routeSegments);
|
||||
const t = useTranslations();
|
||||
// First-start notices wait until the welcome tips are closed: one thing at a time.
|
||||
const welcomeDue = useWelcomeBannerDue();
|
||||
// The page skeleton the gates below draw: the same one the server sent.
|
||||
const skeletonLabels = useSkeletonLabels();
|
||||
const tNotifications = useTranslations('notifications');
|
||||
@@ -1645,7 +1649,33 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
if (isMobile) setActiveView("viewer");
|
||||
}, [composerSessionId, isMobile, setActiveView]);
|
||||
|
||||
const openMailtoDraft = useCallback((pending: DraftRequest) => withComposeGate('compose', {
|
||||
// A mail link from another program, in the desktop app: a compose window of its own,
|
||||
// as Outlook and Thunderbird do, and the main window stays where it was. Plugins get
|
||||
// their say first. False when it has to open here instead.
|
||||
const openMailtoInWindow = useCallback(async (pending: DraftRequest): Promise<boolean> => {
|
||||
if (!pending.fromOs || pending.initialFiles?.length || !hasDesktopWindows() || !popoutsAvailable()) return false;
|
||||
let prefill: ComposePrefill | null = null;
|
||||
if (emailHooks.onBeforeCompose.size > 0) {
|
||||
prefill = await runBeforeCompose(composeOptionsFor('compose', {
|
||||
to: pending.to, cc: pending.cc, subject: pending.subject, body: pending.body,
|
||||
ownEmails: identities.map((i) => i.email).filter((e): e is string => Boolean(e)),
|
||||
replyPrefix: t('email_composer.prefix.reply'),
|
||||
forwardPrefix: t('email_composer.prefix.forward'),
|
||||
}));
|
||||
// A plugin said no: nothing opens, here or there.
|
||||
if (!prefill) return true;
|
||||
}
|
||||
const opened = openComposerWindow(useAuthStore.getState().activeAccountId);
|
||||
if (!opened) return false;
|
||||
void opened.handOver({
|
||||
mode: 'compose',
|
||||
initialData: mailtoComposerDraft(pending, useSettingsStore.getState().plainTextMode),
|
||||
prefill: prefill && Object.keys(prefill).length > 0 ? prefill : null,
|
||||
});
|
||||
return true;
|
||||
}, [identities, t]);
|
||||
|
||||
const openMailtoHere = useCallback((pending: DraftRequest) => withComposeGate('compose', {
|
||||
to: pending.to, cc: pending.cc, subject: pending.subject, body: pending.body,
|
||||
}, () => {
|
||||
// A live composer owns text the user has not sent. Replacing it outright
|
||||
@@ -1664,6 +1694,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
applyMailtoDraft(pending, showComposer);
|
||||
}), [applyMailtoDraft, showComposer, withComposeGate]);
|
||||
|
||||
const openMailtoDraft = useCallback(async (pending: DraftRequest): Promise<boolean> => {
|
||||
if (await openMailtoInWindow(pending)) return true;
|
||||
// Opening here after all: the shell left this window where it was.
|
||||
if (pending.fromOs) getDesktopBridge()?.windows?.focus?.();
|
||||
return openMailtoHere(pending);
|
||||
}, [openMailtoInWindow, openMailtoHere]);
|
||||
|
||||
const openMailtoForAccount = useCallback(async (pending: DraftRequest, accountId: string) => {
|
||||
setIsProtocolAccountSwitching(true);
|
||||
try {
|
||||
@@ -1692,6 +1729,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const routeOutsideDraft = useCallback(async (pending: DraftRequest): Promise<void> => {
|
||||
const protocolAccounts = getMailtoProtocolAccounts();
|
||||
if (protocolAccounts.length > 1) {
|
||||
// The question is asked in this window: bring it up.
|
||||
if (pending.fromOs) getDesktopBridge()?.windows?.focus?.();
|
||||
setPendingMailtoAccountChoice(pending);
|
||||
return;
|
||||
}
|
||||
@@ -5733,6 +5772,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
unless an enabled plugin registered tabs via api.tabs.set. */}
|
||||
{!isScheduledView && <MessageListTabs panelId={messageListPanelId} />}
|
||||
<WelcomeBanner />
|
||||
<FirstStartNoticeBar hidden={welcomeDue} />
|
||||
|
||||
<div {...messageListPanelProps} className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<ErrorBoundary fallback={EmailListErrorFallback}>
|
||||
|
||||
@@ -7,7 +7,9 @@ import { useOfflineStore, writeEnabled } from '@/lib/offline/state';
|
||||
import { passSignOutGates } from '@/lib/sign-out-cleanup';
|
||||
import { setStoreBackend } from '@/lib/store/client/api';
|
||||
import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
|
||||
import { wipeEverything } from '@/lib/offline/offline-mode';
|
||||
import { offlineNotify, wipeEverything } from '@/lib/offline/offline-mode';
|
||||
import { firstStartNoticesWaiting, resetFirstStartNotices } from '@/lib/first-start-notices';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
|
||||
let store: InProcessStore;
|
||||
@@ -53,3 +55,15 @@ describe('the question before a sign-out', () => {
|
||||
expect(screen.queryByRole('button', { name: /signout\.confirm/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the notice that the desktop app keeps mail on this device', () => {
|
||||
it('is a first-start notice above the list, not a toast that covers the composer', () => {
|
||||
resetFirstStartNotices();
|
||||
useToastStore.setState({ toasts: [] });
|
||||
render(<OfflineHost />);
|
||||
offlineNotify({ kind: 'default-on' });
|
||||
expect(useToastStore.getState().toasts).toHaveLength(0);
|
||||
expect(firstStartNoticesWaiting().map((n) => n.text)).toEqual(['default_on']);
|
||||
expect(firstStartNoticesWaiting()[0].action?.label).toBe('open_settings');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { addSignOutGate } from '@/lib/sign-out-cleanup';
|
||||
import { reloadToNewBuild } from '@/lib/app-update';
|
||||
import { buildSettingsPath } from '@/lib/deep-links';
|
||||
import { confirmLoss, setOfflineNotifier, startOfflineMode, unsentCount, type OfflineNotice } from '@/lib/offline/offline-mode';
|
||||
import { showFirstStartNotice } from '@/lib/first-start-notices';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
|
||||
/**
|
||||
@@ -77,7 +78,8 @@ export function OfflineHost() {
|
||||
toast.warning(tStorageRef.current('storage_full_title'), { message: tStorageRef.current('storage_full_text'), duration: 0, action: openStorage });
|
||||
break;
|
||||
case 'default-on':
|
||||
toast.info(tStorageRef.current('default_on'), { duration: 0, action: openStorage });
|
||||
// A first-start notice, not a toast: it stays until closed, and covers nothing.
|
||||
showFirstStartNotice({ id: 'offline-default-on', text: tStorageRef.current('default_on'), action: openStorage });
|
||||
break;
|
||||
case 'legacy-stranded':
|
||||
toast.warning(tConflictsRef.current('legacy_stranded'), { duration: 0 });
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { BulwarkDesktopBridge } from '@/lib/desktop/types';
|
||||
import en from '@/locales/en/common.json';
|
||||
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Bulwark' }) }));
|
||||
|
||||
const { ProtocolHandlerSettings } = await import('../protocol-handler-settings');
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.bulwarkDesktop = undefined;
|
||||
});
|
||||
|
||||
describe('Settings > General in the desktop app', () => {
|
||||
it('a browser keeps its "Default apps" section', () => {
|
||||
render(<ProtocolHandlerSettings supportsCalendar />);
|
||||
expect(screen.getByText('register_mailto')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('the desktop app shows only its own "Default mail app" row, not the browser\'s handlers', () => {
|
||||
window.bulwarkDesktop = { version: 1, platform: 'win32' } as unknown as BulwarkDesktopBridge;
|
||||
render(<ProtocolHandlerSettings supportsCalendar />);
|
||||
expect(screen.queryByText('register_mailto')).toBeNull();
|
||||
expect(screen.queryByText('title')).toBeNull();
|
||||
});
|
||||
|
||||
it('the section about the app\'s windows isn\'t called "Windows", which reads as the OS', () => {
|
||||
expect(en.desktopUi.settings.title).not.toBe('Windows');
|
||||
expect(en.desktopUi.settings.title).toBe('App windows');
|
||||
});
|
||||
});
|
||||
@@ -16,10 +16,12 @@ function shortcutLabel(platform: string | undefined): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* The desktop app's own settings, on the General page: only where there's an app to
|
||||
* set. A browser never sees it, and it renders nothing until the bridge has answered.
|
||||
* The desktop app's own settings: only where there's an app to set. A browser never sees
|
||||
* them, and they render nothing until the bridge has answered. The General page has how
|
||||
* the app runs; the Notifications page has the taskbar flash and quiet hours, next to
|
||||
* the rest of what notifies.
|
||||
*/
|
||||
export function DesktopOsSettings() {
|
||||
export function DesktopOsSettings({ part = "app" }: { part?: "app" | "notifications" } = {}) {
|
||||
const t = useTranslations("desktopOs");
|
||||
const [status, setStatus] = useState<DesktopOsStatus | null>(null);
|
||||
const [defaultApp, setDefaultApp] = useState<DesktopOsDefaultAppStatus | null>(null);
|
||||
@@ -48,6 +50,43 @@ export function DesktopOsSettings() {
|
||||
void os.setSettings(patch).then(setStatus);
|
||||
};
|
||||
|
||||
if (part === "notifications") {
|
||||
return (
|
||||
<SettingsSection id="desktop-notifications" title={t("section_title")}>
|
||||
<SettingItem
|
||||
label={bridge?.platform === "darwin" ? t("attention_mac_label") : t("attention_label")}
|
||||
description={t("attention_description")}
|
||||
>
|
||||
<ToggleSwitch checked={settings.attentionOnNewMail} onChange={(attentionOnNewMail) => change({ attentionOnNewMail })} />
|
||||
</SettingItem>
|
||||
<SettingItem label={t("quiet_hours_label")} description={t("quiet_hours_description")}>
|
||||
<ToggleSwitch checked={settings.quietHoursEnabled} onChange={(quietHoursEnabled) => change({ quietHoursEnabled })} />
|
||||
</SettingItem>
|
||||
{settings.quietHoursEnabled && (
|
||||
<SettingItem nested label={t("quiet_hours_from")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="time"
|
||||
aria-label={t("quiet_hours_from")}
|
||||
value={settings.quietHoursStart}
|
||||
onChange={(event) => change({ quietHoursStart: event.target.value })}
|
||||
className="rounded-md border border-border bg-background px-2 py-1 text-sm"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{t("quiet_hours_to")}</span>
|
||||
<input
|
||||
type="time"
|
||||
aria-label={t("quiet_hours_to")}
|
||||
value={settings.quietHoursEnd}
|
||||
onChange={(event) => change({ quietHoursEnd: event.target.value })}
|
||||
className="rounded-md border border-border bg-background px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection id="desktop-app" title={t("section_title")} description={t("section_description")}>
|
||||
<SettingItem label={t("close_to_tray_label")} description={t("close_to_tray_description")}>
|
||||
@@ -61,36 +100,6 @@ export function DesktopOsSettings() {
|
||||
<ToggleSwitch checked={settings.startMinimized} onChange={(startMinimized) => change({ startMinimized })} />
|
||||
</SettingItem>
|
||||
)}
|
||||
<SettingItem
|
||||
label={bridge?.platform === "darwin" ? t("attention_mac_label") : t("attention_label")}
|
||||
description={t("attention_description")}
|
||||
>
|
||||
<ToggleSwitch checked={settings.attentionOnNewMail} onChange={(attentionOnNewMail) => change({ attentionOnNewMail })} />
|
||||
</SettingItem>
|
||||
<SettingItem label={t("quiet_hours_label")} description={t("quiet_hours_description")}>
|
||||
<ToggleSwitch checked={settings.quietHoursEnabled} onChange={(quietHoursEnabled) => change({ quietHoursEnabled })} />
|
||||
</SettingItem>
|
||||
{settings.quietHoursEnabled && (
|
||||
<SettingItem nested label={t("quiet_hours_from")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="time"
|
||||
aria-label={t("quiet_hours_from")}
|
||||
value={settings.quietHoursStart}
|
||||
onChange={(event) => change({ quietHoursStart: event.target.value })}
|
||||
className="rounded-md border border-border bg-background px-2 py-1 text-sm"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{t("quiet_hours_to")}</span>
|
||||
<input
|
||||
type="time"
|
||||
aria-label={t("quiet_hours_to")}
|
||||
value={settings.quietHoursEnd}
|
||||
onChange={(event) => change({ quietHoursEnd: event.target.value })}
|
||||
className="rounded-md border border-border bg-background px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
)}
|
||||
<SettingItem
|
||||
label={t("show_shortcut_label")}
|
||||
description={settings.showShortcut && !status.shortcutActive
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
|
||||
import { DesktopOsSettings } from './desktop-os-settings';
|
||||
import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound';
|
||||
import type { NotificationSoundChoice } from '@/lib/notification-sound';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -409,6 +410,9 @@ export function NotificationSettings() {
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
{/* The desktop app's taskbar flash and quiet hours. Nothing in a browser. */}
|
||||
<DesktopOsSettings part="notifications" />
|
||||
|
||||
<SettingsSection id="push" title={t('push.title')} description={t('push.description')} term="push">
|
||||
<SettingItem label={t('push.enable')} description={statusDescription}>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import { productName } from "@/lib/product-name";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import type { ProtocolOpenMode } from "@/stores/settings-store";
|
||||
@@ -41,9 +42,13 @@ export function ProtocolHandlerSettings({ supportsCalendar }: ProtocolHandlerSet
|
||||
const protocolOpenMode = useSettingsStore((state) => state.protocolOpenMode);
|
||||
const updateSetting = useSettingsStore((state) => state.updateSetting);
|
||||
const [supported, setSupported] = useState(false);
|
||||
// The desktop app is a mail app of its own: its "Default mail app" row (Desktop app
|
||||
// section) is the one that counts, and a browser's handler means nothing there.
|
||||
const [desktop, setDesktop] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSupported(canRegisterProtocolHandler());
|
||||
setDesktop(!!getDesktopBridge());
|
||||
}, []);
|
||||
|
||||
const handleOpenModeChange = async (value: string) => {
|
||||
@@ -76,6 +81,8 @@ export function ProtocolHandlerSettings({ supportsCalendar }: ProtocolHandlerSet
|
||||
);
|
||||
};
|
||||
|
||||
if (desktop) return null;
|
||||
|
||||
return (
|
||||
<SettingsSection id="links" title={t("title")} description={t("description", { appName })}>
|
||||
{!supported && (
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FirstStartNoticeBar } from '../first-start-notice';
|
||||
import { resetFirstStartNotices, showFirstStartNotice } from '@/lib/first-start-notices';
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
resetFirstStartNotices();
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('first-start notices', () => {
|
||||
it('show one at a time, each with a close button, the next once the first is closed', () => {
|
||||
render(<FirstStartNoticeBar />);
|
||||
act(() => {
|
||||
showFirstStartNotice({ id: 'one', text: 'Mail is kept on this device.' });
|
||||
showFirstStartNotice({ id: 'two', text: 'Spelling needs a dictionary.' });
|
||||
});
|
||||
expect(screen.getByText('Mail is kept on this device.')).toBeTruthy();
|
||||
expect(screen.queryByText('Spelling needs a dictionary.')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'close' }));
|
||||
expect(screen.queryByText('Mail is kept on this device.')).toBeNull();
|
||||
expect(screen.getByText('Spelling needs a dictionary.')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('once closed, never again on this device', () => {
|
||||
const onClose = vi.fn();
|
||||
const view = render(<FirstStartNoticeBar />);
|
||||
act(() => showFirstStartNotice({ id: 'kept', text: 'Mail is kept on this device.', onClose }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'close' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
view.unmount();
|
||||
resetFirstStartNotices();
|
||||
render(<FirstStartNoticeBar />);
|
||||
act(() => showFirstStartNotice({ id: 'kept', text: 'Mail is kept on this device.' }));
|
||||
expect(screen.queryByText('Mail is kept on this device.')).toBeNull();
|
||||
});
|
||||
|
||||
it('its button does its job and closes it', () => {
|
||||
const open = vi.fn();
|
||||
render(<FirstStartNoticeBar />);
|
||||
act(() => showFirstStartNotice({ id: 'act', text: 'Mail is kept on this device.', action: { label: 'Open settings', onClick: open } }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open settings' }));
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText('Mail is kept on this device.')).toBeNull();
|
||||
});
|
||||
|
||||
it('waits while something else greets the user, and the same notice isn\'t queued twice', () => {
|
||||
const view = render(<FirstStartNoticeBar hidden />);
|
||||
act(() => {
|
||||
showFirstStartNotice({ id: 'wait', text: 'Mail is kept on this device.' });
|
||||
showFirstStartNotice({ id: 'wait', text: 'Mail is kept on this device.' });
|
||||
});
|
||||
expect(screen.queryByText('Mail is kept on this device.')).toBeNull();
|
||||
view.rerender(<FirstStartNoticeBar />);
|
||||
expect(screen.getAllByText('Mail is kept on this device.')).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'close' }));
|
||||
expect(screen.queryByText('Mail is kept on this device.')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Info, X } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { closeFirstStartNotice, useCurrentFirstStartNotice } from "@/lib/first-start-notices";
|
||||
|
||||
/**
|
||||
* The first-start notice that's waiting, as a bar above the message list (see
|
||||
* lib/first-start-notices). In the page's flow, it covers nothing. `hidden` while
|
||||
* something else greets the user there, so only one shows at a time.
|
||||
*/
|
||||
export function FirstStartNoticeBar({ hidden = false }: { hidden?: boolean }) {
|
||||
const tCommon = useTranslations("common");
|
||||
const notice = useCurrentFirstStartNotice();
|
||||
if (!notice || hidden) return null;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
data-first-start-notice={notice.id}
|
||||
className="flex items-start gap-3 border-b border-border bg-accent/30 px-4 py-2.5 text-sm"
|
||||
>
|
||||
<Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<p className="min-w-0 flex-1 text-foreground">{notice.text}</p>
|
||||
{notice.action && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="flex-shrink-0 text-xs"
|
||||
onClick={() => {
|
||||
const action = notice.action!;
|
||||
closeFirstStartNotice(notice.id);
|
||||
action.onClick();
|
||||
}}
|
||||
>
|
||||
{notice.action.label}
|
||||
</Button>
|
||||
)}
|
||||
<IconButton label={tCommon("close")} onClick={() => closeFirstStartNotice(notice.id)} className="-my-1 flex-shrink-0 text-muted-foreground">
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,25 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
|
||||
const ONBOARDING_KEY = "onboarding_completed";
|
||||
|
||||
/** Whether the welcome tips are due on this device. Read after mount: localStorage. */
|
||||
export function useWelcomeBannerDue(): boolean {
|
||||
const onboardingCompleted = useSettingsStore((s) => s.onboardingCompleted);
|
||||
const showOnboardingOnNewDevices = useSettingsStore((s) => s.showOnboardingOnNewDevices);
|
||||
const [due, setDue] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!onboardingCompleted) {
|
||||
setDue(true);
|
||||
return;
|
||||
}
|
||||
let seenHere = true;
|
||||
try {
|
||||
seenHere = localStorage.getItem(ONBOARDING_KEY) === "true";
|
||||
} catch { /* localStorage unavailable */ }
|
||||
setDue(showOnboardingOnNewDevices && !seenHere);
|
||||
}, [onboardingCompleted, showOnboardingOnNewDevices]);
|
||||
return due;
|
||||
}
|
||||
|
||||
export function WelcomeBanner() {
|
||||
const t = useTranslations("welcome");
|
||||
const router = useRouter();
|
||||
|
||||
@@ -81,12 +81,6 @@
|
||||
<enabledValue><decimal value="1"/></enabledValue>
|
||||
<disabledValue><decimal value="0"/></disabledValue>
|
||||
</policy>
|
||||
<policy name="AutoUpdate_Recommended" class="Both" displayName="$(string.AutoUpdate)" explainText="$(string.AutoUpdate_Explain)" key="Software\Policies\Bulwark\Desktop\Recommended" valueName="AutoUpdate">
|
||||
<parentCategory ref="Bulwark_Desktop_Recommended"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
<enabledValue><decimal value="1"/></enabledValue>
|
||||
<disabledValue><decimal value="0"/></disabledValue>
|
||||
</policy>
|
||||
<policy name="PluginsMode" class="Both" displayName="$(string.PluginsMode)" explainText="$(string.PluginsMode_Explain)" presentation="$(presentation.PluginsMode)" key="Software\Policies\Bulwark\Desktop">
|
||||
<parentCategory ref="Bulwark_Desktop_plugins"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
@@ -123,12 +117,6 @@
|
||||
<enabledValue><decimal value="1"/></enabledValue>
|
||||
<disabledValue><decimal value="0"/></disabledValue>
|
||||
</policy>
|
||||
<policy name="AppLockRequired_Recommended" class="Both" displayName="$(string.AppLockRequired)" explainText="$(string.AppLockRequired_Explain)" key="Software\Policies\Bulwark\Desktop\Recommended" valueName="AppLockRequired">
|
||||
<parentCategory ref="Bulwark_Desktop_Recommended"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
<enabledValue><decimal value="1"/></enabledValue>
|
||||
<disabledValue><decimal value="0"/></disabledValue>
|
||||
</policy>
|
||||
<policy name="AppLockTimeoutMinutes" class="Both" displayName="$(string.AppLockTimeoutMinutes)" explainText="$(string.AppLockTimeoutMinutes_Explain)" presentation="$(presentation.AppLockTimeoutMinutes)" key="Software\Policies\Bulwark\Desktop">
|
||||
<parentCategory ref="Bulwark_Desktop_security"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
@@ -149,24 +137,12 @@
|
||||
<enabledValue><decimal value="1"/></enabledValue>
|
||||
<disabledValue><decimal value="0"/></disabledValue>
|
||||
</policy>
|
||||
<policy name="TelemetryAllowed_Recommended" class="Both" displayName="$(string.TelemetryAllowed)" explainText="$(string.TelemetryAllowed_Explain)" key="Software\Policies\Bulwark\Desktop\Recommended" valueName="TelemetryAllowed">
|
||||
<parentCategory ref="Bulwark_Desktop_Recommended"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
<enabledValue><decimal value="1"/></enabledValue>
|
||||
<disabledValue><decimal value="0"/></disabledValue>
|
||||
</policy>
|
||||
<policy name="CrashReportsAllowed" class="Both" displayName="$(string.CrashReportsAllowed)" explainText="$(string.CrashReportsAllowed_Explain)" key="Software\Policies\Bulwark\Desktop" valueName="CrashReportsAllowed">
|
||||
<parentCategory ref="Bulwark_Desktop_privacy"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
<enabledValue><decimal value="1"/></enabledValue>
|
||||
<disabledValue><decimal value="0"/></disabledValue>
|
||||
</policy>
|
||||
<policy name="CrashReportsAllowed_Recommended" class="Both" displayName="$(string.CrashReportsAllowed)" explainText="$(string.CrashReportsAllowed_Explain)" key="Software\Policies\Bulwark\Desktop\Recommended" valueName="CrashReportsAllowed">
|
||||
<parentCategory ref="Bulwark_Desktop_Recommended"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
<enabledValue><decimal value="1"/></enabledValue>
|
||||
<disabledValue><decimal value="0"/></disabledValue>
|
||||
</policy>
|
||||
<policy name="QuickComposeShortcut" class="Both" displayName="$(string.QuickComposeShortcut)" explainText="$(string.QuickComposeShortcut_Explain)" presentation="$(presentation.QuickComposeShortcut)" key="Software\Policies\Bulwark\Desktop">
|
||||
<parentCategory ref="Bulwark_Desktop_security"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
<string id="BrowserSignIn">Anmeldung über den Browser anbieten</string>
|
||||
<string id="BrowserSignIn_Explain">Wenn aktiviert oder nicht konfiguriert, bietet die Anmeldeseite „Mit Ihrem Browser anmelden“ an, damit Benutzer dort ihren Passwort-Manager, Passkeys und Ihr Single Sign-on verwenden können. Wenn deaktiviert, fehlt die Schaltfläche und die App lehnt den Vorgang ab. Das Formular in der App bleibt, sofern der Server nicht nur OAuth erlaubt.</string>
|
||||
<string id="WipeOfflineData">Auf dem Gerät behaltene E-Mails löschen</string>
|
||||
<string id="WipeOfflineData_Explain">Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alles zu löschen, was die App für alle Server und Konten auf dem Gerät behält: E-Mails, Schlüssel und Anmeldungen, Benutzer melden sich also neu an. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen.</string>
|
||||
<string id="WipeOfflineData_Explain">Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alles zu löschen, was die App für alle Server und Konten auf dem Gerät behält: E-Mails, Schlüssel und Anmeldungen, Benutzer melden sich also neu an. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen. Diese Liste liegt im Profil des Benutzers. Wer sie bearbeitet, kann also eine Löschung umgehen, von der er weiß: Dies räumt ein Gerät auf, es nimmt der Person am Gerät keine Daten weg.</string>
|
||||
</stringTable>
|
||||
<presentationTable>
|
||||
<presentation id="InstanceUrl"><textBox refId="InstanceUrl_Value"><label>Adresse des Bulwark-Servers</label></textBox></presentation>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
<string id="BrowserSignIn">Offer sign-in through the browser</string>
|
||||
<string id="BrowserSignIn_Explain">When enabled or not configured, the login page offers "Sign in with your browser", so people can use their password manager, passkeys and your single sign-on there. When disabled, the button is gone and the app refuses the flow. The form in the app stays, unless the server only allows OAuth.</string>
|
||||
<string id="WipeOfflineData">Delete mail kept on the device</string>
|
||||
<string id="WipeOfflineData_Explain">Set this to a new value, such as today's date, to delete everything the app keeps on the device for every server and account: mail, keys, and sign-ins, so people sign in again. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again.</string>
|
||||
<string id="WipeOfflineData_Explain">Set this to a new value, such as today's date, to delete everything the app keeps on the device for every server and account: mail, keys, and sign-ins, so people sign in again. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again. That memory is in the user's own profile, so someone who edits it can skip a wipe they know is coming: this cleans up a device, it doesn't take data from the person using it.</string>
|
||||
</stringTable>
|
||||
<presentationTable>
|
||||
<presentation id="InstanceUrl"><textBox refId="InstanceUrl_Value"><label>Address of the Bulwark server</label></textBox></presentation>
|
||||
|
||||
@@ -896,6 +896,9 @@ describe('policy', () => {
|
||||
it('a forced timeout wins over the user\'s choice, a recommended one only over no choice', () => {
|
||||
withPolicy({ value: false, locked: false }, { value: 3, locked: true });
|
||||
expect(effectiveTimeout(60)).toEqual({ minutes: 3, locked: true });
|
||||
// A value nobody could read: the user's choice was never what the admin meant.
|
||||
(window.bulwarkDesktop as unknown as { policy: { get(): unknown } }).policy.get = () => ({ AppLockTimeoutMinutes: { value: null, locked: true, source: 'forced-machine', invalid: 'out_of_range' } });
|
||||
expect(effectiveTimeout(60)).toEqual({ minutes: 1, locked: true });
|
||||
withPolicy({ value: false, locked: false }, { value: 3, locked: false });
|
||||
expect(effectiveTimeout(60)).toEqual({ minutes: 60, locked: false });
|
||||
expect(effectiveTimeout(undefined)).toEqual({ minutes: 3, locked: false });
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
*/
|
||||
|
||||
import { getDesktopBridge } from '@/lib/desktop/bridge';
|
||||
import { POLICY_SCHEMA } from '@/lib/desktop/policy-schema';
|
||||
import { DEFAULT_TIMEOUT_MINUTES } from './state';
|
||||
|
||||
const timeoutDefinition = POLICY_SCHEMA.AppLockTimeoutMinutes;
|
||||
const SHORTEST_MINUTES = timeoutDefinition.type === 'integer' ? timeoutDefinition.min : 1;
|
||||
|
||||
interface PolicyEntry<T> {
|
||||
value: T;
|
||||
locked: boolean;
|
||||
@@ -51,7 +55,9 @@ export interface EffectiveTimeout {
|
||||
export function effectiveTimeout(choice: number | undefined): EffectiveTimeout {
|
||||
const entry = read().AppLockTimeoutMinutes;
|
||||
const value = typeof entry?.value === 'number' && entry.value > 0 ? entry.value : null;
|
||||
if (entry?.locked && value !== null) return { minutes: value, locked: true };
|
||||
// Forced to something nobody can read, by a shell that hands that on as nothing: the
|
||||
// tightest there is, since the user's choice was never meant to apply.
|
||||
if (entry?.locked) return { minutes: value ?? SHORTEST_MINUTES, locked: true };
|
||||
return { minutes: choice ?? value ?? DEFAULT_TIMEOUT_MINUTES, locked: false };
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,18 @@ describe('launches', () => {
|
||||
expect(push).toHaveBeenCalledWith('/');
|
||||
});
|
||||
|
||||
it('a mailto from the OS is marked so, opens in its own window, and leaves the main window be', async () => {
|
||||
await run({ type: 'mailto', url: 'mailto:bob@example.org' });
|
||||
expect(session.savePendingMailto.mock.calls[0][0]).toMatchObject({ fromOs: true });
|
||||
expect(focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('signed out, the main window comes up for the sign-in', async () => {
|
||||
authenticated = false;
|
||||
await run({ type: 'mailto', url: 'mailto:bob@example.org' });
|
||||
expect(focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a mailto that is not one does nothing', async () => {
|
||||
await run({ type: 'mailto', url: 'mailto:' + '%'.repeat(3) });
|
||||
expect(push).not.toHaveBeenCalledWith('/calendar');
|
||||
|
||||
@@ -490,6 +490,18 @@ describe('the summary', () => {
|
||||
expect(summary.unread).toEqual([expect.objectContaining({ emailId: 'e1', accountId: 'acc', sender: 'Alice', subject: 'Lunch?' })]);
|
||||
expect(JSON.parse(summary.unread[0].context!)).toMatchObject({ emailId: 'e1', slot: 3 });
|
||||
expect(getEmails).toHaveBeenCalledWith('in', undefined, 5, 0, undefined, false, { notKeyword: '$seen' });
|
||||
// One account: no need to say whose.
|
||||
expect(summary.account).toBeUndefined();
|
||||
});
|
||||
|
||||
it('with two accounts it names the one the unread mail is from', async () => {
|
||||
const { useAccountStore } = await import('@/stores/account-store');
|
||||
useAccountStore.setState({ accounts: [
|
||||
{ id: 'alice@one', cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org' },
|
||||
{ id: 'bob@two', cookieSlot: 4, serverUrl: 'https://two.example', username: 'bob@example.org' },
|
||||
] as never });
|
||||
const { readSummary } = await import('../os-summary');
|
||||
expect((await readSummary('en')).account).toBe('alice@example.org');
|
||||
});
|
||||
|
||||
it('before the policy is known it is empty', async () => {
|
||||
@@ -590,7 +602,24 @@ describe('the settings section', () => {
|
||||
it('macOS gets its own word for the bounce', async () => {
|
||||
const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings');
|
||||
setBridge(bridgeFor(status()) as never, 'darwin');
|
||||
render(<DesktopOsSettings />);
|
||||
render(<DesktopOsSettings part="notifications" />);
|
||||
expect(await screen.findByText('attention_mac_label')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('the taskbar flash and quiet hours are with the notifications, not on General', async () => {
|
||||
const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings');
|
||||
const os = bridgeFor(status());
|
||||
setBridge(os as never);
|
||||
const general = render(<DesktopOsSettings />);
|
||||
expect(await screen.findByText('close_to_tray_label')).toBeInTheDocument();
|
||||
expect(screen.queryByText('attention_label')).toBeNull();
|
||||
expect(screen.queryByText('quiet_hours_label')).toBeNull();
|
||||
general.unmount();
|
||||
render(<DesktopOsSettings part="notifications" />);
|
||||
expect(await screen.findByText('quiet_hours_label')).toBeInTheDocument();
|
||||
expect(screen.getByText('attention_label')).toBeInTheDocument();
|
||||
expect(screen.queryByText('close_to_tray_label')).toBeNull();
|
||||
fireEvent.click(screen.getAllByRole('switch')[1]);
|
||||
await waitFor(() => expect(os.setSettings).toHaveBeenCalledWith({ quietHoursEnabled: true }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,6 +116,13 @@ describe('in the app', () => {
|
||||
expect(devicesForAccount('some-account').noOffline).toBe(false);
|
||||
}, 60_000);
|
||||
|
||||
it('a forced value that makes no sense keeps offline off without forbidding it, which would wipe', async () => {
|
||||
setBridgePolicy(snapshotOf({ OfflineEnabled: 'yes' }));
|
||||
expect(desktopOfflinePolicy()).toEqual({ forced: false, fallback: null, invalid: true });
|
||||
const { devicesForAccount } = await import('@/lib/device-policy');
|
||||
expect(devicesForAccount('some-account').noOffline).toBe(false);
|
||||
}, 60_000);
|
||||
|
||||
it('the hook settles on a bridge that copies what it returns, as contextBridge does', async () => {
|
||||
setBridgePolicy(snapshotOf({ PluginsMode: 'off' }));
|
||||
window.bulwarkDesktop!.policy!.get = () => JSON.parse(JSON.stringify(current));
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DEFAULT_POLICY_SNAPSHOT,
|
||||
EMPTY_POLICY_LAYERS,
|
||||
POLICY_KEYS,
|
||||
policyProblems,
|
||||
POLICY_CONSUMERS,
|
||||
POLICY_MAX_LIST_ITEMS,
|
||||
POLICY_SCHEMA,
|
||||
@@ -133,9 +134,9 @@ describe('hostile values fail closed', () => {
|
||||
expect(entry.raw!.length).toBeLessThanOrEqual(201);
|
||||
});
|
||||
|
||||
it('an out of range timeout falls to no value, not to a huge one', () => {
|
||||
it('an out of range timeout falls to the shortest, not to a huge one or the user\'s own', () => {
|
||||
const entry = resolvePolicies(layers({ forcedMachine: { AppLockTimeoutMinutes: 999999 } })).AppLockTimeoutMinutes;
|
||||
expect(entry).toMatchObject({ value: null, invalid: 'out_of_range' });
|
||||
expect(entry).toMatchObject({ value: 1, invalid: 'out_of_range' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -322,6 +323,26 @@ describe('KeystoreAllowedOrigins', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('what a recommendation can do', () => {
|
||||
it.each(['AppLockRequired', 'TelemetryAllowed', 'CrashReportsAllowed', 'AutoUpdate'] as const)('%s: nothing, and the templates do not offer one', (key) => {
|
||||
expect(POLICY_SCHEMA[key].forcedOnly).toBe(true);
|
||||
expect(resolvePolicies({ ...EMPTY_POLICY_LAYERS, recommendedMachine: { [key]: 0 } })[key]).toMatchObject({ source: 'default', locked: false });
|
||||
expect(resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine: { [key]: 0 } })[key]).toMatchObject({ value: false, locked: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('values that fail open', () => {
|
||||
it('a lock timeout nobody can read is the shortest there is', () => {
|
||||
expect(resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine: { AppLockTimeoutMinutes: '99999' } }).AppLockTimeoutMinutes).toMatchObject({ value: 1, locked: true, invalid: 'out_of_range' });
|
||||
});
|
||||
|
||||
it('a wipe code too long to use is named as invalid', () => {
|
||||
const snapshot = resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine: { WipeOfflineData: 'w'.repeat(200) } });
|
||||
expect(snapshot.WipeOfflineData).toMatchObject({ value: null, invalid: 'too_long' });
|
||||
expect(policyProblems(snapshot).map((problem) => problem.key)).toEqual(['WipeOfflineData']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('every key has someone acting on it', () => {
|
||||
it('names a consumer or who owes one', () => {
|
||||
for (const key of POLICY_KEYS) {
|
||||
@@ -338,9 +359,7 @@ describe('every key has someone acting on it', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// The coordinator flips this when step 7 is done: then no key may be waiting.
|
||||
const ALL_WIRED = false;
|
||||
it.runIf(ALL_WIRED)('nobody is waiting any more', () => {
|
||||
it('nobody is waiting any more', () => {
|
||||
expect(POLICY_KEYS.filter((key) => 'pendingConsumer' in POLICY_CONSUMERS[key])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,7 +164,10 @@ export function handleOsCommand(command: DesktopOsCommand, env: OsCommandEnv): v
|
||||
case 'mailto': {
|
||||
const parsed = parseMailto(command.url);
|
||||
if (!parsed) return;
|
||||
savePendingMailto(parsed);
|
||||
// The shell leaves the main window where it is for a mail link. Signed out, the
|
||||
// sign-in has to show.
|
||||
if (!useAuthStore.getState().isAuthenticated) getDesktopBridge()?.windows?.focus?.();
|
||||
savePendingMailto({ ...parsed, fromOs: true });
|
||||
notifyPendingMailto();
|
||||
env.push('/');
|
||||
return;
|
||||
|
||||
@@ -81,7 +81,9 @@ export async function readSummary(locale: string | undefined, now = Date.now()):
|
||||
const next = nextEventOf(events, now);
|
||||
if (next) nextEvent = { title: next.title ?? '', when: formatEventTime(eventStart(next), now, locale) };
|
||||
}
|
||||
return { unread, recent, nextEvent };
|
||||
// The tray lists one account's inbox: with more than one, it says whose.
|
||||
const several = useAccountStore.getState().accounts.length > 1;
|
||||
return { unread, recent, nextEvent, ...(several && entry.username ? { account: entry.username } : {}) };
|
||||
}
|
||||
|
||||
/** Keeps main's copy current: when the unread count moves, and now and then for the next event. */
|
||||
|
||||
@@ -88,9 +88,10 @@ interface PolicyBase {
|
||||
title: PolicyText;
|
||||
description: PolicyText;
|
||||
/**
|
||||
* Only a forced value counts. Anything that decides what's allowed: it has no user
|
||||
* choice to sit under, so a "recommended" one would act as forced without being
|
||||
* shown as such, and only a forced value may loosen a forced one.
|
||||
* Only a forced value counts. Anything that decides what's allowed, or a yes or no with
|
||||
* nothing for a recommendation to set: it has no user choice to sit under, so a
|
||||
* "recommended" one would act as forced without being shown as such, or do nothing, and
|
||||
* only a forced value may loosen a forced one.
|
||||
*/
|
||||
forcedOnly?: boolean;
|
||||
}
|
||||
@@ -210,6 +211,7 @@ export const POLICY_SCHEMA: Record<PolicyKey, PolicyDefinition> = {
|
||||
},
|
||||
AutoUpdate: {
|
||||
category: 'updates',
|
||||
forcedOnly: true,
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
onInvalid: false,
|
||||
@@ -266,6 +268,7 @@ export const POLICY_SCHEMA: Record<PolicyKey, PolicyDefinition> = {
|
||||
},
|
||||
AppLockRequired: {
|
||||
category: 'security',
|
||||
forcedOnly: true,
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
onInvalid: true,
|
||||
@@ -292,6 +295,7 @@ export const POLICY_SCHEMA: Record<PolicyKey, PolicyDefinition> = {
|
||||
},
|
||||
TelemetryAllowed: {
|
||||
category: 'privacy',
|
||||
forcedOnly: true,
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
onInvalid: false,
|
||||
@@ -304,6 +308,7 @@ export const POLICY_SCHEMA: Record<PolicyKey, PolicyDefinition> = {
|
||||
},
|
||||
CrashReportsAllowed: {
|
||||
category: 'privacy',
|
||||
forcedOnly: true,
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
onInvalid: false,
|
||||
@@ -422,8 +427,8 @@ export const POLICY_SCHEMA: Record<PolicyKey, PolicyDefinition> = {
|
||||
label: { en: 'Wipe code', de: 'Löschcode' },
|
||||
title: { en: 'Delete mail kept on the device', de: 'Auf dem Gerät behaltene E-Mails löschen' },
|
||||
description: {
|
||||
en: 'Set this to a new value, such as today\'s date, to delete everything the app keeps on the device for every server and account: mail, keys, and sign-ins, so people sign in again. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again.',
|
||||
de: 'Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alles zu löschen, was die App für alle Server und Konten auf dem Gerät behält: E-Mails, Schlüssel und Anmeldungen, Benutzer melden sich also neu an. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen.',
|
||||
en: 'Set this to a new value, such as today\'s date, to delete everything the app keeps on the device for every server and account: mail, keys, and sign-ins, so people sign in again. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again. That memory is in the user\'s own profile, so someone who edits it can skip a wipe they know is coming: this cleans up a device, it doesn\'t take data from the person using it.',
|
||||
de: 'Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alles zu löschen, was die App für alle Server und Konten auf dem Gerät behält: E-Mails, Schlüssel und Anmeldungen, Benutzer melden sich also neu an. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen. Diese Liste liegt im Profil des Benutzers. Wer sie bearbeitet, kann also eine Löschung umgehen, von der er weiß: Dies räumt ein Gerät auf, es nimmt der Person am Gerät keine Daten weg.',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -587,6 +592,19 @@ function parseShortcut(raw: unknown): Parsed<string> {
|
||||
|
||||
const HOST_LIST = /^[A-Za-z0-9*.,:_[\]-]+$/;
|
||||
|
||||
const COMMON_TOP_LEVEL = /^(?:[a-z]{2}|com|net|org|edu|gov|mil|int|info|biz|name|pro|mobi|app|dev|cloud|online|site|xyz|top|shop|store|tech|club|live|blog|page|arpa)$/;
|
||||
|
||||
/**
|
||||
* Chromium reads a plain name in these lists as "ends with", so `com` means every .com
|
||||
* address, and `.com` is `*.com`. `intranet` or `sso` stay fine: that's how a server with
|
||||
* no domain is named.
|
||||
*/
|
||||
function coversTopLevelDomain(host: string): boolean {
|
||||
const name = host.replace(/:\d+$/, '').replace(/\.+$/, '').toLowerCase();
|
||||
if (name.startsWith('.')) return !/^\.+[^.]+\.[^.]/.test(name);
|
||||
return COMMON_TOP_LEVEL.test(name);
|
||||
}
|
||||
|
||||
/** What Chromium takes for its auth lists: names and wildcards, commas between them. */
|
||||
function parseHostList(raw: unknown): Parsed<string> {
|
||||
if (typeof raw !== 'string') return fail('not_text');
|
||||
@@ -599,6 +617,7 @@ function parseHostList(raw: unknown): Parsed<string> {
|
||||
// A wildcard must sit in front of a real domain of two labels or more: `*` or `*.com`
|
||||
// would hand Windows credentials to any site that asks.
|
||||
if (hosts.some((host) => host.includes('*') && !/^\*\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+(?::\d+)?$/.test(host))) return fail('invalid_host_list');
|
||||
if (hosts.some(coversTopLevelDomain)) return fail('invalid_host_list');
|
||||
return { ok: true, value: hosts.join(',') };
|
||||
}
|
||||
|
||||
@@ -643,12 +662,15 @@ export function parseCertificateRule(raw: unknown): CertificateRule | null {
|
||||
const match = /^(?:https:\/\/)?(\[\*\.\]|\*\.)?([^/*[\]]+(?::\d+)?)\/?$/i.exec(pattern);
|
||||
if (!match) return null;
|
||||
let host: string;
|
||||
let hostname: string;
|
||||
try {
|
||||
host = new URL(`https://${match[2]}`).host;
|
||||
({ host, hostname } = new URL(`https://${match[2]}`));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!host) return null;
|
||||
// A wildcard over a bare name is the whole of a top-level domain.
|
||||
if (match[1] && !hostname.includes('.')) return null;
|
||||
if (filter !== undefined && (typeof filter !== 'object' || filter === null || Array.isArray(filter))) return null;
|
||||
const { ISSUER, SUBJECT } = (filter ?? {}) as { ISSUER?: unknown; SUBJECT?: unknown };
|
||||
const issuer = certificateName(ISSUER);
|
||||
@@ -764,6 +786,8 @@ function failClosedValue(key: PolicyKey): PolicyValues[PolicyKey] {
|
||||
if (definition.type === 'list') return [] as PolicyValues[PolicyKey];
|
||||
// A shortcut nobody can read doesn't get the built-in one instead.
|
||||
if (definition.type === 'string' && definition.format === 'shortcut') return 'off' as PolicyValues[PolicyKey];
|
||||
// A limit nobody can read is the tightest one, not the user's own.
|
||||
if (definition.type === 'integer') return definition.min as PolicyValues[PolicyKey];
|
||||
return definition.default as PolicyValues[PolicyKey];
|
||||
}
|
||||
|
||||
@@ -829,6 +853,53 @@ export function effectivePolicyValue<T>(entry: { value: T; locked: boolean }, ch
|
||||
|
||||
export const DEFAULT_POLICY_SNAPSHOT: PolicySnapshot = resolvePolicies(EMPTY_POLICY_LAYERS);
|
||||
|
||||
export interface PolicyProblem {
|
||||
key: PolicyKey;
|
||||
reason: string;
|
||||
raw?: string;
|
||||
}
|
||||
|
||||
/** Forced values the app can't use, and lists with entries it had to leave out. */
|
||||
export function policyProblems(snapshot: PolicySnapshot): PolicyProblem[] {
|
||||
const problems: PolicyProblem[] = [];
|
||||
for (const key of POLICY_KEYS) {
|
||||
const { invalid, dropped, raw } = snapshot[key];
|
||||
if (invalid) problems.push({ key, reason: invalid, ...(raw ? { raw } : {}) });
|
||||
else if (dropped) problems.push({ key, reason: 'entries_left_out' });
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/** What the webmail reads, in `lib/` and `components/`. A page gets nothing else. */
|
||||
const PAGE_POLICY_KEYS: readonly PolicyKey[] = [
|
||||
'AllowStandalone',
|
||||
'AllowedJmapServers',
|
||||
'InstanceUrl',
|
||||
'PluginsMode',
|
||||
'PluginAllowlist',
|
||||
'OfflineEnabled',
|
||||
'AppLockRequired',
|
||||
'AppLockTimeoutMinutes',
|
||||
'CrashReportsAllowed',
|
||||
];
|
||||
|
||||
/**
|
||||
* The policies as a page gets them. A page can be any server someone chose, and the auth
|
||||
* lists and certificate rules name an organisation's internal hosts, so those stay at their
|
||||
* defaults. Nor does it get a bad value's text, or a recommended server's address: a forced
|
||||
* one is the only server such a page can be on.
|
||||
*/
|
||||
export function pagePolicySnapshot(snapshot: PolicySnapshot): PolicySnapshot {
|
||||
const page = { ...DEFAULT_POLICY_SNAPSHOT } as Record<PolicyKey, PolicyEntry>;
|
||||
for (const key of PAGE_POLICY_KEYS) {
|
||||
const entry: PolicyEntry = { ...snapshot[key] };
|
||||
delete entry.raw;
|
||||
if (key === 'InstanceUrl' && !entry.locked) (entry as { value: unknown }).value = null;
|
||||
page[key] = entry;
|
||||
}
|
||||
return page as PolicySnapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who acts on each key, so none can sit idle: the desktop repo's test and the webmail's
|
||||
* read the named file for the key. `pendingConsumer` names who still has to wire one.
|
||||
@@ -846,8 +917,8 @@ export const POLICY_CONSUMERS: Record<PolicyKey, PolicyConsumer> = {
|
||||
WipeOfflineData: { repo: 'shell', file: 'src/main/features/policies.ts' },
|
||||
AuthServerAllowlist: { repo: 'shell', file: 'src/main/features/policies.ts' },
|
||||
AuthNegotiateDelegateAllowlist: { repo: 'shell', file: 'src/main/features/policies.ts' },
|
||||
AutoUpdate: { pendingConsumer: 'feat/packaging: src/main/features/updater.ts' },
|
||||
UpdateChannel: { pendingConsumer: 'feat/packaging: src/main/features/updater.ts' },
|
||||
AutoUpdate: { repo: 'shell', file: 'src/main/update-rules.ts' },
|
||||
UpdateChannel: { repo: 'shell', file: 'src/main/update-rules.ts' },
|
||||
AppLockRequired: { repo: 'webmail', file: 'lib/app-lock/policy.ts' },
|
||||
AppLockTimeoutMinutes: { repo: 'webmail', file: 'lib/app-lock/policy.ts' },
|
||||
TelemetryAllowed: { repo: 'shell', file: 'src/main/features/privacy.ts' },
|
||||
|
||||
@@ -108,9 +108,12 @@ export function customServerAllowedByDesktopPolicy(snapshot: PolicySnapshot = ge
|
||||
* Whether accounts may keep mail on this device: `forced` when the policy says so
|
||||
* either way, `fallback` for the default where the user hasn't chosen. Forced off
|
||||
* feeds `noOffline` (lib/device-policy.ts), which also clears what's there already.
|
||||
* `invalid`: forced to something that makes no sense. Nothing new is kept, and a typo
|
||||
* deletes nothing.
|
||||
*/
|
||||
export function desktopOfflinePolicy(snapshot: PolicySnapshot = getDesktopPolicy()): { forced: boolean | null; fallback: boolean | null } {
|
||||
export function desktopOfflinePolicy(snapshot: PolicySnapshot = getDesktopPolicy()): { forced: boolean | null; fallback: boolean | null; invalid?: true } {
|
||||
const { OfflineEnabled } = snapshot;
|
||||
if (OfflineEnabled.locked && OfflineEnabled.invalid) return { forced: false, fallback: null, invalid: true };
|
||||
if (OfflineEnabled.locked) return { forced: OfflineEnabled.value, fallback: null };
|
||||
return { forced: null, fallback: OfflineEnabled.source === 'default' ? null : OfflineEnabled.value };
|
||||
}
|
||||
|
||||
@@ -645,6 +645,8 @@ export interface DesktopOsSummary {
|
||||
/** Newest conversations, whether read or not, for the jump list. */
|
||||
recent: DesktopOsTrayMessage[];
|
||||
nextEvent: { title: string; when: string } | null;
|
||||
/** The account `unread` is from, when more than one is signed in. */
|
||||
account?: string;
|
||||
}
|
||||
|
||||
export type DesktopOsPowerEvent = 'suspend' | 'resume' | 'lock-screen' | 'unlock-screen';
|
||||
|
||||
@@ -83,8 +83,10 @@ export function devicesForAccount(subject: DeviceSubject): DevicePolicy {
|
||||
const accountSubject = subjectForAccount(accountId);
|
||||
const policy = accountSubject === store.subject ? store.policy : store.resolveFor(accountSubject);
|
||||
const devices = devicesOf(policy);
|
||||
// The desktop app's OS policy says the same thing as the organisation's switch.
|
||||
return desktopOfflinePolicy().forced === false ? { ...devices, noOffline: true } : devices;
|
||||
// The desktop app's OS policy says the same thing as the organisation's switch. Not a
|
||||
// value that makes no sense: noOffline wipes, and offlineAllowed asks about that one itself.
|
||||
const offline = desktopOfflinePolicy();
|
||||
return offline.forced === false && !offline.invalid ? { ...devices, noOffline: true } : devices;
|
||||
}
|
||||
|
||||
/** What applies to anyone, with no account's groups: for data of an account nobody here is signed in to. */
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Notices for a first start: what the app did by itself (keeping mail on this device),
|
||||
* or a question it has to ask once (a spelling dictionary). They show one at a time, in
|
||||
* a bar above the message list (components/ui/first-start-notice.tsx), so they never
|
||||
* cover the composer, a toolbar or the window's controls the way a floating toast did.
|
||||
* Each has a close button, and once closed, by the X or its button, it doesn't come
|
||||
* back on this device.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface FirstStartNotice {
|
||||
id: string;
|
||||
text: string;
|
||||
action?: { label: string; onClick: () => void };
|
||||
/** Where "closed" is kept. Defaults to one key per id. */
|
||||
doneKey?: string;
|
||||
/** Runs when the user closes it, by the X or the action. */
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
interface NoticeState {
|
||||
queue: FirstStartNotice[];
|
||||
}
|
||||
|
||||
const useNotices = create<NoticeState>(() => ({ queue: [] }));
|
||||
|
||||
function doneKeyOf(notice: Pick<FirstStartNotice, 'id' | 'doneKey'>): string {
|
||||
return notice.doneKey ?? `bulwark-notice-done:${notice.id}`;
|
||||
}
|
||||
|
||||
function isDone(notice: Pick<FirstStartNotice, 'id' | 'doneKey'>): boolean {
|
||||
try {
|
||||
return localStorage.getItem(doneKeyOf(notice)) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Queues a notice. One closed before, or already waiting, is left out. */
|
||||
export function showFirstStartNotice(notice: FirstStartNotice): void {
|
||||
if (isDone(notice)) return;
|
||||
if (useNotices.getState().queue.some((waiting) => waiting.id === notice.id)) return;
|
||||
useNotices.setState((state) => ({ queue: [...state.queue, notice] }));
|
||||
}
|
||||
|
||||
/** The user closed it: gone for good on this device, and the next one shows. */
|
||||
export function closeFirstStartNotice(id: string): void {
|
||||
const notice = useNotices.getState().queue.find((waiting) => waiting.id === id);
|
||||
if (!notice) return;
|
||||
try {
|
||||
localStorage.setItem(doneKeyOf(notice), '1');
|
||||
} catch {
|
||||
// Private mode: it goes for this session.
|
||||
}
|
||||
useNotices.setState((state) => ({ queue: state.queue.filter((waiting) => waiting.id !== id) }));
|
||||
try {
|
||||
notice.onClose?.();
|
||||
} catch {
|
||||
// A failing listener must not keep the notice on screen.
|
||||
}
|
||||
}
|
||||
|
||||
/** Takes a waiting notice away without marking it closed, e.g. when what it asks no longer applies. */
|
||||
export function withdrawFirstStartNotice(id: string): void {
|
||||
useNotices.setState((state) => ({ queue: state.queue.filter((waiting) => waiting.id !== id) }));
|
||||
}
|
||||
|
||||
/** The one notice to show now, if any. */
|
||||
export function useCurrentFirstStartNotice(): FirstStartNotice | null {
|
||||
return useNotices((state) => state.queue[0] ?? null);
|
||||
}
|
||||
|
||||
export function firstStartNoticesWaiting(): FirstStartNotice[] {
|
||||
return useNotices.getState().queue;
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetFirstStartNotices(): void {
|
||||
useNotices.setState({ queue: [] });
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
||||
import { setStoreBackend } from '@/lib/store/client/api';
|
||||
import { FakeJmapMail } from '@/lib/store/__tests__/helpers/fake-jmap-mail';
|
||||
import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
|
||||
import { startOfflineMode, wipeEverything } from '../offline-mode';
|
||||
import { setOfflineNotifier, startOfflineMode, wipeEverything } from '../offline-mode';
|
||||
import { forgetLegacyMove } from '../migrate-legacy';
|
||||
import { useOfflineStore, writeEnabled } from '../state';
|
||||
|
||||
@@ -291,3 +291,27 @@ describe('what the store answers and is told', () => {
|
||||
stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('saying a connection is needed', () => {
|
||||
it('not while online with the server answering, as at a start with a draft waiting', async () => {
|
||||
const { client, stop } = await ready();
|
||||
const { getOfflineHooks } = await import('@/lib/jmap/offline-hooks');
|
||||
const notices: { kind: string }[] = [];
|
||||
setOfflineNotifier((notice) => notices.push(notice));
|
||||
await getOfflineHooks()?.answer(client, [['Identity/set', { create: { x: { email: 'a@example.org' } } }, '0']]);
|
||||
expect(notices.filter((n) => n.kind.startsWith('refused'))).toEqual([]);
|
||||
stop();
|
||||
});
|
||||
|
||||
it('when offline, once', async () => {
|
||||
const { client, stop } = await ready();
|
||||
const { getOfflineHooks } = await import('@/lib/jmap/offline-hooks');
|
||||
const notices: { kind: string }[] = [];
|
||||
setOfflineNotifier((notice) => notices.push(notice));
|
||||
await offline(async () => {
|
||||
await getOfflineHooks()?.answer(client, [['Identity/set', { create: { x: { email: 'a@example.org' } } }, '0']]);
|
||||
});
|
||||
expect(notices.filter((n) => n.kind === 'refused-other')).toHaveLength(1);
|
||||
stop();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
||||
import { EMPTY_POLICY_LAYERS, resolvePolicies } from '@/lib/desktop/policy-schema';
|
||||
import { getOfflineHooks, setOfflineHooks } from '@/lib/jmap/offline-hooks';
|
||||
import { setStoreBackend } from '@/lib/store/client/api';
|
||||
import { FakeJmap } from '@/lib/store/__tests__/helpers/fake-jmap';
|
||||
@@ -14,6 +15,8 @@ import {
|
||||
disableOffline,
|
||||
enableOffline,
|
||||
enforcePolicy,
|
||||
offlineActive,
|
||||
offlineAllowed,
|
||||
refreshQueueState,
|
||||
refreshUsage,
|
||||
registerOfflineHooks,
|
||||
@@ -62,6 +65,34 @@ afterEach(async () => {
|
||||
setStoreBackend(null);
|
||||
});
|
||||
|
||||
describe('OfflineEnabled from the desktop app\'s OS policy', () => {
|
||||
function forced(value: unknown): void {
|
||||
const snapshot = resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine: { OfflineEnabled: value } });
|
||||
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode: 'standalone', policy: { get: () => snapshot, onChange: () => () => {} } };
|
||||
}
|
||||
|
||||
it('a value that makes no sense stops keeping mail and deletes nothing', async () => {
|
||||
await enableOffline('acc');
|
||||
expect(useOfflineStore.getState().enabled).toEqual(['acc']);
|
||||
|
||||
forced('yes');
|
||||
expect(offlineAllowed('acc')).toBe(false);
|
||||
enforcePolicy();
|
||||
await vi.waitFor(() => expect(offlineActive('acc')).toBe(false));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(useOfflineStore.getState().enabled).toEqual(['acc']);
|
||||
expect(notices.map((notice) => notice.kind)).not.toContain('forbidden');
|
||||
});
|
||||
|
||||
it('forced off does delete', async () => {
|
||||
await enableOffline('acc');
|
||||
forced(0);
|
||||
enforcePolicy();
|
||||
await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual([]));
|
||||
expect(notices.map((notice) => notice.kind)).toContain('forbidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('at page load, before the subject is known', () => {
|
||||
const forbiddenExceptGroup = {
|
||||
...usePolicyStore.getState().rawPolicy,
|
||||
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
type OutboxRow,
|
||||
type UserChoice,
|
||||
} from './state';
|
||||
import { desktopOfflinePolicy } from '@/lib/desktop/policy';
|
||||
import { effectiveScope, offlineDefaultOn, olderThanCap, platformDefault, userChoiceFor, type EffectiveScope } from './scope';
|
||||
import { isStandaloneDisplayMode } from '@/lib/standalone-display';
|
||||
import { readPersistence, requestPersistence, safariMayDelete } from './storage-health';
|
||||
@@ -166,7 +167,7 @@ export function offlineSupported(): boolean {
|
||||
|
||||
/** The organisation allows this account to keep mail on the device. */
|
||||
export function offlineAllowed(account: string | null | undefined): boolean {
|
||||
return offlineSupported() && !devicesForAccount(account ?? null).noOffline;
|
||||
return offlineSupported() && !devicesForAccount(account ?? null).noOffline && !desktopOfflinePolicy().invalid;
|
||||
}
|
||||
|
||||
/** A dropped account's data isn't judged by the groups of whoever is signed in now. */
|
||||
@@ -319,7 +320,12 @@ function hasWholeText(email: { bodyValues?: Record<string, { isTruncated?: boole
|
||||
const NEEDS_SERVER = /^(EmailSubmission|Email\/import|CalendarEvent\/set|ContactCard\/set|AddressBook\/set|Calendar\/set|Mailbox\/set|FileNode\/set|SieveScript\/set|VacationResponse\/set|Identity\/set)/;
|
||||
let lastRefusalNotice = 0;
|
||||
|
||||
function noticeRefused(answered: OfflineResponse | null, methodCalls: OfflineMethodCall[]): void {
|
||||
function noticeRefused(client: OfflineClientRef, answered: OfflineResponse | null, methodCalls: OfflineMethodCall[]): void {
|
||||
// Online, with the server answering: the store only stood in for a moment (a draft
|
||||
// waiting from last time, at a start). The outbox retries by itself; "this needs a
|
||||
// connection" would be wrong.
|
||||
const online = typeof navigator === 'undefined' || navigator.onLine !== false;
|
||||
if (online && !useOfflineStore.getState().downServers.includes(serverKey(client.getServerUrl()))) return;
|
||||
const refusedCall = methodCalls.some(([name, , id]) => NEEDS_SERVER.test(name)
|
||||
&& (!answered || answered.methodResponses.some(([responseName, , responseId]) => responseId === id && responseName === 'error')));
|
||||
const refusedChange = answered?.methodResponses.some(([name, result]) => name === 'Email/set' && result?.notUpdated) ?? false;
|
||||
@@ -382,7 +388,7 @@ const hooks: OfflineHooks = {
|
||||
} catch {
|
||||
// The store couldn't start or lost its owner: as if nothing were kept.
|
||||
}
|
||||
noticeRefused(answered, methodCalls);
|
||||
noticeRefused(client, answered, methodCalls);
|
||||
if (answered && methodCalls.some(([name]) => QUEUEING_METHOD.test(name))) {
|
||||
requestOutboxSync();
|
||||
void refreshQueueState();
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* What an invitation's card says, for the printout of its message. The card works its
|
||||
* details out itself (components/email/calendar-invitation-banner.tsx); the print copy
|
||||
* (components/email/message-print.tsx) only shows the header and the body, so without
|
||||
* this a printed invitation lost when and where, the part people print it for.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface InvitationPrint {
|
||||
heading: string;
|
||||
rows: Array<[label: string, value: string]>;
|
||||
}
|
||||
|
||||
const useInvitations = create<Record<string, InvitationPrint>>(() => ({}));
|
||||
|
||||
export function setInvitationPrint(emailId: string, print: InvitationPrint | null): void {
|
||||
useInvitations.setState((state) => {
|
||||
const next = { ...state };
|
||||
if (print) next[emailId] = print;
|
||||
else delete next[emailId];
|
||||
return next;
|
||||
}, true);
|
||||
}
|
||||
|
||||
export function useInvitationPrint(emailId: string | undefined): InvitationPrint | null {
|
||||
return useInvitations((state) => (emailId ? state[emailId] ?? null : null));
|
||||
}
|
||||
|
||||
/** Rendered by the card while its details are known; takes them away again when it goes. */
|
||||
export function InvitationPrintSync({ emailId, print }: { emailId: string; print: InvitationPrint }): null {
|
||||
const key = JSON.stringify(print);
|
||||
useEffect(() => {
|
||||
setInvitationPrint(emailId, JSON.parse(key) as InvitationPrint);
|
||||
return () => setInvitationPrint(emailId, null);
|
||||
}, [emailId, key]);
|
||||
return null;
|
||||
}
|
||||
@@ -19,6 +19,11 @@ export interface ParsedMailto {
|
||||
* written from the account in use, without asking which one.
|
||||
*/
|
||||
inApp?: boolean;
|
||||
/**
|
||||
* Handed over by the OS in the desktop app: a link clicked in another program. It
|
||||
* opens in a compose window of its own, and the main window stays where it was.
|
||||
*/
|
||||
fromOs?: boolean;
|
||||
}
|
||||
|
||||
const MAX_RECIPIENTS = 200;
|
||||
|
||||
@@ -6748,7 +6748,7 @@
|
||||
},
|
||||
"desktopUi": {
|
||||
"settings": {
|
||||
"title": "Windows",
|
||||
"title": "App windows",
|
||||
"double_click_label": "Open messages in a new window on double-click",
|
||||
"double_click_description": "A single click still opens the message next to the list.",
|
||||
"restore_label": "Reopen windows at start",
|
||||
|
||||
Reference in New Issue
Block a user