+
+
{!IS_LITE &&
}
diff --git a/hooks/__tests__/use-email-drag-routing.test.tsx b/hooks/__tests__/use-email-drag-routing.test.tsx
index e53285031..e3757d946 100644
--- a/hooks/__tests__/use-email-drag-routing.test.tsx
+++ b/hooks/__tests__/use-email-drag-routing.test.tsx
@@ -731,4 +731,32 @@ describe('message row drag hover wiring', () => {
expect(retry.payload.get('DownloadURL')).toMatch(/^message\/rfc822:Message.eml:blob:/);
expect(await text(payloadBlob(retry.payload))).toBe(bytes('shared-b', authB));
});
+
+ it("in the desktop app an Alt drag is the OS's own with the real bytes, and a plain one stays the page's", async () => {
+ const email = mail({ subject: 'Hello world' });
+ const nativeDrag = vi.fn(async (_files: { name: string; data: Uint8Array }[]) => true);
+ window.bulwarkDesktop = { version: 1, platform: 'win32', os: { startDrag: nativeDrag } } as never;
+ const { result } = renderHook(() => useEmailDrag({ email, sourceMailboxId: 'inbox' }));
+ await warm(result);
+ try {
+ startDrag.mockClear();
+ // Plain: still a move to a folder, still DownloadURL for Explorer.
+ expect(drag(result.current.dragHandlers).has('DownloadURL')).toBe(true);
+ expect(startDrag).toHaveBeenCalled();
+ expect(nativeDrag).not.toHaveBeenCalled();
+
+ startDrag.mockClear();
+ const preventDefault = vi.fn();
+ const dataTransfer = { setData: vi.fn(), setDragImage: vi.fn(), effectAllowed: '' };
+ act(() => result.current.dragHandlers.onDragStart({ altKey: true, preventDefault, dataTransfer } as unknown as DragEvent
));
+ expect(preventDefault).toHaveBeenCalled();
+ expect(startDrag).not.toHaveBeenCalled();
+ await waitFor(() => expect(nativeDrag).toHaveBeenCalled(), { container: document.createElement('div') });
+ const [files] = nativeDrag.mock.calls[0];
+ expect(files[0].name).toBe('Hello world.eml');
+ expect(new TextDecoder().decode(files[0].data)).toBe(bytes('primary-a', authA));
+ } finally {
+ window.bulwarkDesktop = undefined;
+ }
+ });
});
diff --git a/hooks/__tests__/use-new-mail-alerts.test.tsx b/hooks/__tests__/use-new-mail-alerts.test.tsx
index c98b51dca..40c1fcee7 100644
--- a/hooks/__tests__/use-new-mail-alerts.test.tsx
+++ b/hooks/__tests__/use-new-mail-alerts.test.tsx
@@ -39,6 +39,14 @@ describe('new mail alerts', () => {
vi.useRealTimers();
});
+ it('in the desktop app leaves the sound to the notification path, so it plays once', () => {
+ window.bulwarkDesktop = { version: 1, platform: 'win32', os: {} } as never;
+ renderHook(() => useNewMailAlerts());
+ arrive('a');
+ expect(playNotificationSound).not.toHaveBeenCalled();
+ window.bulwarkDesktop = undefined;
+ });
+
it('tells screen readers once how many messages came, never what they say', () => {
renderHook(() => useNewMailAlerts());
arrive('a');
diff --git a/hooks/use-app-badge.ts b/hooks/use-app-badge.ts
index 927a869b5..d8a33b320 100644
--- a/hooks/use-app-badge.ts
+++ b/hooks/use-app-badge.ts
@@ -2,6 +2,8 @@
import { useEffect } from 'react';
import { debug } from '@/lib/debug';
+import { getDesktopBridge } from '@/lib/desktop/bridge';
+import { isPopoutWindow } from '@/lib/popout/window-role';
/**
* Badges the *installed* PWA's icon — the shortcut Chrome/Edge pin next to
@@ -35,6 +37,13 @@ export function useAppBadge(count: number, enabled = true): void {
const effectiveCount = enabled ? count : 0;
+ // The desktop app's own icon. A pop-out has its own account and no say in it.
+ const desktopOs = getDesktopBridge()?.os;
+ if (desktopOs) {
+ if (!isPopoutWindow()) desktopOs.setBadge(effectiveCount);
+ return;
+ }
+
void (async () => {
try {
if (effectiveCount > 0) {
diff --git a/hooks/use-attachment-drag.ts b/hooks/use-attachment-drag.ts
index 83cdce627..6136c4453 100644
--- a/hooks/use-attachment-drag.ts
+++ b/hooks/use-attachment-drag.ts
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, DragEvent } from "react";
+import { canDragNativeFiles, copyFilesToClipboard, startNativeFileDrag } from "@/lib/desktop/file-drag";
// Chromium ships the `DownloadURL` DataTransfer entry, which the OS reads on
// drop to materialize a real file. Firefox and Safari ignore it, so we only
@@ -33,6 +34,8 @@ export interface AttachmentDragSource {
name: string;
type: string;
getBlobUrl: () => Promise;
+ /** The bytes themselves, for the desktop app's native drag. */
+ getBlob?: () => Promise;
}
export interface UseAttachmentDragResult {
@@ -40,6 +43,8 @@ export interface UseAttachmentDragResult {
onPointerEnter: () => void;
onDragStart: (e: DragEvent) => void;
onDragEnd: (e: DragEvent) => void;
+ /** The desktop app: put the file on the clipboard, for pasting into Explorer or Finder. */
+ onCopyAsFile?: () => Promise;
}
const NOOP_HANDLERS: UseAttachmentDragResult = {
@@ -56,6 +61,8 @@ export function useAttachmentDrag(
const urlRef = useRef(null);
const ownedRef = useRef(false);
const inFlightRef = useRef | null>(null);
+ const blobRef = useRef(null);
+ const nativeDrag = canDragNativeFiles() && !!source.getBlob;
useEffect(() => {
return () => {
@@ -70,6 +77,19 @@ export function useAttachmentDrag(
const prefetch = useCallback(() => {
if (!enabled) return;
+ if (nativeDrag) {
+ if (blobRef.current || inFlightRef.current) return;
+ inFlightRef.current = source.getBlob!()
+ .then((blob) => {
+ blobRef.current = blob;
+ return null;
+ })
+ .catch(() => null)
+ .finally(() => {
+ inFlightRef.current = null;
+ });
+ return;
+ }
if (urlRef.current || inFlightRef.current) return;
inFlightRef.current = source
.getBlobUrl()
@@ -87,7 +107,7 @@ export function useAttachmentDrag(
.finally(() => {
inFlightRef.current = null;
});
- }, [enabled, source]);
+ }, [enabled, source, nativeDrag]);
const handleDragStart = useCallback(
(e: DragEvent) => {
@@ -95,6 +115,15 @@ export function useAttachmentDrag(
const name = source.name || "download";
const type = source.type || "application/octet-stream";
+ // In the desktop app the OS's own drag, with the real file on disk first. The bytes
+ // aren't here yet: fetch them for the next try, as below.
+ if (nativeDrag) {
+ e.preventDefault();
+ if (blobRef.current) void startNativeFileDrag([{ name, blob: blobRef.current }]);
+ else prefetch();
+ return;
+ }
+
if (!url) {
// Blob isn't materialized yet. Kick off the fetch so the next attempt
// works, but cancel this drag so the user doesn't get a silent failure
@@ -111,7 +140,7 @@ export function useAttachmentDrag(
e.dataTransfer.setData("DownloadURL", `${type}:${name}:${url}`);
e.dataTransfer.effectAllowed = "copyMove";
},
- [source.name, source.type, prefetch],
+ [source.name, source.type, prefetch, nativeDrag],
);
const handleDragEnd = useCallback(() => {
@@ -125,6 +154,11 @@ export function useAttachmentDrag(
}
}, []);
+ const copyAsFile = useCallback(async () => {
+ const blob = blobRef.current ?? await source.getBlob?.();
+ return blob ? copyFilesToClipboard([{ name: source.name || "download", blob }]) : false;
+ }, [source]);
+
if (!enabled) return NOOP_HANDLERS;
return {
@@ -132,5 +166,6 @@ export function useAttachmentDrag(
onPointerEnter: prefetch,
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
+ ...(nativeDrag ? { onCopyAsFile: copyAsFile } : {}),
};
}
diff --git a/hooks/use-email-drag.ts b/hooks/use-email-drag.ts
index 25450a031..6debff52c 100644
--- a/hooks/use-email-drag.ts
+++ b/hooks/use-email-drag.ts
@@ -9,6 +9,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { useUIStore } from "@/stores/ui-store";
import { isDragOutSupported } from "@/hooks/use-attachment-drag";
+import { canDragNativeFiles, forgetDragBlob, keepDragBlob, keptDragBlob, startNativeFileDrag } from "@/lib/desktop/file-drag";
import { canExport } from "@/lib/device-policy";
import { appUrl, buildMailPath } from "@/lib/deep-links";
import {
@@ -103,7 +104,10 @@ function selectionKey(emails: RoutedEmail[], bundleOptions: EmailFilenameOptions
function revokeLater(url: string): void {
// Chromium may read the URL asynchronously after the drop completes.
- setTimeout(() => URL.revokeObjectURL(url), 60_000);
+ setTimeout(() => {
+ URL.revokeObjectURL(url);
+ forgetDragBlob(url);
+ }, 60_000);
}
async function buildEmailZip(emails: RoutedEmail[]): Promise {
@@ -126,7 +130,7 @@ async function buildEmailZip(emails: RoutedEmail[]): Promise {
}),
);
const zipBlob = await zip.generateAsync({ type: "blob", mimeType: "application/zip" });
- return URL.createObjectURL(zipBlob);
+ return keepDragBlob(URL.createObjectURL(zipBlob), zipBlob);
}
function prefetchEmailBundle(
@@ -224,8 +228,11 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
if (previous?.url) revokeLater(previous.url);
const entry: BundleEntry = { key: route.key, name: route.name, url: null, promise: null };
singleRef.current = entry;
- entry.promise = route.client
- .fetchBlobAsObjectUrl(target.blobId, route.name, "message/rfc822", route.accountId)
+ // The desktop app's native drag needs the bytes as well as the address.
+ const fetched = canDragNativeFiles()
+ ? route.client.fetchBlob(target.blobId, route.name, "message/rfc822", route.accountId).then((blob) => keepDragBlob(URL.createObjectURL(blob), blob))
+ : route.client.fetchBlobAsObjectUrl(target.blobId, route.name, "message/rfc822", route.accountId);
+ entry.promise = fetched
.then((url) => {
if (url) {
if (singleRef.current === entry && routeEmail(target, filenameOptions).key === entry.key) entry.url = url;
@@ -295,6 +302,14 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
const url = singleRef.current?.key === route.key ? singleRef.current.url : null;
if (url) {
const name = emailExportFilename(emailsToDrag[0], filenameOptions);
+ // Desktop app, Alt held: the OS's own drag with the real file. A plain drag has
+ // to stay the page's, because it also moves the message to a folder.
+ const blob = e.altKey ? keptDragBlob(url) : undefined;
+ if (blob) {
+ e.preventDefault();
+ void startNativeFileDrag([{ name, blob }]);
+ return;
+ }
// `DownloadURL` format: ::. Chromium expects
// the filename raw - URL-encoding it ends up literally on disk
// (e.g. `%20` instead of a space). The sanitiser already removed
@@ -313,6 +328,12 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
const routed = emailsToDrag.map((em) => routeEmail(em, filenameOptions));
const ready = getReadyBundle(routed, bundleOptions);
if (ready) {
+ const blob = e.altKey ? keptDragBlob(ready.url) : undefined;
+ if (blob) {
+ e.preventDefault();
+ void startNativeFileDrag([{ name: ready.name, blob }]);
+ return;
+ }
e.dataTransfer.setData(
"DownloadURL",
`application/zip:${ready.name}:${ready.url}`,
diff --git a/hooks/use-new-mail-alerts.ts b/hooks/use-new-mail-alerts.ts
index 0b118372c..b2fd6801f 100644
--- a/hooks/use-new-mail-alerts.ts
+++ b/hooks/use-new-mail-alerts.ts
@@ -8,6 +8,7 @@ import { playNotificationSound } from "@/lib/notification-sound";
import { announce } from "@/lib/announce";
import { startTitleAlert } from "@/lib/title-alert";
import { debug } from "@/lib/debug";
+import { desktopPlaysMailSound } from "@/lib/desktop/mail-notifications";
/**
* Mail that arrives within this time is told about once ("3 new messages"),
@@ -51,7 +52,8 @@ export function useNewMailAlerts(): void {
const s = useSettingsStore.getState();
if (s.doNotDisturb) return;
if (batchTimer === null) {
- if (s.emailNotificationsEnabled && s.emailNotificationSound) {
+ // The desktop app sounds it with the notification, in Do Not Disturb not at all.
+ if (s.emailNotificationsEnabled && s.emailNotificationSound && !desktopPlaysMailSound()) {
playNotificationSound(s.notificationSoundChoice);
}
batchTimer = setTimeout(flush, NEW_MAIL_BATCH_MS);
diff --git a/hooks/use-policy-refresh.ts b/hooks/use-policy-refresh.ts
index 3ae942946..f7115e422 100644
--- a/hooks/use-policy-refresh.ts
+++ b/hooks/use-policy-refresh.ts
@@ -1,6 +1,7 @@
'use client';
import { useEffect } from 'react';
+import { isDesktopApp } from '@/lib/desktop/bridge';
import { usePolicyStore } from '@/stores/policy-store';
/** How often an open, visible tab fetches the policy again. */
@@ -13,7 +14,9 @@ let mounted = 0;
let interval: ReturnType | null = null;
function refresh(force = false): void {
- if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
+ // A browser tab out of view waits until it's back. The desktop app in the tray is
+ // out of view for days and still has to hear about a wipe or a changed policy.
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden' && !isDesktopApp()) return;
const now = Date.now();
if (!force && now - lastRefresh < FOCUS_THROTTLE_MS) return;
const state = usePolicyStore.getState();
@@ -27,7 +30,7 @@ const onVisible = () => refresh();
/**
* Fetches the policy again when the tab comes back into view and every 15
- * minutes while it is visible, so an announcement (or any other change the
+ * minutes while it is visible (in the desktop app, also while hidden), so an announcement (or any other change the
* admin saves) reaches tabs that stay open. One timer for the page however
* many components use it.
*/
diff --git a/lib/desktop/__tests__/os-commands.test.ts b/lib/desktop/__tests__/os-commands.test.ts
new file mode 100644
index 000000000..465180c64
--- /dev/null
+++ b/lib/desktop/__tests__/os-commands.test.ts
@@ -0,0 +1,245 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { BulwarkDesktopBridge, DesktopOsCommand } from '../types';
+import type { NotificationActionRequest, NotificationActionStatus } from '@/lib/pwa-notify/page-actions';
+
+/** What the OS asks for, carried out by the parts the app already has. */
+
+const runNotificationAction = vi.hoisted(() => vi.fn());
+vi.mock('@/lib/pwa-notify/page-actions', () => ({ runNotificationAction }));
+
+const accounts = [{ id: 'alice@one', cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org' }];
+let authenticated = true;
+vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts }) } }));
+vi.mock('@/stores/auth-store', () => ({
+ useAuthStore: {
+ getState: () => ({ isAuthenticated: authenticated }),
+ subscribe: () => () => {},
+ },
+}));
+
+const session = vi.hoisted(() => ({
+ savePendingMailto: vi.fn(),
+ notifyPendingMailto: vi.fn(),
+ savePendingWebcal: vi.fn(),
+ notifyPendingWebcal: vi.fn(),
+}));
+vi.mock('@/lib/protocol-handlers/session', () => session);
+
+async function fingerprint() {
+ const { loginFingerprint } = await import('@/lib/pwa-notify/login-fingerprint');
+ return loginFingerprint('https://one.example', 'alice@example.org');
+}
+
+const leaveFor = vi.fn();
+const push = vi.fn();
+const notifyFailed = vi.fn();
+const env = { leaveFor, push, notifyFailed };
+const focus = vi.fn();
+
+async function run(command: DesktopOsCommand) {
+ const { handleOsCommand } = await import('../os-commands');
+ await handleOsCommand(command, env);
+}
+
+const text = (value: string) => new TextEncoder().encode(value);
+
+beforeEach(async () => {
+ vi.clearAllMocks();
+ authenticated = true;
+ window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { focus } } as unknown as BulwarkDesktopBridge;
+ const { resetMailLinks } = await import('../mail-links');
+ resetMailLinks();
+ const { clearHandoffs, takeHandoff } = await import('@/lib/launch-handoff');
+ clearHandoffs();
+ for (const kind of ['compose', 'eml', 'calendar', 'contacts'] as const) takeHandoff(kind);
+});
+
+afterEach(() => {
+ window.bulwarkDesktop = undefined;
+});
+
+describe('launches', () => {
+ it('New message goes through the launch handoff, with no question asked', async () => {
+ const { subscribeHandoff } = await import('@/lib/launch-handoff');
+ const fired = vi.fn();
+ const stop = subscribeHandoff('compose', fired);
+ await run({ type: 'new-message' });
+ expect(fired).toHaveBeenCalled();
+ stop();
+ });
+
+ it('a mailto goes through the protocol handler to the mail page', async () => {
+ await run({ type: 'mailto', url: 'mailto:bob@example.org?subject=Hi&body=Text' });
+ expect(session.savePendingMailto).toHaveBeenCalledTimes(1);
+ expect(session.savePendingMailto.mock.calls[0][0]).toMatchObject({ to: ['bob@example.org'], subject: 'Hi' });
+ expect(session.notifyPendingMailto).toHaveBeenCalled();
+ expect(push).toHaveBeenCalledWith('/');
+ });
+
+ it('a mailto that is not one does nothing', async () => {
+ await run({ type: 'mailto', url: 'mailto:' + '%'.repeat(3) });
+ expect(push).not.toHaveBeenCalledWith('/calendar');
+ });
+
+ it('a webcal goes to the calendar', async () => {
+ await run({ type: 'webcal', url: 'webcal://example.org/cal.ics' });
+ expect(session.savePendingWebcal).toHaveBeenCalledTimes(1);
+ expect(session.notifyPendingWebcal).toHaveBeenCalled();
+ expect(push).toHaveBeenCalledWith('/calendar');
+ });
+
+ it('a double-clicked .eml, .ics and .vcf each reach their handler', async () => {
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ await run({
+ type: 'files',
+ reason: 'open',
+ failed: [],
+ files: [
+ { name: 'letter.eml', data: text('Subject: x') },
+ { name: 'party.ics', data: text('BEGIN:VCALENDAR') },
+ { name: 'bob.vcf', data: text('BEGIN:VCARD') },
+ ],
+ });
+ expect(await takeHandoff('eml')?.files[0].text()).toBe('Subject: x');
+ expect(takeHandoff('calendar')?.files[0].name).toBe('party.ics');
+ expect(takeHandoff('contacts')?.files[0].name).toBe('bob.vcf');
+ expect(notifyFailed).not.toHaveBeenCalled();
+ });
+
+ it('a file dropped on the icon becomes an attachment on a new draft', async () => {
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ await run({ type: 'files', reason: 'attach', failed: [], files: [{ name: 'report.pdf', data: text('pdf') }] });
+ const draft = takeHandoff('compose');
+ expect(draft?.files.map((file) => file.name)).toEqual(['report.pdf']);
+ });
+
+ it('says so when a file could not be read', async () => {
+ await run({ type: 'files', reason: 'open', failed: ['gone.eml'], files: [] });
+ expect(notifyFailed).toHaveBeenCalledWith(['gone.eml']);
+ });
+});
+
+describe('going somewhere', () => {
+ it('Calendar and Contacts leave through the save-first path', async () => {
+ await run({ type: 'navigate', target: 'calendar' });
+ expect(leaveFor).toHaveBeenCalledWith('/calendar');
+ });
+
+ it('Inbox opens the folder on the mail page when it is mounted', async () => {
+ const { setMailLinkOpener } = await import('../mail-links');
+ const opener = vi.fn();
+ const stop = setMailLinkOpener(opener);
+ await run({ type: 'navigate', target: 'inbox' });
+ expect(opener).toHaveBeenCalledWith({ kind: 'folder', ref: 'inbox' }, undefined);
+ expect(leaveFor).not.toHaveBeenCalled();
+ stop();
+ });
+
+ it('with the mail page not mounted the link waits for it, and the app goes there', async () => {
+ const { setMailLinkOpener } = await import('../mail-links');
+ await run({ type: 'open-message', emailId: 'e1', accountId: 'acc' });
+ expect(leaveFor).toHaveBeenCalledWith('/');
+ const opener = vi.fn();
+ const stop = setMailLinkOpener(opener);
+ expect(opener).toHaveBeenCalledWith({ kind: 'message', id: 'e1', ownerAccountId: 'acc' }, undefined);
+ stop();
+ });
+});
+
+describe('a toast\'s buttons', () => {
+ const context = async (over: object = {}) => JSON.stringify({ emailId: 'e1', threadId: 't1', accountId: 'acc', slot: 3, login: await fingerprint(), ...over });
+ const finishWith = (status: NotificationActionStatus) => {
+ runNotificationAction.mockImplementation(async (_request: NotificationActionRequest, answer: (s: NotificationActionStatus) => void) => {
+ answer('accepted');
+ answer(status);
+ });
+ };
+
+ it.each(['archive', 'mark-read', 'delete'] as const)('%s runs through the page\'s notification action and stays out of sight', async (action) => {
+ const { setMailLinkOpener } = await import('../mail-links');
+ const opener = vi.fn();
+ const stop = setMailLinkOpener(opener);
+ finishWith('done');
+ await run({ type: 'notification', action, context: await context() });
+ expect(runNotificationAction.mock.calls[0][0]).toMatchObject({ action, emailId: 'e1', accountId: 'acc', slot: 3, login: await fingerprint() });
+ expect(opener).not.toHaveBeenCalled();
+ expect(focus).not.toHaveBeenCalled();
+ stop();
+ });
+
+ it('a button that can\'t be carried out opens the message instead', async () => {
+ const { setMailLinkOpener } = await import('../mail-links');
+ const opener = vi.fn();
+ const stop = setMailLinkOpener(opener);
+ finishWith('failed');
+ await run({ type: 'notification', action: 'archive', context: await context() });
+ expect(focus).toHaveBeenCalled();
+ expect(opener).toHaveBeenCalledWith(expect.objectContaining({ kind: 'message', id: 'e1', slot: 3 }), undefined);
+ stop();
+ });
+
+ it('a reply opens the composer with the typed text, and is not sent', async () => {
+ const { setMailLinkOpener } = await import('../mail-links');
+ const opener = vi.fn();
+ const stop = setMailLinkOpener(opener);
+ await run({ type: 'notification', action: 'reply', context: await context(), reply: 'On my way' });
+ expect(runNotificationAction).not.toHaveBeenCalled();
+ expect(opener).toHaveBeenCalledWith(
+ { kind: 'message', id: 'e1', slot: 3, ownerAccountId: 'acc', reply: true },
+ { replyText: 'On my way' },
+ );
+ stop();
+ });
+
+ it('a click on an old toast whose slot went to someone else opens the Inbox, not their message', async () => {
+ const { setMailLinkOpener } = await import('../mail-links');
+ const opener = vi.fn();
+ const stop = setMailLinkOpener(opener);
+ await run({ type: 'notification', action: 'open', context: await context({ login: 'someone-else' }) });
+ expect(opener).toHaveBeenCalledWith({ kind: 'folder', ref: 'inbox' }, undefined);
+ stop();
+ });
+
+ it('a click on a summary toast opens the Inbox of that login', async () => {
+ const { setMailLinkOpener } = await import('../mail-links');
+ const opener = vi.fn();
+ const stop = setMailLinkOpener(opener);
+ await run({ type: 'notification', action: 'open', context: await context({ emailId: undefined }) });
+ expect(opener).toHaveBeenCalledWith({ kind: 'folder', ref: 'inbox', slot: 3 }, undefined);
+ stop();
+ });
+
+ it('junk in the context does no harm', async () => {
+ const { setMailLinkOpener } = await import('../mail-links');
+ const opener = vi.fn();
+ const stop = setMailLinkOpener(opener);
+ await run({ type: 'notification', action: 'archive', context: '{not json' });
+ expect(runNotificationAction).not.toHaveBeenCalled();
+ expect(opener).toHaveBeenCalledWith({ kind: 'folder', ref: 'inbox' }, undefined);
+ stop();
+ });
+});
+
+describe('listening', () => {
+ it('does nothing in a browser', async () => {
+ window.bulwarkDesktop = undefined;
+ const { startOsCommands } = await import('../os-commands');
+ expect(() => startOsCommands(env)()).not.toThrow();
+ });
+
+ it('subscribes, and carries out what arrives', async () => {
+ let listener: ((command: DesktopOsCommand) => void) | null = null;
+ window.bulwarkDesktop = {
+ version: 1,
+ platform: 'win32',
+ os: { onCommand: (l: (command: DesktopOsCommand) => void) => { listener = l; return () => { listener = null; }; } },
+ } as unknown as BulwarkDesktopBridge;
+ const { startOsCommands } = await import('../os-commands');
+ const stop = startOsCommands(env);
+ listener!({ type: 'navigate', target: 'contacts' });
+ await Promise.resolve();
+ expect(leaveFor).toHaveBeenCalledWith('/contacts');
+ stop();
+ expect(listener).toBeNull();
+ });
+});
diff --git a/lib/desktop/__tests__/os-mail.test.ts b/lib/desktop/__tests__/os-mail.test.ts
new file mode 100644
index 000000000..ca3f25bf1
--- /dev/null
+++ b/lib/desktop/__tests__/os-mail.test.ts
@@ -0,0 +1,360 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { BulwarkDesktopBridge, DesktopOsNotification } from '../types';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+
+/** New mail to a native notification: which messages count, what the toast may say. */
+
+const playNotificationSound = vi.hoisted(() => vi.fn());
+vi.mock('@/lib/notification-sound', () => ({ playNotificationSound }));
+
+let pushWithoutContent = false;
+vi.mock('@/lib/device-policy', () => ({ devicesForAccount: () => ({ pushWithoutContent }) }));
+
+const accounts = [{ id: 'alice@one', cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org', label: 'Work', email: 'alice@example.org' }];
+let client: IJMAPClient;
+vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts }) } }));
+vi.mock('@/stores/auth-store', () => ({
+ useAuthStore: {
+ getState: () => ({
+ activeAccountId: 'alice@one',
+ client,
+ getClientForAccount: () => client,
+ }),
+ },
+}));
+
+const INBOX = { id: 'in', role: 'inbox', name: 'Inbox' } as unknown as Mailbox;
+const SENT = { id: 'out', role: 'sent', name: 'Sent' } as unknown as Mailbox;
+const NEWS = { id: 'news', name: 'News' } as unknown as Mailbox;
+const JUNK = { id: 'bad', role: 'junk', name: 'Junk' } as unknown as Mailbox;
+const MAILBOXES = [INBOX, SENT, NEWS, JUNK];
+
+function email(id: string, over: Partial = {}): Email {
+ return {
+ id,
+ threadId: `t-${id}`,
+ subject: `Secret ${id}`,
+ preview: 'preview text',
+ from: [{ name: 'Alice Sender', email: 'sender@example.org' }],
+ mailboxIds: { in: true },
+ keywords: {},
+ receivedAt: new Date().toISOString(),
+ ...over,
+ } as unknown as Email;
+}
+
+function fakeClient(mail: Record, changes: { created: string[]; newState: string }[] = []) {
+ const queue = [...changes];
+ return {
+ getAccountId: () => 'acc',
+ getAllMailboxes: vi.fn(async () => MAILBOXES),
+ getEmails: vi.fn(async () => ({ emails: [], hasMore: false, total: 0, state: 'seeded' })),
+ getEmailChanges: vi.fn(async (since: string) => {
+ const next = queue.shift();
+ return next ? { oldState: since, newState: next.newState, created: next.created, updated: [], destroyed: [], hasMoreChanges: false } : null;
+ }),
+ getSomeEmails: vi.fn(async (ids: string[]) => ids.flatMap((id) => mail[id] ?? [])),
+ } as unknown as IJMAPClient;
+}
+
+const labels = {
+ newMail: 'new_mail',
+ newMailCount: (count: number) => `count:${count}`,
+ noSubject: 'no_subject',
+ archive: 'archive',
+ markRead: 'mark_read',
+ delete: 'delete',
+ reply: 'reply',
+ replyPlaceholder: 'placeholder',
+};
+
+function setBridge(bridge: Partial | undefined) {
+ window.bulwarkDesktop = bridge as BulwarkDesktopBridge | undefined;
+}
+
+beforeEach(async () => {
+ vi.clearAllMocks();
+ pushWithoutContent = false;
+ const { useSettingsStore } = await import('@/stores/settings-store');
+ useSettingsStore.setState({
+ emailNotificationsEnabled: true,
+ emailNotificationSound: true,
+ doNotDisturb: false,
+ pushNotifyInboxOnly: true,
+ notificationMutes: { accounts: [], mailboxes: {} },
+ notificationSoundChoice: 'involved',
+ });
+ const { setAppLockCheck } = await import('../app-lock-state');
+ setAppLockCheck(null);
+});
+
+afterEach(() => {
+ setBridge(undefined);
+ vi.useRealTimers();
+});
+
+describe('which new messages notify', () => {
+ it('a new unread message in the Inbox', async () => {
+ const { notifyingMailbox } = await import('../new-mail');
+ expect(notifyingMailbox(email('a'), 'acc', 'acc', MAILBOXES)?.id).toBe('in');
+ });
+
+ it('nothing already read, a draft, or old mail coming back from an import', async () => {
+ const { notifyingMailbox } = await import('../new-mail');
+ expect(notifyingMailbox(email('a', { keywords: { $seen: true } }), 'acc', 'acc', MAILBOXES)).toBeNull();
+ expect(notifyingMailbox(email('a', { keywords: { $draft: true } }), 'acc', 'acc', MAILBOXES)).toBeNull();
+ const old = new Date(Date.now() - 3 * 86400_000).toISOString();
+ expect(notifyingMailbox(email('a', { receivedAt: old }), 'acc', 'acc', MAILBOXES)).toBeNull();
+ });
+
+ it('with Inbox only on, mail filed elsewhere stays silent; off, it notifies, but never Junk or Sent', async () => {
+ const { notifyingMailbox } = await import('../new-mail');
+ const { useSettingsStore } = await import('@/stores/settings-store');
+ const filed = email('a', { mailboxIds: { news: true } });
+ expect(notifyingMailbox(filed, 'acc', 'acc', MAILBOXES)).toBeNull();
+ useSettingsStore.setState({ pushNotifyInboxOnly: false });
+ expect(notifyingMailbox(filed, 'acc', 'acc', MAILBOXES)?.id).toBe('news');
+ expect(notifyingMailbox(email('b', { mailboxIds: { bad: true } }), 'acc', 'acc', MAILBOXES)).toBeNull();
+ expect(notifyingMailbox(email('c', { mailboxIds: { out: true } }), 'acc', 'acc', MAILBOXES)).toBeNull();
+ });
+
+ it('a muted folder or account stays silent', async () => {
+ const { notifyingMailbox } = await import('../new-mail');
+ const { useSettingsStore } = await import('@/stores/settings-store');
+ useSettingsStore.setState({ notificationMutes: { accounts: [], mailboxes: { 'acc/in': true } } });
+ expect(notifyingMailbox(email('a'), 'acc', 'acc', MAILBOXES)).toBeNull();
+ useSettingsStore.setState({ notificationMutes: { accounts: ['acc'], mailboxes: {} } });
+ expect(notifyingMailbox(email('a'), 'acc', 'acc', MAILBOXES)).toBeNull();
+ });
+});
+
+describe('the watch', () => {
+ const flush = async () => {
+ for (let i = 0; i < 20; i += 1) await Promise.resolve();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ };
+
+ it('resolves a push against its baseline, whatever page is open', async () => {
+ const { startNewMailWatch } = await import('../new-mail');
+ const { emitPushStateChange } = await import('@/lib/push-events');
+ const fake = fakeClient({ a: email('a') }, [{ created: ['a'], newState: 's2' }]);
+ const seen: string[] = [];
+ const watch = startNewMailWatch((mail) => seen.push(...mail.map((m) => m.email.id)));
+ watch.seed(fake);
+ await flush();
+ emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, fake, 'background');
+ await flush();
+ expect(fake.getEmailChanges).toHaveBeenCalledWith('seeded', 'acc', 200);
+ expect(seen).toEqual(['a']);
+ watch.stop();
+ });
+
+ it('tells a message once when the store and the push both report it', async () => {
+ const { startNewMailWatch } = await import('../new-mail');
+ const { emitPushStateChange } = await import('@/lib/push-events');
+ const fake = fakeClient({ a: email('a') }, [{ created: ['a'], newState: 's2' }]);
+ const seen: string[] = [];
+ const watch = startNewMailWatch((mail) => seen.push(...mail.map((m) => m.email.id)));
+ watch.seed(fake);
+ await flush();
+ watch.noteCreated(fake, 'acc', ['a']);
+ await flush();
+ emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, fake, 'active');
+ await flush();
+ expect(seen).toEqual(['a']);
+ watch.stop();
+ });
+
+ it('a message the store has not got yet is not written off: its created event still tells it', async () => {
+ const { startNewMailWatch } = await import('../new-mail');
+ const { emitPushStateChange } = await import('@/lib/push-events');
+ const held: Record = {};
+ const fake = fakeClient(held, [{ created: ['a'], newState: 's2' }]);
+ const seen: string[] = [];
+ const watch = startNewMailWatch((mail) => seen.push(...mail.map((m) => m.email.id)));
+ watch.seed(fake);
+ await flush();
+ // The push got here first. The device answers, and it doesn't have the message.
+ emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, fake, 'active');
+ await flush();
+ expect(seen).toEqual([]);
+ // The store has synced it now.
+ held.a = email('a');
+ watch.noteCreated(fake, 'acc', ['a']);
+ await flush();
+ expect(seen).toEqual(['a']);
+ watch.stop();
+ });
+
+ it('without a baseline the first push only sets one: old mail is not news', async () => {
+ const { startNewMailWatch } = await import('../new-mail');
+ const { emitPushStateChange } = await import('@/lib/push-events');
+ const fake = fakeClient({}, []);
+ const onMail = vi.fn();
+ const watch = startNewMailWatch(onMail);
+ emitPushStateChange({ changed: { acc: { Email: 's1' } } } as never, fake, 'active');
+ await flush();
+ expect(fake.getEmailChanges).not.toHaveBeenCalled();
+ expect(onMail).not.toHaveBeenCalled();
+ watch.stop();
+ });
+
+ it('ignores a change after it stopped', async () => {
+ const { startNewMailWatch } = await import('../new-mail');
+ const { emitPushStateChange } = await import('@/lib/push-events');
+ const fake = fakeClient({ a: email('a') }, [{ created: ['a'], newState: 's2' }]);
+ const onMail = vi.fn();
+ const watch = startNewMailWatch(onMail);
+ watch.seed(fake);
+ await flush();
+ watch.stop();
+ emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, fake, 'active');
+ await flush();
+ expect(onMail).not.toHaveBeenCalled();
+ });
+});
+
+describe('what the notification says', () => {
+ const one = (id = 'a', over: Partial = {}) => ({ client, accountId: 'acc', email: email(id, over) });
+
+ beforeEach(() => {
+ client = fakeClient({});
+ });
+
+ it('sender, subject, the buttons, and what a button needs after a restart', async () => {
+ const { buildMailNotification } = await import('../mail-notifications');
+ const { loginFingerprint } = await import('@/lib/pwa-notify/login-fingerprint');
+ const notification = await buildMailNotification([one()], labels);
+ expect(notification.title).toBe('Alice Sender');
+ expect(notification.body).toBe('Secret a');
+ expect(notification.actions?.map((a) => a.id)).toEqual(['archive', 'mark-read', 'delete']);
+ expect(notification.reply?.label).toBe('reply');
+ expect(JSON.parse(notification.context)).toEqual({
+ accountId: 'acc',
+ slot: 3,
+ login: await loginFingerprint('https://one.example', 'alice@example.org'),
+ emailId: 'a',
+ threadId: 't-a',
+ });
+ });
+
+ it('no Archive button where the organisation has it off', async () => {
+ const { buildMailNotification } = await import('../mail-notifications');
+ const notification = await buildMailNotification([one()], { ...labels, archive: null });
+ expect(notification.actions?.map((a) => a.id)).toEqual(['mark-read', 'delete']);
+ });
+
+ it('while the app is locked: no sender, no subject, no buttons', async () => {
+ const { buildMailNotification } = await import('../mail-notifications');
+ const { setAppLockCheck } = await import('../app-lock-state');
+ setAppLockCheck(() => true);
+ const notification = await buildMailNotification([one()], labels);
+ const text = JSON.stringify(notification);
+ expect(notification.title).toBe('new_mail');
+ expect(text).not.toContain('Alice Sender');
+ expect(text).not.toContain('Secret');
+ expect(notification.actions).toBeUndefined();
+ expect(notification.reply).toBeUndefined();
+ });
+
+ it('where the organisation keeps content off notifications: the same', async () => {
+ const { buildMailNotification } = await import('../mail-notifications');
+ pushWithoutContent = true;
+ const notification = await buildMailNotification([one()], labels);
+ expect(JSON.stringify(notification)).not.toContain('Secret');
+ expect(JSON.stringify(notification)).not.toContain('Alice Sender');
+ });
+
+ it('several at once are told about once, and that one has no message to act on', async () => {
+ const { buildMailNotification } = await import('../mail-notifications');
+ const notification = await buildMailNotification([one('a'), one('b'), one('c')], labels);
+ expect(notification.title).toBe('count:3');
+ expect(notification.actions).toBeUndefined();
+ expect(JSON.parse(notification.context).emailId).toBeUndefined();
+ });
+
+ it('several at once while locked say only how many', async () => {
+ const { buildMailNotification } = await import('../mail-notifications');
+ const { setAppLockCheck } = await import('../app-lock-state');
+ setAppLockCheck(() => true);
+ const notification = await buildMailNotification([one('a'), one('b')], labels);
+ expect(notification.title).toBe('count:2');
+ expect(notification.body).toBe('');
+ });
+});
+
+describe('from arrival to toast and sound', () => {
+ /** The login's fingerprint comes from crypto.subtle, which no fake timer moves. */
+ const settle = async () => {
+ const end = performance.now() + 80;
+ while (performance.now() < end) await new Promise((resolve) => setImmediate(resolve));
+ };
+
+ async function arrive(over: { result?: { shown: boolean; playSound: boolean } } = {}) {
+ const { startMailNotifications, MAIL_BATCH_MS } = await import('../mail-notifications');
+ const { emitPushStateChange } = await import('@/lib/push-events');
+ const notify = vi.fn(async (_n: DesktopOsNotification) => over.result ?? { shown: true, playSound: true });
+ setBridge({ version: 1, platform: 'win32', os: { notify } as never });
+ client = fakeClient({ a: email('a') }, [{ created: ['a'], newState: 's2' }]);
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
+ const mail = startMailNotifications(labels);
+ mail.seed(client);
+ await vi.advanceTimersByTimeAsync(10);
+ emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, client, 'active');
+ await vi.advanceTimersByTimeAsync(MAIL_BATCH_MS + 10);
+ await settle();
+ mail.stop();
+ return notify;
+ }
+
+ it('asks main, and plays the picked sound when main says so', async () => {
+ const notify = await arrive();
+ expect(notify).toHaveBeenCalledTimes(1);
+ expect(notify.mock.calls[0][0].sound).toBe(true);
+ expect(notify.mock.calls[0][0].attention).toBe(true);
+ expect(playNotificationSound).toHaveBeenCalledWith('involved');
+ });
+
+ it('no sound when main says it is quiet', async () => {
+ const notify = await arrive({ result: { shown: true, playSound: false } });
+ expect(notify).toHaveBeenCalledTimes(1);
+ expect(playNotificationSound).not.toHaveBeenCalled();
+ });
+
+ it('nothing at all with mail notifications off or the app\'s Do not disturb on', async () => {
+ const { useSettingsStore } = await import('@/stores/settings-store');
+ useSettingsStore.setState({ emailNotificationsEnabled: false });
+ expect(await arrive()).not.toHaveBeenCalled();
+ useSettingsStore.setState({ emailNotificationsEnabled: true, doNotDisturb: true });
+ expect(await arrive()).not.toHaveBeenCalled();
+ expect(playNotificationSound).not.toHaveBeenCalled();
+ });
+
+ it("the store's created event reaches a running watch, and nothing else", async () => {
+ const { startMailNotifications, MAIL_BATCH_MS } = await import('../mail-notifications');
+ const { reportStoreCreatedMail: noteStoreCreated } = await import('../system-events');
+ const notify = vi.fn(async () => ({ shown: true, playSound: false }));
+ setBridge({ version: 1, platform: 'win32', os: { notify } as never });
+ client = fakeClient({ a: email('a') });
+ noteStoreCreated(client, 'acc', ['a']);
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
+ const mail = startMailNotifications(labels);
+ noteStoreCreated(client, 'acc', ['a']);
+ await vi.advanceTimersByTimeAsync(MAIL_BATCH_MS + 10);
+ await settle();
+ expect(notify).toHaveBeenCalledTimes(1);
+ mail.stop();
+ noteStoreCreated(client, 'acc', ['b']);
+ await vi.advanceTimersByTimeAsync(MAIL_BATCH_MS + 10);
+ await settle();
+ expect(notify).toHaveBeenCalledTimes(1);
+ });
+
+ it('tells main the sound is off when the user has it off', async () => {
+ const { useSettingsStore } = await import('@/stores/settings-store');
+ useSettingsStore.setState({ emailNotificationSound: false });
+ const notify = await arrive({ result: { shown: true, playSound: false } });
+ expect(notify.mock.calls[0][0].sound).toBe(false);
+ });
+});
diff --git a/lib/desktop/__tests__/os-store.test.ts b/lib/desktop/__tests__/os-store.test.ts
new file mode 100644
index 000000000..cd876cfd2
--- /dev/null
+++ b/lib/desktop/__tests__/os-store.test.ts
@@ -0,0 +1,79 @@
+import 'fake-indexeddb/auto';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { StorePageHooks } from '@/lib/store/client/api';
+
+/** The local store and the OS integration: mail it found, and a machine going to sleep. */
+
+// The first import of offline-mode compiles a good part of the app.
+vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
+
+const configured = vi.hoisted(() => ({ hooks: null as StorePageHooks | null }));
+const setOnline = vi.hoisted(() => vi.fn(async () => {}));
+
+vi.mock('@/lib/store/client/api', async (original) => ({
+ ...(await original()),
+ configureStore: (hooks: StorePageHooks | null) => {
+ if (hooks) configured.hooks = hooks;
+ },
+ getStore: () => ({
+ supported: () => true,
+ setOnline,
+ ensureOpen: async () => {},
+ refresh: () => {},
+ sync: async () => {},
+ stop: () => {},
+ }),
+}));
+
+describe('the store and the OS integration', () => {
+ const client = { isOfflineOnly: () => false, getAccountId: () => 'acc' };
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ const { useAuthStore } = await import('@/stores/auth-store');
+ useAuthStore.setState({ getClientForAccount: () => client as never } as never);
+ const { registerOfflineHooks } = await import('@/lib/offline/offline-mode');
+ registerOfflineHooks();
+ });
+
+ afterEach(async () => {
+ const { setStoreCreatedListener } = await import('../system-events');
+ setStoreCreatedListener(null);
+ });
+
+ it("hands the store's new mail to the notification path, with the login's client", async () => {
+ const { setStoreCreatedListener } = await import('../system-events');
+ const listener = vi.fn();
+ setStoreCreatedListener(listener);
+ configured.hooks?.onEvent({ type: 'created', account: 'alice@one', jmap: 'acc', collection: 'Email', ids: ['e1', 'e2'] });
+ expect(listener).toHaveBeenCalledWith(client, 'acc', ['e1', 'e2']);
+ });
+
+ it('other collections are not mail', async () => {
+ const { setStoreCreatedListener } = await import('../system-events');
+ const listener = vi.fn();
+ setStoreCreatedListener(listener);
+ configured.hooks?.onEvent({ type: 'created', account: 'alice@one', jmap: 'acc', collection: 'CalendarEvent', ids: ['c1'] });
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ it('with nothing listening, as in a browser, it does nothing', () => {
+ expect(() => configured.hooks?.onEvent({ type: 'created', account: 'alice@one', jmap: 'acc', collection: 'Email', ids: ['e1'] })).not.toThrow();
+ });
+
+ it('a sleep takes the engine offline; waking brings it back and syncs through the events it already listens to', async () => {
+ const { startOfflineMode, offlineSupported } = await import('@/lib/offline/offline-mode');
+ expect(offlineSupported()).toBe(true);
+ const { runSleepHandlers } = await import('../system-events');
+ const stop = startOfflineMode();
+ runSleepHandlers();
+ expect(setOnline).toHaveBeenLastCalledWith(false);
+ window.bulwarkDesktop = { version: 1, platform: 'win32', os: { onPower: (listener: (event: string) => void) => { listener('resume'); return () => {}; } } } as never;
+ const { startPowerWatch } = await import('../power');
+ const stopPower = startPowerWatch();
+ expect(setOnline).toHaveBeenLastCalledWith(true);
+ stopPower();
+ stop();
+ window.bulwarkDesktop = undefined;
+ });
+});
diff --git a/lib/desktop/__tests__/os-window.test.tsx b/lib/desktop/__tests__/os-window.test.tsx
new file mode 100644
index 000000000..2e005476f
--- /dev/null
+++ b/lib/desktop/__tests__/os-window.test.tsx
@@ -0,0 +1,442 @@
+import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { BulwarkDesktopBridge, DesktopOsPowerEvent, DesktopOsStatus } from '../types';
+import type { CalendarEvent } from '@/lib/jmap/types';
+
+/** The window's end: badge, dropped and pasted files, drag-out, waking up, the settings section. */
+
+vi.mock('next-intl', () => ({
+ useTranslations: () => (key: string, values?: Record) => (values ? `${key}:${JSON.stringify(values)}` : key),
+ useLocale: () => 'en',
+}));
+
+function setBridge(bridge: Partial | undefined, platform: 'win32' | 'darwin' = 'win32') {
+ window.bulwarkDesktop = bridge ? ({ version: 1, platform, os: bridge } as unknown as BulwarkDesktopBridge) : undefined;
+}
+
+afterEach(() => {
+ setBridge(undefined);
+ vi.unstubAllGlobals();
+});
+
+describe('the badge', () => {
+ it('goes to the bridge in the app, and the Badging API is left alone', async () => {
+ const { useAppBadge } = await import('@/hooks/use-app-badge');
+ const setBadge = vi.fn();
+ const setAppBadge = vi.fn(async () => {});
+ Object.defineProperty(navigator, 'setAppBadge', { value: setAppBadge, configurable: true });
+ setBridge({ setBadge });
+ const { rerender } = renderHook(({ count, enabled }) => useAppBadge(count, enabled), { initialProps: { count: 4, enabled: true } });
+ expect(setBadge).toHaveBeenLastCalledWith(4);
+ rerender({ count: 4, enabled: false });
+ expect(setBadge).toHaveBeenLastCalledWith(0);
+ expect(setAppBadge).not.toHaveBeenCalled();
+ });
+
+ it('a pop-out has no say in it', async () => {
+ const { useAppBadge } = await import('@/hooks/use-app-badge');
+ const role = await import('@/lib/popout/window-role');
+ const popout = vi.spyOn(role, 'isPopoutWindow').mockReturnValue(true);
+ const setBadge = vi.fn();
+ setBridge({ setBadge });
+ renderHook(() => useAppBadge(4, true));
+ expect(setBadge).not.toHaveBeenCalled();
+ popout.mockRestore();
+ });
+});
+
+describe('new-mail sound', () => {
+ it('is left to the notification path in the app, not played twice', async () => {
+ const { desktopPlaysMailSound } = await import('../mail-notifications');
+ expect(desktopPlaysMailSound()).toBe(false);
+ setBridge({ notify: vi.fn() as never });
+ expect(desktopPlaysMailSound()).toBe(true);
+ });
+});
+
+describe('the policy keeps refreshing', () => {
+ const fetchPolicy = vi.fn(async () => {});
+
+ beforeEach(async () => {
+ vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'Date'] });
+ fetchPolicy.mockClear();
+ const { usePolicyStore } = await import('@/stores/policy-store');
+ usePolicyStore.setState({ loaded: true, fetching: false, fetchPolicy });
+ const { resetPolicyRefresh } = await import('@/hooks/use-policy-refresh');
+ resetPolicyRefresh();
+ Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ delete (document as unknown as Record).visibilityState;
+ });
+
+ it('in the desktop app while hidden in the tray', async () => {
+ const { POLICY_REFRESH_MS, usePolicyRefresh } = await import('@/hooks/use-policy-refresh');
+ setBridge({});
+ const { unmount } = renderHook(() => usePolicyRefresh());
+ vi.advanceTimersByTime(POLICY_REFRESH_MS);
+ expect(fetchPolicy).toHaveBeenCalledTimes(1);
+ unmount();
+ });
+
+ it('but not in a browser tab out of view', async () => {
+ const { POLICY_REFRESH_MS, usePolicyRefresh } = await import('@/hooks/use-policy-refresh');
+ const { unmount } = renderHook(() => usePolicyRefresh());
+ vi.advanceTimersByTime(POLICY_REFRESH_MS);
+ expect(fetchPolicy).not.toHaveBeenCalled();
+ unmount();
+ });
+});
+
+function dropEvent(type: string, files: File[]): Event {
+ const event = new Event(type, { bubbles: true, cancelable: true });
+ Object.defineProperty(event, 'dataTransfer', { value: { types: files.length ? ['Files'] : ['text/plain'], files, dropEffect: 'none' } });
+ return event;
+}
+
+describe('files on the window', () => {
+ const file = new File(['x'], 'report.pdf');
+
+ beforeEach(async () => {
+ const { clearHandoffs, takeHandoff } = await import('@/lib/launch-handoff');
+ clearHandoffs();
+ takeHandoff('compose');
+ });
+
+ it('nothing happens in a browser', async () => {
+ const { startWindowFiles } = await import('../window-files');
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ const stop = startWindowFiles();
+ const event = dropEvent('drop', [file]);
+ window.dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(false);
+ expect(takeHandoff('compose')).toBeNull();
+ stop();
+ });
+
+ it('a drop on the window starts a new draft with the file', async () => {
+ const { startWindowFiles } = await import('../window-files');
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ setBridge({});
+ const stop = startWindowFiles();
+ const over = dropEvent('dragover', [file]);
+ window.dispatchEvent(over);
+ expect(over.defaultPrevented).toBe(true);
+ const drop = dropEvent('drop', [file]);
+ window.dispatchEvent(drop);
+ expect(drop.defaultPrevented).toBe(true);
+ expect(takeHandoff('compose')?.files).toEqual([file]);
+ stop();
+ });
+
+ it('a drop something else took is left alone', async () => {
+ const { startWindowFiles } = await import('../window-files');
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ setBridge({});
+ const stop = startWindowFiles();
+ const event = dropEvent('drop', [file]);
+ event.preventDefault();
+ window.dispatchEvent(event);
+ expect(takeHandoff('compose')).toBeNull();
+ stop();
+ });
+
+ it('text dragged around is not a file', async () => {
+ const { startWindowFiles } = await import('../window-files');
+ setBridge({});
+ const stop = startWindowFiles();
+ const event = dropEvent('dragover', []);
+ window.dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(false);
+ stop();
+ });
+
+ it('an open composer takes the file itself', async () => {
+ const { setComposerFileTarget, startWindowFiles } = await import('../window-files');
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ setBridge({});
+ const stop = startWindowFiles();
+ const target = vi.fn();
+ const clear = setComposerFileTarget(target);
+ window.dispatchEvent(dropEvent('drop', [file]));
+ expect(target).toHaveBeenCalledWith([file]);
+ expect(takeHandoff('compose')).toBeNull();
+ clear();
+ stop();
+ });
+
+ it('a pasted file is taken; pasted text is not', async () => {
+ const { startWindowFiles } = await import('../window-files');
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ setBridge({});
+ const stop = startWindowFiles();
+ const paste = (files: File[]) => {
+ const event = new Event('paste', { bubbles: true, cancelable: true });
+ Object.defineProperty(event, 'clipboardData', { value: { files } });
+ window.dispatchEvent(event);
+ return event;
+ };
+ expect(paste([]).defaultPrevented).toBe(false);
+ expect(takeHandoff('compose')).toBeNull();
+ expect(paste([file]).defaultPrevented).toBe(true);
+ expect(takeHandoff('compose')?.files).toEqual([file]);
+ stop();
+ });
+
+ it('our own native drag over our own window is not a new file', async () => {
+ const { setOwnDragActive, startWindowFiles } = await import('../window-files');
+ const { takeHandoff } = await import('@/lib/launch-handoff');
+ setBridge({});
+ const stop = startWindowFiles();
+ setOwnDragActive(true);
+ window.dispatchEvent(dropEvent('drop', [file]));
+ expect(takeHandoff('compose')).toBeNull();
+ setOwnDragActive(false);
+ stop();
+ });
+});
+
+describe('dragging an attachment out', () => {
+ const blobUrl = 'blob:https://app/1';
+ const source = {
+ name: 'report.pdf',
+ type: 'application/pdf',
+ getBlobUrl: async () => blobUrl,
+ getBlob: async () => new Blob([new Uint8Array([1, 2, 3])]),
+ };
+
+ async function ready() {
+ const { useAttachmentDrag } = await import('@/hooks/use-attachment-drag');
+ const hook = renderHook(() => useAttachmentDrag(source, true));
+ act(() => hook.result.current.onPointerEnter());
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ return hook;
+ }
+
+ it('in the app: the OS\'s own drag, with the bytes for a real file', async () => {
+ const startDrag = vi.fn(async () => true);
+ setBridge({ startDrag, copyFiles: vi.fn() as never });
+ const hook = await ready();
+ const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } };
+ hook.result.current.onDragStart(event as never);
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(event.dataTransfer.setData).not.toHaveBeenCalled();
+ await waitFor(() => expect(startDrag).toHaveBeenCalled());
+ const [files] = startDrag.mock.calls[0] as unknown as [{ name: string; data: Uint8Array }[]];
+ expect(files[0].name).toBe('report.pdf');
+ expect([...files[0].data]).toEqual([1, 2, 3]);
+ });
+
+ it('in the app, a drag before the bytes are here starts nothing and fetches them for the next', async () => {
+ const startDrag = vi.fn(async () => true);
+ setBridge({ startDrag, copyFiles: vi.fn() as never });
+ const { useAttachmentDrag } = await import('@/hooks/use-attachment-drag');
+ const hook = renderHook(() => useAttachmentDrag(source, true));
+ const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } };
+ hook.result.current.onDragStart(event as never);
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(startDrag).not.toHaveBeenCalled();
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ hook.result.current.onDragStart(event as never);
+ await waitFor(() => expect(startDrag).toHaveBeenCalled());
+ });
+
+ it('in a browser: still DownloadURL, and no copy button', async () => {
+ const hook = await ready();
+ const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } };
+ hook.result.current.onDragStart(event as never);
+ expect(event.preventDefault).not.toHaveBeenCalled();
+ expect(event.dataTransfer.setData).toHaveBeenCalledWith('DownloadURL', `application/pdf:report.pdf:${blobUrl}`);
+ expect(hook.result.current.onCopyAsFile).toBeUndefined();
+ });
+
+ it('Copy as file hands the bytes to main', async () => {
+ const copyFiles = vi.fn(async () => true);
+ setBridge({ startDrag: vi.fn() as never, copyFiles });
+ const hook = await ready();
+ await expect(hook.result.current.onCopyAsFile?.()).resolves.toBe(true);
+ const [files] = copyFiles.mock.calls[0] as unknown as [{ name: string; data: Uint8Array }[]];
+ expect(files[0].name).toBe('report.pdf');
+ });
+});
+
+describe('waking up', () => {
+ async function started(os: Partial = {}) {
+ let fire: (event: DesktopOsPowerEvent) => void = () => {};
+ setBridge({ onPower: (listener: (event: DesktopOsPowerEvent) => void) => { fire = listener; return () => {}; }, ...os });
+ const power = await import('../power');
+ const events = await import('../system-events');
+ return { fire: (event: DesktopOsPowerEvent) => fire(event), power: events, stop: power.startPowerWatch() };
+ }
+
+ it('a resume checks for changes at once, the way a tab coming back does, and only once with the unlock', async () => {
+ const { fire, power, stop } = await started();
+ const { usePolicyStore } = await import('@/stores/policy-store');
+ const fetchPolicy = vi.fn(async () => {});
+ usePolicyStore.setState({ loaded: true, fetching: false, fetchPolicy });
+ const visible = vi.fn();
+ const online = vi.fn();
+ const wake = vi.fn();
+ document.addEventListener('visibilitychange', visible);
+ window.addEventListener('online', online);
+ const stopWake = power.addWakeHandler(wake);
+ fire('resume');
+ fire('unlock-screen');
+ expect(visible).toHaveBeenCalledTimes(1);
+ expect(online).toHaveBeenCalledTimes(1);
+ expect(fetchPolicy).toHaveBeenCalledTimes(1);
+ expect(wake).toHaveBeenCalledTimes(1);
+ document.removeEventListener('visibilitychange', visible);
+ window.removeEventListener('online', online);
+ stopWake();
+ stop();
+ });
+
+ it('a suspend tells the sleep handlers and wakes nothing', async () => {
+ const { fire, power, stop } = await started();
+ const sleep = vi.fn();
+ const visible = vi.fn();
+ const stopSleep = power.addSleepHandler(sleep);
+ document.addEventListener('visibilitychange', visible);
+ fire('suspend');
+ fire('lock-screen');
+ expect(sleep).toHaveBeenCalledTimes(1);
+ expect(visible).not.toHaveBeenCalled();
+ document.removeEventListener('visibilitychange', visible);
+ stopSleep();
+ stop();
+ });
+
+ it('a network change counts as waking', async () => {
+ const connection = new EventTarget();
+ vi.stubGlobal('navigator', Object.assign(Object.create(navigator), { connection }));
+ const { power, stop } = await started();
+ const wake = vi.fn();
+ const stopWake = power.addWakeHandler(wake);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ connection.dispatchEvent(new Event('change'));
+ expect(wake).toHaveBeenCalled();
+ stopWake();
+ stop();
+ });
+});
+
+describe('the summary', () => {
+ const event = (id: string, start: string, over: Partial = {}) => ({ id, title: id, start, utcStart: start, status: 'confirmed', showWithoutTime: false, ...over }) as unknown as CalendarEvent;
+ const now = new Date('2026-11-03T10:00:00Z').getTime();
+
+ it('the next event is the soonest one still ahead, not cancelled and not all-day', async () => {
+ const { nextEventOf } = await import('../os-summary');
+ const events = [
+ event('later', '2026-11-03T15:00:00Z'),
+ event('past', '2026-11-03T09:00:00Z'),
+ event('cancelled', '2026-11-03T11:00:00Z', { status: 'cancelled' }),
+ event('allday', '2026-11-03T10:30:00Z', { showWithoutTime: true }),
+ event('soon', '2026-11-03T12:00:00Z'),
+ ];
+ expect(nextEventOf(events, now)?.id).toBe('soon');
+ expect(nextEventOf([event('past', '2026-11-03T09:00:00Z')], now)).toBeNull();
+ });
+
+ describe('with a login', () => {
+ const row = { id: 'e1', threadId: 't1', subject: 'Lunch?', from: [{ name: 'Alice', email: 'a@example.org' }] };
+ const getEmails = vi.fn(async () => ({ emails: [row], hasMore: false, total: 1 }));
+ const fake = { getAccountId: () => 'acc', getEmails, queryCalendarEvents: vi.fn(async () => []) };
+
+ beforeEach(async () => {
+ getEmails.mockClear();
+ const { useAuthStore } = await import('@/stores/auth-store');
+ const { useAccountStore } = await import('@/stores/account-store');
+ const { useEmailStore } = await import('@/stores/email-store');
+ useAuthStore.setState({ isAuthenticated: true, client: fake as never, activeAccountId: 'alice@one' });
+ useAccountStore.setState({ accounts: [{ id: 'alice@one', cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org' }] as never });
+ useEmailStore.setState({ mailboxes: [{ id: 'in', role: 'inbox', name: 'Inbox' }] as never });
+ });
+
+ afterEach(async () => {
+ const { useAuthStore } = await import('@/stores/auth-store');
+ useAuthStore.setState({ isAuthenticated: false, client: null, activeAccountId: null });
+ const { setAppLockCheck } = await import('../app-lock-state');
+ setAppLockCheck(null);
+ });
+
+ it('lists unread mail with what the tray item needs to open it', async () => {
+ const { readSummary } = await import('../os-summary');
+ const summary = await readSummary('en');
+ 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' });
+ });
+
+ it('while locked it is empty, without asking anyone anything', async () => {
+ const { setAppLockCheck } = await import('../app-lock-state');
+ const { readSummary, EMPTY_SUMMARY } = await import('../os-summary');
+ setAppLockCheck(() => true);
+ await expect(readSummary('en')).resolves.toEqual(EMPTY_SUMMARY);
+ expect(getEmails).not.toHaveBeenCalled();
+ });
+ });
+});
+
+describe('the settings section', () => {
+ const settings = { closeToTray: true, startAtLogin: false, startMinimized: false, attentionOnNewMail: true, quietHoursEnabled: false, quietHoursStart: '22:00', quietHoursEnd: '07:00', showShortcut: null };
+ const status = (over = {}): DesktopOsStatus => ({ settings: { ...settings, ...over }, pausedUntil: null, shortcutActive: false });
+
+ function bridgeFor(current: DesktopOsStatus) {
+ return {
+ getStatus: vi.fn(async () => current),
+ setSettings: vi.fn(async (patch: Partial) => ({ ...current, settings: { ...current.settings, ...patch } })),
+ onStatusChange: vi.fn(() => () => {}),
+ defaultAppStatus: vi.fn(async () => ({ registered: false, mailto: false, webcal: false })),
+ openDefaultAppSettings: vi.fn(async () => ({ registered: true, mailto: false, webcal: false })),
+ };
+ }
+
+ it('a browser never sees it', async () => {
+ const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings');
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('in the app it shows the state and changes it', async () => {
+ const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings');
+ const os = bridgeFor(status());
+ setBridge(os as never);
+ render();
+ expect(await screen.findByText('section_title')).toBeInTheDocument();
+ expect(screen.queryByText('start_minimized_label')).toBeNull();
+ // Second in the section, after Close to tray.
+ fireEvent.click(screen.getAllByRole('switch')[1]);
+ await waitFor(() => expect(os.setSettings).toHaveBeenCalledWith({ startAtLogin: true }));
+ expect(await screen.findByText('start_minimized_label')).toBeInTheDocument();
+ });
+
+ it('the button offers the app and opens where the choice is made', async () => {
+ const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings');
+ const os = bridgeFor(status());
+ setBridge(os as never);
+ render();
+ fireEvent.click(await screen.findByRole('button', { name: 'default_app_button' }));
+ await waitFor(() => expect(os.openDefaultAppSettings).toHaveBeenCalled());
+ });
+
+ it('says when another app holds the shortcut', async () => {
+ const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings');
+ setBridge(bridgeFor(status({ showShortcut: 'CommandOrControl+Shift+B' })) as never);
+ render();
+ expect(await screen.findByText(/show_shortcut_taken/)).toBeInTheDocument();
+ });
+
+ 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();
+ expect(await screen.findByText('attention_mac_label')).toBeInTheDocument();
+ });
+});
diff --git a/lib/desktop/app-lock-state.ts b/lib/desktop/app-lock-state.ts
new file mode 100644
index 000000000..72432d62b
--- /dev/null
+++ b/lib/desktop/app-lock-state.ts
@@ -0,0 +1,19 @@
+/**
+ * Whether the app is locked, for everything that would show mail outside the app.
+ *
+ * App lock isn't built yet. When it is, its code calls setAppLockCheck once with a
+ * function that answers from its own state, and calls bridge.os.setLocked on every
+ * change: main then clears the tray, the jump list and delivered toasts, and strips
+ * anything that still comes through. Nothing else needs to change. Until then it
+ * answers false.
+ */
+
+let check: (() => boolean) | null = null;
+
+export function setAppLockCheck(next: (() => boolean) | null): void {
+ check = next;
+}
+
+export function isAppLocked(): boolean {
+ return check?.() === true;
+}
diff --git a/lib/desktop/file-drag.ts b/lib/desktop/file-drag.ts
new file mode 100644
index 000000000..07084646c
--- /dev/null
+++ b/lib/desktop/file-drag.ts
@@ -0,0 +1,67 @@
+/**
+ * Mail as real files from the page's side: a native drag or a copy to the clipboard.
+ * Later agents' calls for the taskbar's progress bar live here too.
+ */
+
+import { getDesktopBridge } from '@/lib/desktop/bridge';
+import { setOwnDragActive } from '@/lib/desktop/window-files';
+
+/** The page's CSP keeps fetch off blob: URLs, so a file travels as its Blob. */
+export interface DragFile {
+ name: string;
+ blob: Blob;
+}
+
+const keptBlobs = new Map();
+
+/** In the app, remembers what's behind an object URL made for dragging. */
+export function keepDragBlob(url: string, blob: Blob): string {
+ if (canDragNativeFiles()) keptBlobs.set(url, blob);
+ return url;
+}
+
+export function keptDragBlob(url: string): Blob | undefined {
+ return keptBlobs.get(url);
+}
+
+export function forgetDragBlob(url: string): void {
+ keptBlobs.delete(url);
+}
+
+export function canDragNativeFiles(): boolean {
+ return !!getDesktopBridge()?.os?.startDrag;
+}
+
+async function toBytes(file: DragFile): Promise<{ name: string; data: Uint8Array }> {
+ return { name: file.name, data: new Uint8Array(await file.blob.arrayBuffer()) };
+}
+
+/**
+ * Hands the file to main, which writes it to its private folder and starts the OS's
+ * drag. The mouse is still down, so this has to come straight from `dragstart`.
+ */
+export async function startNativeFileDrag(files: readonly DragFile[]): Promise {
+ const os = getDesktopBridge()?.os;
+ if (!os?.startDrag) return false;
+ setOwnDragActive(true);
+ try {
+ return await os.startDrag(await Promise.all(files.map(toBytes)));
+ } finally {
+ setOwnDragActive(false);
+ }
+}
+
+/** Copy attachments as files, for pasting into Explorer or Finder. */
+export async function copyFilesToClipboard(files: readonly DragFile[]): Promise {
+ const os = getDesktopBridge()?.os;
+ if (!os?.copyFiles) return false;
+ return os.copyFiles(await Promise.all(files.map(toBytes)));
+}
+
+/**
+ * The taskbar's progress bar, for an import, an export or a big send. 0 to 1, or null
+ * to take it away. Does nothing in a browser.
+ */
+export function setTaskbarProgress(progress: number | null, mode?: 'normal' | 'indeterminate' | 'paused' | 'error'): void {
+ getDesktopBridge()?.os?.setProgress(progress, mode);
+}
diff --git a/lib/desktop/mail-links.ts b/lib/desktop/mail-links.ts
new file mode 100644
index 000000000..12416ef30
--- /dev/null
+++ b/lib/desktop/mail-links.ts
@@ -0,0 +1,44 @@
+/**
+ * Opening a message or folder from outside the mail page: a tray item, a toast, a jump
+ * list entry. The mail page applies a link the way it applies a permalink. If it isn't
+ * on screen, the link waits and the app goes there.
+ */
+
+import type { MailDeepLink } from '@/lib/deep-links';
+
+export interface MailLinkOptions {
+ replyText?: string;
+}
+
+export type MailLinkOpener = (link: MailDeepLink, options?: MailLinkOptions) => void;
+
+let opener: MailLinkOpener | null = null;
+let waiting: { link: MailDeepLink; options?: MailLinkOptions } | null = null;
+
+/** The mail page, while it's mounted. Takes over a link that was waiting for it. */
+export function setMailLinkOpener(next: MailLinkOpener): () => void {
+ opener = next;
+ if (waiting) {
+ const { link, options } = waiting;
+ waiting = null;
+ next(link, options);
+ }
+ return () => {
+ if (opener === next) opener = null;
+ };
+}
+
+export function openMailLink(link: MailDeepLink, options: MailLinkOptions | undefined, leaveFor: (path: '/') => void | Promise): void {
+ if (opener) {
+ opener(link, options);
+ return;
+ }
+ waiting = { link, options };
+ void leaveFor('/');
+}
+
+/** For tests. */
+export function resetMailLinks(): void {
+ opener = null;
+ waiting = null;
+}
diff --git a/lib/desktop/mail-notifications.ts b/lib/desktop/mail-notifications.ts
new file mode 100644
index 000000000..05b3303ba
--- /dev/null
+++ b/lib/desktop/mail-notifications.ts
@@ -0,0 +1,174 @@
+/**
+ * New mail as native notifications, with the same rules as the app's other ways of
+ * saying so. Mail notifications off or the app's Do not disturb: nothing. Mail that
+ * arrives together is told about once. Sender and subject stay off the notification
+ * when the app is locked, or when the organisation says so for that login
+ * (pushWithoutContent, the rule the push preview route follows).
+ *
+ * Main decides what the OS allows: pause, quiet hours, Do Not Disturb. It answers
+ * whether the page should play the sound the user picked, because this is the one
+ * place that sound is played from in the desktop app.
+ */
+
+import { getDesktopBridge } from '@/lib/desktop/bridge';
+import type { DesktopOsNotification } from '@/lib/desktop/types';
+import { isAppLocked } from '@/lib/desktop/app-lock-state';
+import { startNewMailWatch, type NewMail, type NewMailWatch } from '@/lib/desktop/new-mail';
+import { setStoreCreatedListener } from '@/lib/desktop/system-events';
+import { devicesForAccount } from '@/lib/device-policy';
+import { playNotificationSound } from '@/lib/notification-sound';
+import { isPopoutWindow } from '@/lib/popout/window-role';
+import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
+import { useAccountStore } from '@/stores/account-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+
+export const MAIL_BATCH_MS = 3000;
+
+export interface MailNotificationLabels {
+ newMail: string;
+ newMailCount: (count: number) => string;
+ noSubject: string;
+ archive: string | null;
+ markRead: string;
+ delete: string;
+ reply: string;
+ replyPlaceholder: string;
+}
+
+/** What a toast's button hands back, possibly after the app restarted. */
+export interface MailNotificationContext {
+ emailId?: string;
+ threadId?: string;
+ accountId?: string;
+ slot?: number;
+ login?: string;
+}
+
+/** True where this module plays the new-mail sound, so useNewMailAlerts doesn't as well. */
+export function desktopPlaysMailSound(): boolean {
+ return !!getDesktopBridge()?.os && !isPopoutWindow();
+}
+
+interface Sender {
+ entryId: string | null;
+ slot?: number;
+ login?: string;
+ label: string | null;
+}
+
+async function senderInfo(client: IJMAPClient): Promise {
+ const auth = useAuthStore.getState();
+ const accounts = useAccountStore.getState().accounts;
+ const entry = accounts.find((account) => auth.getClientForAccount(account.id) === client)
+ ?? (auth.client === client ? accounts.find((account) => account.id === auth.activeAccountId) : undefined);
+ if (!entry) return { entryId: null, label: null };
+ return {
+ entryId: entry.id,
+ slot: entry.cookieSlot,
+ login: await loginFingerprint(entry.serverUrl, entry.username),
+ label: accounts.length > 1 ? (entry.label || entry.email || entry.username || null) : null,
+ };
+}
+
+function withoutContent(sender: Sender): boolean {
+ if (isAppLocked()) return true;
+ // Unknown counts as on, as on the server's route.
+ if (!sender.entryId) return true;
+ try {
+ return devicesForAccount(sender.entryId).pushWithoutContent;
+ } catch {
+ return true;
+ }
+}
+
+function senderName(mail: NewMail): string {
+ const first = mail.email.from?.[0];
+ return first?.name || first?.email || '';
+}
+
+export async function buildMailNotification(batch: readonly NewMail[], labels: MailNotificationLabels): Promise {
+ const newest = batch.reduce((a, b) => (new Date(b.email.receivedAt ?? 0).getTime() > new Date(a.email.receivedAt ?? 0).getTime() ? b : a));
+ const sender = await senderInfo(newest.client);
+ const hidden = withoutContent(sender);
+ const context: MailNotificationContext = { accountId: newest.accountId, slot: sender.slot, login: sender.login };
+ const base = {
+ id: `mail:${newest.accountId}/${newest.email.id}`,
+ group: `mail:${newest.accountId}`,
+ sound: true,
+ attention: true,
+ };
+ const suffix = sender.label && !hidden ? ` (${sender.label})` : '';
+ if (batch.length > 1) {
+ return {
+ ...base,
+ title: `${labels.newMailCount(batch.length)}${suffix}`,
+ body: hidden ? '' : `${senderName(newest)}: ${newest.email.subject || labels.noSubject}`,
+ // Nothing for one message to act on: a click opens the Inbox.
+ context: JSON.stringify(context),
+ };
+ }
+ const full: MailNotificationContext = { ...context, emailId: newest.email.id, threadId: newest.email.threadId };
+ if (hidden) {
+ return { ...base, title: labels.newMail, body: '', context: JSON.stringify(full) };
+ }
+ return {
+ ...base,
+ title: `${senderName(newest) || labels.newMail}${suffix}`,
+ body: newest.email.subject || newest.email.preview || labels.noSubject,
+ context: JSON.stringify(full),
+ actions: [
+ ...(labels.archive ? [{ id: 'archive', label: labels.archive }] : []),
+ { id: 'mark-read', label: labels.markRead },
+ { id: 'delete', label: labels.delete },
+ ],
+ reply: { label: labels.reply, placeholder: labels.replyPlaceholder },
+ };
+}
+
+export interface MailNotifications extends NewMailWatch {
+ setLabels(labels: MailNotificationLabels): void;
+}
+
+export function startMailNotifications(initial: MailNotificationLabels): MailNotifications {
+ let labels = initial;
+ let batch: NewMail[] = [];
+ let timer: ReturnType | null = null;
+
+ const flush = () => {
+ timer = null;
+ const current = batch;
+ batch = [];
+ if (current.length === 0) return;
+ const settings = useSettingsStore.getState();
+ if (!settings.emailNotificationsEnabled || settings.doNotDisturb) return;
+ void (async () => {
+ const notification = await buildMailNotification(current, labels);
+ const result = await getDesktopBridge()?.os?.notify({ ...notification, sound: settings.emailNotificationSound });
+ if (result?.playSound) playNotificationSound(settings.notificationSoundChoice);
+ })().catch((error) => {
+ console.error('Failed to show a notification:', error);
+ });
+ };
+
+ const watch = startNewMailWatch((mail) => {
+ batch.push(...mail);
+ timer ??= setTimeout(flush, MAIL_BATCH_MS);
+ });
+
+ const handle: MailNotifications = {
+ ...watch,
+ setLabels(next) {
+ labels = next;
+ },
+ stop() {
+ watch.stop();
+ if (timer) clearTimeout(timer);
+ timer = null;
+ setStoreCreatedListener(null);
+ },
+ };
+ setStoreCreatedListener((client, accountId, ids) => watch.noteCreated(client, accountId, ids));
+ return handle;
+}
diff --git a/lib/desktop/new-mail.ts b/lib/desktop/new-mail.ts
new file mode 100644
index 000000000..efe23173d
--- /dev/null
+++ b/lib/desktop/new-mail.ts
@@ -0,0 +1,151 @@
+/**
+ * Which messages count as new mail for the desktop app's notifications, for every
+ * connected login, whichever page or folder is open.
+ *
+ * With the local store the source is its `created` event (noteCreatedEmails): the
+ * engine has already worked out the delta. Without one, each login's push change is
+ * resolved here with Email/changes on a baseline of its own, the way
+ * reportNewInboxMail does for plugins. Both can see the same message; it's only told
+ * once.
+ */
+
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+import { isMailboxMuted } from '@/lib/notification-mutes';
+import { subscribePushStateChanges } from '@/lib/push-events';
+import { useSettingsStore } from '@/stores/settings-store';
+
+export interface NewMail {
+ client: IJMAPClient;
+ /** The JMAP account the message is in. */
+ accountId: string;
+ email: Email;
+}
+
+const MAX_PAGES = 5;
+const MAX_CHANGES = 200;
+const SEEN_LIMIT = 500;
+/** An import or a restore creates old mail: nobody is waiting to hear about that. */
+const NEWEST_AGE_MS = 24 * 60 * 60 * 1000;
+/** Folders mail lands in by itself, when not only the Inbox notifies. */
+const NOT_ARRIVALS = new Set(['junk', 'trash', 'sent', 'drafts', 'archive']);
+
+function mailboxIdsOf(email: Email): string[] {
+ return Object.keys(email.mailboxIds ?? {}).filter((id) => email.mailboxIds[id]);
+}
+
+/** The folder a new message counts for, or null if it stays silent. */
+export function notifyingMailbox(email: Email, accountId: string, ownAccountId: string, mailboxes: readonly Mailbox[], now = Date.now()): Mailbox | null {
+ if (email.keywords?.$seen || email.keywords?.$draft) return null;
+ const received = email.receivedAt ? new Date(email.receivedAt).getTime() : now;
+ if (now - received > NEWEST_AGE_MS) return null;
+ const { notificationMutes, pushNotifyInboxOnly } = useSettingsStore.getState();
+ const here = new Set(mailboxIdsOf(email));
+ const mine = mailboxes.filter((mailbox) => (mailbox.accountId ?? ownAccountId) === accountId && here.has(mailbox.originalId || mailbox.id));
+ const counts = mine.filter((mailbox) => (pushNotifyInboxOnly ? mailbox.role === 'inbox' : !NOT_ARRIVALS.has(mailbox.role ?? '')));
+ // Mail filed in a muted folder and an unmuted one still notifies.
+ return counts.find((mailbox) => !isMailboxMuted(notificationMutes, accountId, mailbox.originalId || mailbox.id)) ?? null;
+}
+
+export interface NewMailWatch {
+ /** The local store's `created` event for this login's account. */
+ noteCreated(client: IJMAPClient, accountId: string, ids: readonly string[]): void;
+ /** Takes a state to work from, so the first push after the start can tell what's new. */
+ seed(client: IJMAPClient): void;
+ stop(): void;
+}
+
+export function startNewMailWatch(onMail: (mail: NewMail[]) => void): NewMailWatch {
+ const baselines = new WeakMap>();
+ const seen: string[] = [];
+ let queue: Promise = Promise.resolve();
+ let stopped = false;
+
+ const baselinesOf = (client: IJMAPClient) => {
+ let map = baselines.get(client);
+ if (!map) {
+ map = new Map();
+ baselines.set(client, map);
+ }
+ return map;
+ };
+
+ const enqueue = (run: () => Promise) => {
+ const next = queue.then(run, run).catch((error) => {
+ console.error('Failed to check for new mail:', error);
+ });
+ queue = next;
+ return next;
+ };
+
+ const report = async (client: IJMAPClient, accountId: string, ids: readonly string[]) => {
+ const fresh = ids.filter((id) => !seen.includes(`${accountId}/${id}`));
+ if (fresh.length === 0 || stopped) return;
+ const [emails, mailboxes] = await Promise.all([client.getSomeEmails(fresh, accountId), client.getAllMailboxes()]);
+ // Only what came back counts as told. With the store on, a push can overtake it: the
+ // device doesn't have the message yet, and its created event has to be able to tell it.
+ for (const email of emails) seen.push(`${accountId}/${email.id}`);
+ seen.splice(0, Math.max(0, seen.length - SEEN_LIMIT));
+ const own = client.getAccountId();
+ const mail = emails
+ .filter((email) => notifyingMailbox(email, accountId, own, mailboxes))
+ .map((email) => ({ client, accountId, email }));
+ if (mail.length > 0 && !stopped) onMail(mail);
+ };
+
+ const check = async (client: IJMAPClient, accountId: string, pushed: string) => {
+ const map = baselinesOf(client);
+ const since = map.get(accountId);
+ if (!since || !client.getEmailChanges) {
+ map.set(accountId, pushed);
+ return;
+ }
+ if (since === pushed) return;
+ const created: string[] = [];
+ let state = since;
+ for (let page = 0; page < MAX_PAGES; page += 1) {
+ const delta = await client.getEmailChanges(state, accountId, MAX_CHANGES);
+ if (!delta) {
+ // Too old for the server: start again from here.
+ map.set(accountId, pushed);
+ return;
+ }
+ created.push(...delta.created);
+ state = delta.newState;
+ if (!delta.hasMoreChanges) break;
+ }
+ map.set(accountId, state);
+ await report(client, accountId, created);
+ };
+
+ const stopPush = subscribePushStateChanges((change, client) => {
+ for (const [accountId, types] of Object.entries(change.changed ?? {})) {
+ const pushed = types?.Email;
+ if (pushed) void enqueue(() => check(client, accountId, pushed));
+ }
+ });
+
+ return {
+ noteCreated(client, accountId, ids) {
+ void enqueue(() => report(client, accountId, ids));
+ },
+ seed(client) {
+ void enqueue(async () => {
+ const map = baselinesOf(client);
+ const own = client.getAccountId();
+ const mailboxes = await client.getAllMailboxes();
+ const inboxes = mailboxes.filter((mailbox) => mailbox.role === 'inbox');
+ await Promise.all(inboxes.map(async (inbox) => {
+ const accountId = inbox.accountId ?? own;
+ if (map.has(accountId)) return;
+ const { state } = await client.getEmails(inbox.originalId || inbox.id, accountId, 1);
+ if (state && !map.has(accountId)) map.set(accountId, state);
+ }));
+ });
+ },
+ stop() {
+ stopped = true;
+ stopPush();
+ },
+ };
+}
diff --git a/lib/desktop/os-commands.ts b/lib/desktop/os-commands.ts
new file mode 100644
index 000000000..8c7332225
--- /dev/null
+++ b/lib/desktop/os-commands.ts
@@ -0,0 +1,181 @@
+/**
+ * What the OS asks of the app, carried out with what the app already has: the launch
+ * handoff for files and new messages, the protocol handlers for mailto: and webcal:,
+ * the notification actions for a toast's buttons. A bridge command comes from the
+ * user's own OS, so it skips the question a web share gets (decision 17).
+ */
+
+import { getDesktopBridge } from '@/lib/desktop/bridge';
+import type { DesktopOsCommand } from '@/lib/desktop/types';
+import { openMailLink } from '@/lib/desktop/mail-links';
+import type { MailNotificationContext } from '@/lib/desktop/mail-notifications';
+import { handOffDraft, handOffFiles, handOffNewMessage, kindForFile } from '@/lib/launch-handoff';
+import { parseMailto } from '@/lib/protocol-handlers/mailto';
+import { notifyPendingMailto, notifyPendingWebcal, savePendingMailto, savePendingWebcal } from '@/lib/protocol-handlers/session';
+import { parseWebcal } from '@/lib/protocol-handlers/webcal';
+import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
+import { runNotificationAction, type NotificationActionRequest, type NotificationActionStatus } from '@/lib/pwa-notify/page-actions';
+import { useAccountStore } from '@/stores/account-store';
+import { useAuthStore } from '@/stores/auth-store';
+
+export interface OsCommandEnv {
+ /** Goes to a page, saving an open composer first. */
+ leaveFor: (path: '/' | '/calendar' | '/contacts') => void | Promise;
+ /** A route change that isn't leaving anything behind. */
+ push: (path: string) => void;
+ /** Says something couldn't be opened. */
+ notifyFailed: (names: readonly string[]) => void;
+}
+
+/** How long a toast's button waits for a cold start to sign in. */
+const SIGN_IN_WAIT_MS = 30_000;
+
+function parseContext(raw: string | undefined): MailNotificationContext {
+ if (!raw) return {};
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ return parsed && typeof parsed === 'object' ? parsed as MailNotificationContext : {};
+ } catch {
+ return {};
+ }
+}
+
+function whenSignedIn(): Promise {
+ return new Promise((resolve) => {
+ if (useAuthStore.getState().isAuthenticated) {
+ resolve(true);
+ return;
+ }
+ const finish = (value: boolean) => {
+ clearTimeout(timer);
+ stop();
+ resolve(value);
+ };
+ const timer = setTimeout(() => finish(false), SIGN_IN_WAIT_MS);
+ const stop = useAuthStore.subscribe((state) => {
+ if (state.isAuthenticated) finish(true);
+ });
+ });
+}
+
+/** A slot goes to whoever signs in next: a day-old toast has to still mean this login. */
+async function stillThatLogin(context: MailNotificationContext): Promise {
+ if (context.slot === undefined || !context.login) return false;
+ const entry = useAccountStore.getState().accounts.find((account) => account.cookieSlot === context.slot);
+ return !!entry && (await loginFingerprint(entry.serverUrl, entry.username)) === context.login;
+}
+
+function showWindow(): void {
+ getDesktopBridge()?.windows?.focus?.();
+}
+
+async function openFromNotification(context: MailNotificationContext, env: OsCommandEnv, reply?: { text: string }): Promise {
+ showWindow();
+ await whenSignedIn();
+ const known = await stillThatLogin(context);
+ if (known && context.emailId) {
+ openMailLink({ kind: 'message', id: context.emailId, slot: context.slot, ownerAccountId: context.accountId, ...(reply ? { reply: true } : {}) }, reply ? { replyText: reply.text } : undefined, env.leaveFor);
+ return;
+ }
+ openMailLink({ kind: 'folder', ref: 'inbox', ...(known ? { slot: context.slot } : {}) }, undefined, env.leaveFor);
+}
+
+function runAction(request: NotificationActionRequest): Promise {
+ return new Promise((resolve) => {
+ void runNotificationAction(request, (status) => {
+ if (status !== 'accepted') resolve(status);
+ });
+ });
+}
+
+async function handleNotification(action: string, rawContext: string, reply: string | undefined, env: OsCommandEnv): Promise {
+ const context = parseContext(rawContext);
+ if (action === 'reply') {
+ await openFromNotification(context, env, { text: reply ?? '' });
+ return;
+ }
+ if ((action === 'archive' || action === 'mark-read' || action === 'delete') && context.emailId && context.accountId) {
+ if (await whenSignedIn()) {
+ const status = await runAction({
+ action,
+ emailId: context.emailId,
+ accountId: context.accountId,
+ threadId: context.threadId,
+ slot: context.slot,
+ login: context.login,
+ at: Date.now(),
+ });
+ if (status === 'done') return;
+ }
+ }
+ // Clicked, or the button couldn't be carried out here: the message opens.
+ await openFromNotification(context, env);
+}
+
+function handleFiles(command: Extract, env: OsCommandEnv): void {
+ const made = command.files.map(({ name, data }) => new File([new Uint8Array(data)], name));
+ const failed = [...command.failed];
+ if (command.reason === 'attach') {
+ if (made.length > 0) handOffDraft({ files: made });
+ } else {
+ for (const file of made) {
+ const kind = kindForFile(file);
+ if (kind) handOffFiles([file], kind);
+ else failed.push(file.name);
+ }
+ }
+ if (failed.length > 0) env.notifyFailed(failed);
+}
+
+export function handleOsCommand(command: DesktopOsCommand, env: OsCommandEnv): void | Promise {
+ switch (command.type) {
+ case 'new-message':
+ handOffNewMessage();
+ return;
+ case 'navigate':
+ if (command.target === 'inbox') openMailLink({ kind: 'folder', ref: 'inbox' }, undefined, env.leaveFor);
+ else void env.leaveFor(`/${command.target}`);
+ return;
+ case 'open-message': {
+ const context = parseContext(command.context);
+ if (context.slot !== undefined) return openFromNotification({ ...context, emailId: command.emailId }, env);
+ openMailLink({ kind: 'message', id: command.emailId, ownerAccountId: command.accountId }, undefined, env.leaveFor);
+ return;
+ }
+ case 'open-mailbox':
+ openMailLink({ kind: 'folder', ref: command.mailboxId }, undefined, env.leaveFor);
+ return;
+ case 'mailto': {
+ const parsed = parseMailto(command.url);
+ if (!parsed) return;
+ savePendingMailto(parsed);
+ notifyPendingMailto();
+ env.push('/');
+ return;
+ }
+ case 'webcal': {
+ const parsed = parseWebcal(command.url);
+ if (!parsed) return;
+ savePendingWebcal(parsed);
+ notifyPendingWebcal();
+ env.push('/calendar');
+ return;
+ }
+ case 'files':
+ handleFiles(command, env);
+ return;
+ case 'notification':
+ return handleNotification(command.action, command.context, command.reply, env);
+ }
+}
+
+/** Listens for as long as the page lives. Commands wait in main until this runs. */
+export function startOsCommands(env: OsCommandEnv): () => void {
+ const os = getDesktopBridge()?.os;
+ if (!os) return () => {};
+ return os.onCommand((command) => {
+ void Promise.resolve(handleOsCommand(command, env)).catch((error) => {
+ console.error('An OS command failed:', error);
+ });
+ });
+}
diff --git a/lib/desktop/os-summary.ts b/lib/desktop/os-summary.ts
new file mode 100644
index 000000000..12d014051
--- /dev/null
+++ b/lib/desktop/os-summary.ts
@@ -0,0 +1,130 @@
+/**
+ * What the tray and the jump list show: recent unread mail, the newest conversations,
+ * the next calendar event. Read with the same client calls as the rest of the app, so
+ * the local store answers when it has them. No content while locked or where the
+ * organisation keeps it off notifications.
+ */
+
+import { getDesktopBridge } from '@/lib/desktop/bridge';
+import { isAppLocked } from '@/lib/desktop/app-lock-state';
+import type { DesktopOsSummary, DesktopOsTrayMessage } from '@/lib/desktop/types';
+import { devicesForAccount } from '@/lib/device-policy';
+import type { CalendarEvent, Email } from '@/lib/jmap/types';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
+import { useAccountStore } from '@/stores/account-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { useCalendarStore } from '@/stores/calendar-store';
+import { useEmailStore } from '@/stores/email-store';
+
+const ROWS = 5;
+const REFRESH_DELAY_MS = 2000;
+const REFRESH_EVERY_MS = 5 * 60 * 1000;
+const EVENT_WINDOW_MS = 24 * 60 * 60 * 1000;
+
+export const EMPTY_SUMMARY: DesktopOsSummary = { unread: [], recent: [], nextEvent: null };
+
+export function eventStart(event: CalendarEvent): number {
+ return new Date(event.utcStart ?? event.start).getTime();
+}
+
+/** The soonest event still ahead, leaving out cancelled ones. */
+export function nextEventOf(events: readonly CalendarEvent[], now: number): CalendarEvent | null {
+ const ahead = events
+ .filter((event) => event.status !== 'cancelled' && !event.showWithoutTime && eventStart(event) >= now)
+ .sort((a, b) => eventStart(a) - eventStart(b));
+ return ahead[0] ?? null;
+}
+
+export function formatEventTime(start: number, now: number, locale: string | undefined): string {
+ const date = new Date(start);
+ const sameDay = date.toDateString() === new Date(now).toDateString();
+ const options: Intl.DateTimeFormatOptions = sameDay
+ ? { hour: 'numeric', minute: '2-digit' }
+ : { weekday: 'short', hour: 'numeric', minute: '2-digit' };
+ return new Intl.DateTimeFormat(locale, options).format(date);
+}
+
+export async function readSummary(locale: string | undefined, now = Date.now()): Promise {
+ if (isAppLocked()) return EMPTY_SUMMARY;
+ const auth = useAuthStore.getState();
+ const client: IJMAPClient | null = auth.client;
+ const entry = useAccountStore.getState().accounts.find((account) => account.id === auth.activeAccountId);
+ if (!auth.isAuthenticated || !client || !entry) return EMPTY_SUMMARY;
+ if (devicesForAccount(entry.id).pushWithoutContent) return EMPTY_SUMMARY;
+
+ const inbox = useEmailStore.getState().mailboxes.find((mailbox) => mailbox.role === 'inbox' && !mailbox.isShared);
+ let unread: DesktopOsTrayMessage[] = [];
+ let recent: DesktopOsTrayMessage[] = [];
+ if (inbox) {
+ const accountId = client.getAccountId();
+ const login = await loginFingerprint(entry.serverUrl, entry.username);
+ const row = (email: Email): DesktopOsTrayMessage => ({
+ emailId: email.id,
+ accountId,
+ sender: email.from?.[0]?.name || email.from?.[0]?.email || '',
+ subject: email.subject ?? '',
+ context: JSON.stringify({ emailId: email.id, threadId: email.threadId, accountId, slot: entry.cookieSlot, login }),
+ });
+ const [unreadList, recentList] = await Promise.all([
+ client.getEmails(inbox.originalId || inbox.id, undefined, ROWS, 0, undefined, false, { notKeyword: '$seen' }),
+ client.getEmails(inbox.originalId || inbox.id, undefined, ROWS),
+ ]);
+ unread = unreadList.emails.map(row);
+ recent = recentList.emails.map(row);
+ }
+
+ let nextEvent: DesktopOsSummary['nextEvent'] = null;
+ if (useCalendarStore.getState().supportsCalendar) {
+ const events = await client.queryCalendarEvents({
+ after: new Date(now).toISOString(),
+ before: new Date(now + EVENT_WINDOW_MS).toISOString(),
+ }).catch(() => [] as CalendarEvent[]);
+ const next = nextEventOf(events, now);
+ if (next) nextEvent = { title: next.title ?? '', when: formatEventTime(eventStart(next), now, locale) };
+ }
+ return { unread, recent, nextEvent };
+}
+
+/** Keeps main's copy current: when the unread count moves, and now and then for the next event. */
+export function startOsSummary(locale: () => string | undefined): { refresh: () => void; stop: () => void } {
+ const os = getDesktopBridge()?.os;
+ if (!os) return { refresh: () => {}, stop: () => {} };
+ let timer: ReturnType | null = null;
+ let stale = false;
+
+ const run = () => {
+ timer = null;
+ void readSummary(locale()).then((summary) => {
+ if (!stale) os.setSummary(summary);
+ }, (error) => {
+ console.warn('Could not read the tray summary:', error);
+ });
+ };
+ const refresh = () => {
+ timer ??= setTimeout(run, REFRESH_DELAY_MS);
+ };
+ const inboxUnread = () => useEmailStore.getState().mailboxes.find((mailbox) => mailbox.role === 'inbox' && !mailbox.isShared)?.unreadEmails ?? -1;
+ let lastUnread = inboxUnread();
+ const stopEmail = useEmailStore.subscribe(() => {
+ const current = inboxUnread();
+ if (current === lastUnread) return;
+ lastUnread = current;
+ refresh();
+ });
+ const stopAuth = useAuthStore.subscribe((state, previous) => {
+ if (state.activeAccountId !== previous.activeAccountId || state.isAuthenticated !== previous.isAuthenticated) refresh();
+ });
+ const interval = setInterval(refresh, REFRESH_EVERY_MS);
+ refresh();
+ return {
+ refresh,
+ stop() {
+ stale = true;
+ stopEmail();
+ stopAuth();
+ clearInterval(interval);
+ if (timer) clearTimeout(timer);
+ },
+ };
+}
diff --git a/lib/desktop/power.ts b/lib/desktop/power.ts
new file mode 100644
index 000000000..44d429dfc
--- /dev/null
+++ b/lib/desktop/power.ts
@@ -0,0 +1,48 @@
+/**
+ * Waking up without stale state. After sleep, an unlock or a network change, the app
+ * does what it does when a tab comes back into view: the JMAP client checks for state
+ * changes and replaces a dead stream, the identities and policy refresh. Those are the
+ * handlers already listening, so this only gives them the word. The hidden main window
+ * of the desktop app never goes out of view, so without this they wouldn't hear it.
+ * The local store hangs on the same two: `online` is setOnline(true) and a flush,
+ * `visibilitychange` is a sync per account. It takes itself offline for a sleep through
+ * addSleepHandler, in lib/desktop/system-events.ts.
+ */
+
+import { getDesktopBridge } from '@/lib/desktop/bridge';
+import { runSleepHandlers, runWakeHandlers } from '@/lib/desktop/system-events';
+import { usePolicyStore } from '@/stores/policy-store';
+
+/** A resume and an unlock usually come together. */
+const WAKE_REPEAT_MS = 2000;
+
+function wake(): void {
+ document.dispatchEvent(new Event('visibilitychange'));
+ window.dispatchEvent(new Event('online'));
+ const policy = usePolicyStore.getState();
+ if (policy.loaded && !policy.fetching) void policy.fetchPolicy();
+ runWakeHandlers();
+}
+
+export function startPowerWatch(): () => void {
+ const bridge = getDesktopBridge()?.os;
+ if (!bridge) return () => {};
+ let lastWake = 0;
+ const wakeOnce = () => {
+ const now = Date.now();
+ if (now - lastWake < WAKE_REPEAT_MS) return;
+ lastWake = now;
+ wake();
+ };
+ const stopPower = bridge.onPower((event) => {
+ if (event === 'resume' || event === 'unlock-screen') wakeOnce();
+ else if (event === 'suspend') runSleepHandlers();
+ });
+ // Wi-Fi to cable is a new network with no offline moment in between.
+ const connection = (navigator as unknown as { connection?: EventTarget }).connection;
+ connection?.addEventListener?.('change', wakeOnce);
+ return () => {
+ stopPower();
+ connection?.removeEventListener?.('change', wakeOnce);
+ };
+}
diff --git a/lib/desktop/system-events.ts b/lib/desktop/system-events.ts
new file mode 100644
index 000000000..f1b258077
--- /dev/null
+++ b/lib/desktop/system-events.ts
@@ -0,0 +1,56 @@
+/**
+ * What the local store and the OS integration say to each other, with nothing imported
+ * either way: lib/offline runs in every browser, and the notification code pulls in half
+ * the app's stores.
+ */
+
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+
+type Handler = () => void;
+type CreatedListener = (client: IJMAPClient, accountId: string, ids: readonly string[]) => void;
+
+const wakeHandlers = new Set();
+const sleepHandlers = new Set();
+let createdListener: CreatedListener | null = null;
+
+export function addWakeHandler(handler: Handler): () => void {
+ wakeHandlers.add(handler);
+ return () => {
+ wakeHandlers.delete(handler);
+ };
+}
+
+export function addSleepHandler(handler: Handler): () => void {
+ sleepHandlers.add(handler);
+ return () => {
+ sleepHandlers.delete(handler);
+ };
+}
+
+function runAll(handlers: Set): void {
+ for (const handler of [...handlers]) {
+ try {
+ handler();
+ } catch (error) {
+ console.error('A wake or sleep handler failed:', error);
+ }
+ }
+}
+
+export function runWakeHandlers(): void {
+ runAll(wakeHandlers);
+}
+
+export function runSleepHandlers(): void {
+ runAll(sleepHandlers);
+}
+
+/** The notification path, while it runs. */
+export function setStoreCreatedListener(listener: CreatedListener | null): void {
+ createdListener = listener;
+}
+
+/** The store's `created` event for mail. Does nothing in a browser or a pop-out. */
+export function reportStoreCreatedMail(client: IJMAPClient, accountId: string, ids: readonly string[]): void {
+ createdListener?.(client, accountId, ids);
+}
diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts
index 5c60da8bf..696ab3183 100644
--- a/lib/desktop/types.ts
+++ b/lib/desktop/types.ts
@@ -182,6 +182,122 @@ export interface DesktopKeystoreBridge {
revokeAccess: () => Promise;
}
+export interface DesktopOsSettings {
+ /** Closing the main window leaves the app running in the tray. */
+ closeToTray: boolean;
+ startAtLogin: boolean;
+ /** At login: no window, only the tray. */
+ startMinimized: boolean;
+ /** Taskbar flash or dock bounce on new mail. */
+ attentionOnNewMail: boolean;
+ quietHoursEnabled: boolean;
+ /** HH:MM, local time. */
+ quietHoursStart: string;
+ quietHoursEnd: string;
+ /** "Show Bulwark", an Electron accelerator. Null: off. */
+ showShortcut: string | null;
+}
+
+export interface DesktopOsStatus {
+ settings: DesktopOsSettings;
+ pausedUntil: number | null;
+ /** The shortcut in settings is really held. Another app can have it. */
+ shortcutActive: boolean;
+}
+
+export interface DesktopOsFile {
+ name: string;
+ data: Uint8Array;
+}
+
+/** What the OS asks the app to do: a tray item, a jump list entry, a toast, a double-clicked file. */
+export type DesktopOsCommand =
+ | { type: 'new-message' }
+ | { type: 'navigate'; target: 'inbox' | 'calendar' | 'contacts' }
+ | { type: 'open-message'; emailId: string; accountId?: string; context?: string }
+ | { type: 'open-mailbox'; mailboxId: string }
+ | { type: 'mailto'; url: string }
+ | { type: 'webcal'; url: string }
+ | { type: 'files'; reason: 'open' | 'attach'; files: DesktopOsFile[]; failed: string[] }
+ | { type: 'notification'; action: string; context: string; reply?: string };
+
+export interface DesktopOsNotification {
+ /** Tag of the toast. Showing an id again within a few minutes does nothing. */
+ id: string;
+ group?: string;
+ title: string;
+ body: string;
+ /** Handed back with the action. Keep it short: Windows keeps it in the toast. */
+ context: string;
+ actions?: { id: string; label: string }[];
+ reply?: { label: string; placeholder: string };
+ /** The user wants a sound with this. */
+ sound?: boolean;
+ /** New mail: flash the taskbar or bounce the dock if that's on. */
+ attention?: boolean;
+}
+
+export interface DesktopOsNotifyResult {
+ shown: boolean;
+ reason?: 'duplicate' | 'paused' | 'quiet-hours' | 'focused' | 'unsupported' | 'invalid';
+ /** The page should play the sound the user picked. */
+ playSound: boolean;
+}
+
+export interface DesktopOsTrayMessage {
+ emailId: string;
+ accountId: string;
+ sender: string;
+ subject: string;
+ context?: string;
+}
+
+export interface DesktopOsSummary {
+ unread: DesktopOsTrayMessage[];
+ /** Newest conversations, whether read or not, for the jump list. */
+ recent: DesktopOsTrayMessage[];
+ nextEvent: { title: string; when: string } | null;
+}
+
+export type DesktopOsPowerEvent = 'suspend' | 'resume' | 'lock-screen' | 'unlock-screen';
+
+export interface DesktopOsDefaultAppStatus {
+ /** Offered to the OS as a mail app. */
+ registered: boolean;
+ mailto: boolean;
+ webcal: boolean;
+}
+
+export interface DesktopOsBridge {
+ /**
+ * What the OS asks for. Main holds these until the main window's page subscribes,
+ * so a cold start loses nothing.
+ */
+ onCommand: (listener: (command: DesktopOsCommand) => void) => () => void;
+ getStatus: () => Promise;
+ setSettings: (patch: Partial) => Promise;
+ /** Pause notifications until this time, or null to end the pause. */
+ setPaused: (until: number | null) => void;
+ onStatusChange: (listener: (status: DesktopOsStatus) => void) => () => void;
+ /** The unread count: overlay on Windows, setBadgeCount on macOS and Linux, the tray's dot. */
+ setBadge: (count: number) => void;
+ /** What the tray and the jump list show. */
+ setSummary: (summary: DesktopOsSummary) => void;
+ /** The taskbar's progress bar: lib/desktop/file-drag.ts setTaskbarProgress. */
+ setProgress: (progress: number | null, mode?: 'normal' | 'indeterminate' | 'paused' | 'error') => void;
+ /** App lock's state: main clears the tray, jump list and toasts and strips what comes through. */
+ setLocked: (locked: boolean) => void;
+ notify: (notification: DesktopOsNotification) => Promise;
+ onPower: (listener: (event: DesktopOsPowerEvent) => void) => () => void;
+ /** Writes the files to a private folder and starts a drag. Only with the mouse held. */
+ startDrag: (files: DesktopOsFile[]) => Promise;
+ /** The same, onto the clipboard as files. */
+ copyFiles: (files: DesktopOsFile[]) => Promise;
+ defaultAppStatus: () => Promise;
+ /** Offers the app to the OS and opens where the user chooses. */
+ openDefaultAppSettings: () => Promise;
+}
+
export interface BulwarkDesktopBridge {
version: number;
platform: 'win32' | 'darwin' | 'linux';
@@ -191,6 +307,7 @@ export interface BulwarkDesktopBridge {
menu?: DesktopMenuBridge;
shell?: DesktopShellBridge;
lifecycle?: DesktopLifecycleBridge;
+ os?: DesktopOsBridge;
servers?: DesktopServersBridge;
auth?: DesktopAuthBridge;
plugins?: DesktopPluginsBridge;
diff --git a/lib/desktop/window-files.ts b/lib/desktop/window-files.ts
new file mode 100644
index 000000000..c9b95221a
--- /dev/null
+++ b/lib/desktop/window-files.ts
@@ -0,0 +1,66 @@
+/**
+ * Files dropped on a window or pasted from the clipboard. Whatever already handles a
+ * drop keeps doing so, which it shows by preventDefault: the composer, the file page,
+ * calendar import. The rest becomes an attachment on a new draft, or on the composer
+ * that's open.
+ */
+
+import { getDesktopBridge } from '@/lib/desktop/bridge';
+import { handOffDraft } from '@/lib/launch-handoff';
+
+type ComposerTarget = (files: File[]) => unknown;
+
+let composerTarget: ComposerTarget | null = null;
+/** Our own native drag passing over our own window isn't a new file. */
+let ownDrag = false;
+
+/** The open composer takes files itself. No effect outside the desktop app: only this file reads it. */
+export function setComposerFileTarget(target: ComposerTarget): () => void {
+ composerTarget = target;
+ return () => {
+ if (composerTarget === target) composerTarget = null;
+ };
+}
+
+export function setOwnDragActive(active: boolean): void {
+ ownDrag = active;
+}
+
+function take(files: File[]): void {
+ if (composerTarget) composerTarget(files);
+ else handOffDraft({ files });
+}
+
+function hasFiles(event: DragEvent): boolean {
+ return !!event.dataTransfer && [...event.dataTransfer.types].includes('Files');
+}
+
+export function startWindowFiles(): () => void {
+ if (!getDesktopBridge()?.os) return () => {};
+ const onDragOver = (event: DragEvent) => {
+ if (event.defaultPrevented || ownDrag || !hasFiles(event)) return;
+ event.preventDefault();
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
+ };
+ const onDrop = (event: DragEvent) => {
+ if (event.defaultPrevented || ownDrag || !hasFiles(event)) return;
+ event.preventDefault();
+ const files = Array.from(event.dataTransfer?.files ?? []);
+ if (files.length > 0) take(files);
+ };
+ const onPaste = (event: ClipboardEvent) => {
+ if (event.defaultPrevented) return;
+ const files = Array.from(event.clipboardData?.files ?? []);
+ if (files.length === 0) return;
+ event.preventDefault();
+ take(files);
+ };
+ window.addEventListener('dragover', onDragOver);
+ window.addEventListener('drop', onDrop);
+ window.addEventListener('paste', onPaste);
+ return () => {
+ window.removeEventListener('dragover', onDragOver);
+ window.removeEventListener('drop', onDrop);
+ window.removeEventListener('paste', onPaste);
+ };
+}
diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts
index 33b5fff76..d71993589 100644
--- a/lib/offline/offline-mode.ts
+++ b/lib/offline/offline-mode.ts
@@ -62,6 +62,7 @@ import { flushQueue, LOOKUP_WAIT_MS, SENDING_STALE_MS, settleStaleSends } from '
import { LEGACY_DB_NAME, moveLegacyOutbox, strandedLegacyMessages } from './migrate-legacy';
import { deleteShellCaches, tellServiceWorker } from './shell';
import { rememberLost } from './lost';
+import { addSleepHandler, reportStoreCreatedMail } from '@/lib/desktop/system-events';
export type OfflineNotice =
| { kind: 'queued'; subject: string }
@@ -196,6 +197,10 @@ const storeHooks: StorePageHooks = {
notify({ kind: 'store-reset' });
},
onEvent(event) {
+ if (event.type === 'created' && event.collection === 'Email') {
+ const client = onlineClientFor(event.account);
+ if (client) reportStoreCreatedMail(client, event.jmap, event.ids);
+ }
if (event.type === 'synced') {
useOfflineStore.setState((s) => ({ lastSyncedAt: { ...s.lastSyncedAt, [event.account]: event.at } }));
forgetMailSnapshot(event.account);
@@ -957,6 +962,11 @@ export function startOfflineMode(): () => void {
window.removeEventListener('offline', onOffline);
});
+ // The desktop app going to sleep. Waking comes with the two events above and below.
+ cleanups.push(addSleepHandler(() => {
+ void getStore().setOnline(false).catch(() => {});
+ }));
+
// Back in front: catch up now rather than at the next interval.
const onVisible = () => {
if (document.visibilityState !== 'visible') return;
diff --git a/lib/pwa-notify/__tests__/page-actions.test.ts b/lib/pwa-notify/__tests__/page-actions.test.ts
index 39287cd2f..c911eeae8 100644
--- a/lib/pwa-notify/__tests__/page-actions.test.ts
+++ b/lib/pwa-notify/__tests__/page-actions.test.ts
@@ -13,6 +13,7 @@ const makeClient = (accountId: string) => ({
getAccountId: () => accountId,
getEmail: vi.fn(async (id: string) => (id === 'gone' ? null : { id, receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { [id === 'binned' ? 'bin' : 'inbox']: true } })),
getMailboxes: vi.fn(async () => [{ id: 'inbox', role: 'inbox' }, { id: 'x', originalId: 'bin', role: 'trash' }]),
+ batchMoveEmails: vi.fn(async (..._args: unknown[]) => {}),
});
let clients: Record>;
let activeAccountId = 'alice@one';
@@ -31,7 +32,9 @@ vi.mock('@/stores/auth-store', () => ({
}),
},
}));
-vi.mock('@/stores/email-store', () => ({ useEmailStore: { getState: () => ({ batchMarkAsRead, batchArchive }) } }));
+let listed: { id: string; sourceClientAccountId?: string }[] = [];
+const deleteEmail = vi.fn(async (..._args: unknown[]) => true);
+vi.mock('@/stores/email-store', () => ({ useEmailStore: { getState: () => ({ batchMarkAsRead, batchArchive, deleteEmail, emails: listed }) } }));
// Each login's own subject, and a policy that reads it: the gate of the login
// the notification is for, not of the one that's active here.
vi.mock('@/lib/org/account-subjects', () => ({ subjectForAccount: (id: string) => ({ email: id, groups: [] }) }));
@@ -43,6 +46,7 @@ vi.mock('@/stores/policy-store', () => ({
},
}));
vi.mock('../plugin-veto', () => ({ vetoHooksRegistered: () => hooksRegistered }));
+vi.mock('@/stores/settings-store', () => ({ useSettingsStore: { getState: () => ({ deleteAction: 'trash' }) } }));
async function run(over: Partial = {}) {
const request: NotificationActionRequest = {
@@ -65,6 +69,7 @@ beforeEach(() => {
activeAccountId = 'alice@one';
gateOff.clear();
hooksRegistered = false;
+ listed = [];
batchMarkAsRead.mockResolvedValue(true);
batchArchive.mockResolvedValue(true);
});
@@ -278,3 +283,34 @@ describe('Reply', () => {
removeCurrent();
});
});
+
+describe("Delete, from the desktop app's toast", () => {
+ it('goes through the store when the message is in the list, with its hooks and the Delete setting', async () => {
+ listed = [{ id: 'e1' }];
+ expect(await run({ action: 'delete' })).toEqual(['accepted', 'done']);
+ expect(deleteEmail).toHaveBeenCalledWith(clients['alice@one'], 'e1');
+ expect(clients['alice@one'].batchMoveEmails).not.toHaveBeenCalled();
+ });
+
+ it("moves it to Trash by the folder's real id when it is not in the list", async () => {
+ expect(await run({ action: 'delete' })).toEqual(['accepted', 'done']);
+ expect(clients['alice@one'].batchMoveEmails).toHaveBeenCalledWith(['e1'], 'bin', undefined, expect.any(Boolean));
+ expect(deleteEmail).not.toHaveBeenCalled();
+ });
+
+ it('plugins are never gone round: with a veto hook listening, the message opens instead', async () => {
+ hooksRegistered = true;
+ expect(await run({ action: 'delete' })).toEqual(['accepted', 'failed']);
+ expect(clients['alice@one'].batchMoveEmails).not.toHaveBeenCalled();
+ });
+
+ it('a message since moved to Trash stays there', async () => {
+ clients['alice@one'].getEmail.mockResolvedValue({ id: 'e1', mailboxIds: { bin: true } } as never);
+ expect(await run({ action: 'delete' })).toEqual(['accepted', 'failed']);
+ expect(clients['alice@one'].batchMoveEmails).not.toHaveBeenCalled();
+ });
+
+ it("the worker can't send one", () => {
+ expect(isNotificationActionRequest({ action: 'delete', emailId: 'e1', accountId: 'a', at: Date.now() })).toBe(false);
+ });
+});
diff --git a/lib/pwa-notify/page-actions.ts b/lib/pwa-notify/page-actions.ts
index 96b1db101..1368a61be 100644
--- a/lib/pwa-notify/page-actions.ts
+++ b/lib/pwa-notify/page-actions.ts
@@ -5,10 +5,12 @@ import { useEmailStore } from '@/stores/email-store';
import { usePolicyStore } from '@/stores/policy-store';
import { loginFingerprint } from './login-fingerprint';
import { vetoHooksRegistered } from './plugin-veto';
+import { useSettingsStore } from '@/stores/settings-store';
/** What the service worker posts when a notification's button is clicked (public/sw.js). */
export interface NotificationActionRequest {
- action: 'archive' | 'mark-read' | 'reply';
+ /** `delete` only comes from the desktop app's toasts, never from the worker. */
+ action: 'archive' | 'mark-read' | 'reply' | 'delete';
emailId: string;
accountId: string;
/** When the worker asked. */
@@ -149,6 +151,24 @@ export async function runNotificationAction(
answer('failed');
return;
}
+ if (request.action === 'delete') {
+ const store = useEmailStore.getState();
+ // In the list: the store's own delete, with its hooks and the Delete setting.
+ if (store.emails.some((row) => row.id === request.emailId && (row.sourceClientAccountId ?? entry.id) === entry.id)) {
+ answer(await store.deleteEmail(client, request.emailId) ? 'done' : 'failed');
+ return;
+ }
+ // Not in the list, so no store action can reach it, and plugins are never gone round.
+ const trash = mailboxes.find((mailbox) => mailbox.role === 'trash');
+ if (!trash || !email || vetoHooksRegistered()) {
+ answer('failed');
+ return;
+ }
+ const markRead = useSettingsStore.getState().deleteAction === 'trash-and-read';
+ await client.batchMoveEmails([request.emailId], trash.originalId || trash.id, accountId, markRead);
+ answer('done');
+ return;
+ }
const done = request.action === 'mark-read'
? await useEmailStore.getState().batchMarkAsRead(activeClient, true, { emailIds: [request.emailId], client, accountId })
: await useEmailStore.getState().batchArchive(activeClient, { emailIds: [request.emailId], client, emails: email ? [email] : [], userArchive: true });
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 654025307..169c04ec5 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -6561,5 +6561,35 @@
"label_description": "اسحب إذنك. سيتعين على الخادم أن يطلبه مرة أخرى.",
"revoke": "سحب الإذن",
"revoked": "تم سحب الإذن"
+ },
+ "desktopOs": {
+ "section_title": "تطبيق سطح المكتب",
+ "section_description": "طريقة عمل التطبيق مع حاسوبك.",
+ "close_to_tray_label": "الاستمرار في العمل في شريط النظام عند الإغلاق",
+ "close_to_tray_description": "يستمر وصول البريد الجديد والتذكيرات بعد إغلاق النافذة.",
+ "start_at_login_label": "البدء عند تسجيل الدخول إلى الحاسوب",
+ "start_at_login_description": "يبدأ التطبيق تلقائيًا عند تسجيل الدخول.",
+ "start_minimized_label": "البدء في شريط النظام",
+ "start_minimized_description": "لا تُفتح نافذة عند تسجيل الدخول. استخدم أيقونة شريط النظام لفتحه.",
+ "attention_label": "وميض شريط المهام عند وصول بريد جديد",
+ "attention_mac_label": "قفز أيقونة Dock عند وصول بريد جديد",
+ "attention_description": "فقط عندما لا يكون التطبيق في المقدمة.",
+ "quiet_hours_label": "ساعات الهدوء",
+ "quiet_hours_description": "لا إشعارات ولا أصوات في هذه الأوقات. يستمر وصول البريد.",
+ "quiet_hours_from": "من",
+ "quiet_hours_to": "إلى",
+ "show_shortcut_label": "إظهار التطبيق باختصار لوحة المفاتيح",
+ "show_shortcut_description": "{keys} يُظهر التطبيق في المقدمة من أي تطبيق آخر.",
+ "show_shortcut_taken": "يستخدم تطبيق آخر {keys}. أغلق ذلك التطبيق، أو أوقف هذا الخيار ثم شغّله مرة أخرى.",
+ "default_app_label": "تطبيق البريد الافتراضي",
+ "default_app_description": "فتح روابط البريد وملفات .eml و.ics و.vcf بهذا التطبيق.",
+ "default_app_is_default": "هذا التطبيق يفتح روابط البريد لديك.",
+ "default_app_button": "اختيار التطبيق الافتراضي…",
+ "new_mail": "بريد جديد",
+ "no_subject": "(بدون موضوع)",
+ "action_delete": "حذف",
+ "copy_as_file": "نسخ كملف",
+ "copied": "تم نسخ الملف. يمكنك لصقه في مجلد.",
+ "copy_failed": "تعذّر نسخ الملف."
}
}
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 0a9eb6fb2..5dd448d8e 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -6561,5 +6561,35 @@
"label_description": "Retira el permís. El servidor ho haurà de tornar a demanar.",
"revoke": "Retira",
"revoked": "Permís retirat"
+ },
+ "desktopOs": {
+ "section_title": "Aplicació d'escriptori",
+ "section_description": "Com funciona l'aplicació amb l'ordinador.",
+ "close_to_tray_label": "Continua a la safata en tancar",
+ "close_to_tray_description": "El correu nou i els recordatoris continuen arribant després de tancar la finestra.",
+ "start_at_login_label": "Inicia en entrar a l'ordinador",
+ "start_at_login_description": "L'aplicació s'inicia sola quan entreu.",
+ "start_minimized_label": "Inicia a la safata",
+ "start_minimized_description": "No s'obre cap finestra en entrar. Obriu-la amb la icona de la safata.",
+ "attention_label": "Fes parpellejar la barra de tasques amb correu nou",
+ "attention_mac_label": "Fes botar la icona del Dock amb correu nou",
+ "attention_description": "Només quan l'aplicació no és al davant.",
+ "quiet_hours_label": "Hores de silenci",
+ "quiet_hours_description": "Cap notificació ni so en aquestes hores. El correu continua arribant.",
+ "quiet_hours_from": "De",
+ "quiet_hours_to": "a",
+ "show_shortcut_label": "Mostra l'aplicació amb una drecera de teclat",
+ "show_shortcut_description": "{keys} porta l'aplicació al davant des de qualsevol altra aplicació.",
+ "show_shortcut_taken": "Una altra aplicació fa servir {keys}. Tanqueu-la o desactiveu i torneu a activar això.",
+ "default_app_label": "Aplicació de correu predeterminada",
+ "default_app_description": "Obre els enllaços de correu i els fitxers .eml, .ics i .vcf amb aquesta aplicació.",
+ "default_app_is_default": "Aquesta aplicació obre els vostres enllaços de correu.",
+ "default_app_button": "Tria l'aplicació predeterminada…",
+ "new_mail": "Correu nou",
+ "no_subject": "(sense assumpte)",
+ "action_delete": "Suprimeix",
+ "copy_as_file": "Copia com a fitxer",
+ "copied": "Fitxer copiat. El podeu enganxar en una carpeta.",
+ "copy_failed": "No s'ha pogut copiar el fitxer."
}
}
diff --git a/locales/cs/common.json b/locales/cs/common.json
index 18f0c03ee..567020087 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -6561,5 +6561,35 @@
"label_description": "Odvolejte své povolení. Server se bude muset zeptat znovu.",
"revoke": "Odvolat",
"revoked": "Povolení bylo odvoláno"
+ },
+ "desktopOs": {
+ "section_title": "Desktopová aplikace",
+ "section_description": "Jak aplikace spolupracuje s vaším počítačem.",
+ "close_to_tray_label": "Po zavření běžet dál v oznamovací oblasti",
+ "close_to_tray_description": "Nová pošta a připomenutí chodí i po zavření okna.",
+ "start_at_login_label": "Spustit po přihlášení k počítači",
+ "start_at_login_description": "Aplikace se po přihlášení spustí sama.",
+ "start_minimized_label": "Spustit v oznamovací oblasti",
+ "start_minimized_description": "Po přihlášení se neotevře žádné okno. Otevřete ji ikonou v oznamovací oblasti.",
+ "attention_label": "Při nové poště blikat na hlavním panelu",
+ "attention_mac_label": "Při nové poště poskakovat ikonou v Docku",
+ "attention_description": "Jen když aplikace není v popředí.",
+ "quiet_hours_label": "Doba klidu",
+ "quiet_hours_description": "V tuto dobu žádná oznámení ani zvuky. Pošta dál přichází.",
+ "quiet_hours_from": "Od",
+ "quiet_hours_to": "do",
+ "show_shortcut_label": "Zobrazit aplikaci klávesovou zkratkou",
+ "show_shortcut_description": "{keys} přenese aplikaci do popředí z jakékoli jiné aplikace.",
+ "show_shortcut_taken": "{keys} používá jiná aplikace. Zavřete ji, nebo tuto volbu vypněte a znovu zapněte.",
+ "default_app_label": "Výchozí poštovní aplikace",
+ "default_app_description": "Otevírat poštovní odkazy a soubory .eml, .ics a .vcf v této aplikaci.",
+ "default_app_is_default": "Tato aplikace otevírá vaše poštovní odkazy.",
+ "default_app_button": "Vybrat výchozí aplikaci…",
+ "new_mail": "Nová pošta",
+ "no_subject": "(bez předmětu)",
+ "action_delete": "Smazat",
+ "copy_as_file": "Kopírovat jako soubor",
+ "copied": "Soubor zkopírován. Můžete ho vložit do složky.",
+ "copy_failed": "Soubor se nepodařilo zkopírovat."
}
}
diff --git a/locales/da/common.json b/locales/da/common.json
index 5578eeca0..352cd2805 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -6561,5 +6561,35 @@
"label_description": "Træk din tilladelse tilbage. Serveren skal så spørge igen.",
"revoke": "Træk tilbage",
"revoked": "Tilladelsen er trukket tilbage"
+ },
+ "desktopOs": {
+ "section_title": "Skrivebordsapp",
+ "section_description": "Sådan arbejder appen sammen med din computer.",
+ "close_to_tray_label": "Fortsæt i systembakken ved lukning",
+ "close_to_tray_description": "Ny post og påmindelser bliver ved med at komme, når du lukker vinduet.",
+ "start_at_login_label": "Start, når du logger på computeren",
+ "start_at_login_description": "Appen starter selv, når du logger på.",
+ "start_minimized_label": "Start i systembakken",
+ "start_minimized_description": "Der åbnes intet vindue ved logon. Åbn appen med ikonet i systembakken.",
+ "attention_label": "Blink i proceslinjen ved ny post",
+ "attention_mac_label": "Lad Dock-ikonet hoppe ved ny post",
+ "attention_description": "Kun når appen ikke er forrest.",
+ "quiet_hours_label": "Stille timer",
+ "quiet_hours_description": "Ingen notifikationer eller lyde i dette tidsrum. Post kommer stadig.",
+ "quiet_hours_from": "Fra",
+ "quiet_hours_to": "til",
+ "show_shortcut_label": "Vis appen med en tastaturgenvej",
+ "show_shortcut_description": "{keys} henter appen frem fra enhver anden app.",
+ "show_shortcut_taken": "En anden app bruger {keys}. Luk den app, eller slå dette fra og til igen.",
+ "default_app_label": "Standardmailapp",
+ "default_app_description": "Åbn maillinks og .eml-, .ics- og .vcf-filer med denne app.",
+ "default_app_is_default": "Denne app åbner dine maillinks.",
+ "default_app_button": "Vælg standardapp…",
+ "new_mail": "Ny post",
+ "no_subject": "(intet emne)",
+ "action_delete": "Slet",
+ "copy_as_file": "Kopiér som fil",
+ "copied": "Filen er kopieret. Du kan indsætte den i en mappe.",
+ "copy_failed": "Filen kunne ikke kopieres."
}
}
diff --git a/locales/de/common.json b/locales/de/common.json
index efb641f2b..65fa8c8dd 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -6561,5 +6561,35 @@
"label_description": "Nehmen Sie Ihre Erlaubnis zurück. Der Server muss dann erneut fragen.",
"revoke": "Zurücknehmen",
"revoked": "Erlaubnis zurückgenommen"
+ },
+ "desktopOs": {
+ "section_title": "Desktop-App",
+ "section_description": "Wie die App mit Ihrem Computer zusammenarbeitet.",
+ "close_to_tray_label": "Beim Schließen im Infobereich weiterlaufen",
+ "close_to_tray_description": "Neue E-Mails und Erinnerungen kommen auch dann an, wenn Sie das Fenster schließen.",
+ "start_at_login_label": "Beim Anmelden am Computer starten",
+ "start_at_login_description": "Die App startet von selbst, wenn Sie sich anmelden.",
+ "start_minimized_label": "Im Infobereich starten",
+ "start_minimized_description": "Beim Anmelden öffnet sich kein Fenster. Öffnen Sie die App über das Symbol im Infobereich.",
+ "attention_label": "Taskleiste bei neuen E-Mails blinken lassen",
+ "attention_mac_label": "Dock-Symbol bei neuen E-Mails hüpfen lassen",
+ "attention_description": "Nur wenn die App nicht im Vordergrund ist.",
+ "quiet_hours_label": "Ruhezeiten",
+ "quiet_hours_description": "In dieser Zeit keine Benachrichtigungen und Töne. E-Mails kommen trotzdem an.",
+ "quiet_hours_from": "Von",
+ "quiet_hours_to": "bis",
+ "show_shortcut_label": "App mit einer Tastenkombination anzeigen",
+ "show_shortcut_description": "{keys} holt die App aus jeder anderen App nach vorne.",
+ "show_shortcut_taken": "Eine andere App verwendet {keys}. Schließen Sie diese App oder schalten Sie das hier aus und wieder ein.",
+ "default_app_label": "Standard-E-Mail-App",
+ "default_app_description": "Mail-Links und .eml-, .ics- und .vcf-Dateien mit dieser App öffnen.",
+ "default_app_is_default": "Diese App öffnet Ihre Mail-Links.",
+ "default_app_button": "Standard-App auswählen…",
+ "new_mail": "Neue E-Mail",
+ "no_subject": "(kein Betreff)",
+ "action_delete": "Löschen",
+ "copy_as_file": "Als Datei kopieren",
+ "copied": "Datei kopiert. Sie können sie in einen Ordner einfügen.",
+ "copy_failed": "Die Datei konnte nicht kopiert werden."
}
}
diff --git a/locales/en/common.json b/locales/en/common.json
index 7728594e1..4512e406e 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -6561,5 +6561,35 @@
"label_description": "Take back your permission. The server has to ask again.",
"revoke": "Take back",
"revoked": "Permission taken back"
+ },
+ "desktopOs": {
+ "section_title": "Desktop app",
+ "section_description": "How the app works with your computer.",
+ "close_to_tray_label": "Keep running in the tray when closed",
+ "close_to_tray_description": "New mail and reminders keep coming after you close the window.",
+ "start_at_login_label": "Start when you sign in to your computer",
+ "start_at_login_description": "The app starts by itself when you sign in.",
+ "start_minimized_label": "Start in the tray",
+ "start_minimized_description": "No window opens at sign-in. Use the tray icon to open it.",
+ "attention_label": "Flash the taskbar on new mail",
+ "attention_mac_label": "Bounce the Dock icon on new mail",
+ "attention_description": "Only when the app is not in front.",
+ "quiet_hours_label": "Quiet hours",
+ "quiet_hours_description": "No notifications or sounds at these times. Mail still arrives.",
+ "quiet_hours_from": "From",
+ "quiet_hours_to": "to",
+ "show_shortcut_label": "Show the app with a keyboard shortcut",
+ "show_shortcut_description": "{keys} brings the app to the front, from any other app.",
+ "show_shortcut_taken": "Another app is using {keys}. Close that app, or turn this off and on again.",
+ "default_app_label": "Default mail app",
+ "default_app_description": "Open mail links and .eml, .ics and .vcf files with this app.",
+ "default_app_is_default": "This app opens your mail links.",
+ "default_app_button": "Choose default app…",
+ "new_mail": "New mail",
+ "no_subject": "(no subject)",
+ "action_delete": "Delete",
+ "copy_as_file": "Copy as file",
+ "copied": "File copied. You can paste it into a folder.",
+ "copy_failed": "The file could not be copied."
}
}
diff --git a/locales/es/common.json b/locales/es/common.json
index 5a2af15a4..6ed72a0f4 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -6561,5 +6561,35 @@
"label_description": "Retire su permiso. El servidor tendrá que volver a pedirlo.",
"revoke": "Retirar",
"revoked": "Permiso retirado"
+ },
+ "desktopOs": {
+ "section_title": "Aplicación de escritorio",
+ "section_description": "Cómo funciona la aplicación con tu ordenador.",
+ "close_to_tray_label": "Seguir en la bandeja al cerrar",
+ "close_to_tray_description": "El correo nuevo y los recordatorios siguen llegando después de cerrar la ventana.",
+ "start_at_login_label": "Iniciar al entrar en el ordenador",
+ "start_at_login_description": "La aplicación se inicia sola cuando entras.",
+ "start_minimized_label": "Iniciar en la bandeja",
+ "start_minimized_description": "No se abre ninguna ventana al entrar. Ábrela con el icono de la bandeja.",
+ "attention_label": "Hacer parpadear la barra de tareas con correo nuevo",
+ "attention_mac_label": "Hacer rebotar el icono del Dock con correo nuevo",
+ "attention_description": "Solo cuando la aplicación no está delante.",
+ "quiet_hours_label": "Horas de silencio",
+ "quiet_hours_description": "Sin notificaciones ni sonidos en estas horas. El correo sigue llegando.",
+ "quiet_hours_from": "De",
+ "quiet_hours_to": "a",
+ "show_shortcut_label": "Mostrar la aplicación con un atajo de teclado",
+ "show_shortcut_description": "{keys} trae la aplicación al frente desde cualquier otra.",
+ "show_shortcut_taken": "Otra aplicación está usando {keys}. Ciérrala, o desactiva y vuelve a activar esta opción.",
+ "default_app_label": "Aplicación de correo predeterminada",
+ "default_app_description": "Abrir los enlaces de correo y los archivos .eml, .ics y .vcf con esta aplicación.",
+ "default_app_is_default": "Esta aplicación abre tus enlaces de correo.",
+ "default_app_button": "Elegir aplicación predeterminada…",
+ "new_mail": "Correo nuevo",
+ "no_subject": "(sin asunto)",
+ "action_delete": "Eliminar",
+ "copy_as_file": "Copiar como archivo",
+ "copied": "Archivo copiado. Puedes pegarlo en una carpeta.",
+ "copy_failed": "No se pudo copiar el archivo."
}
}
diff --git a/locales/et/common.json b/locales/et/common.json
index b5ce8e291..d0d6356b6 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -6561,5 +6561,35 @@
"label_description": "Võtke oma luba tagasi. Server peab siis uuesti küsima.",
"revoke": "Võta tagasi",
"revoked": "Luba võeti tagasi"
+ },
+ "desktopOs": {
+ "section_title": "Töölauarakendus",
+ "section_description": "Kuidas rakendus sinu arvutiga koos töötab.",
+ "close_to_tray_label": "Sulgemisel jätka salves",
+ "close_to_tray_description": "Uus kiri ja meeldetuletused saabuvad ka pärast akna sulgemist.",
+ "start_at_login_label": "Käivita arvutisse sisselogimisel",
+ "start_at_login_description": "Rakendus käivitub sisselogimisel ise.",
+ "start_minimized_label": "Käivita salves",
+ "start_minimized_description": "Sisselogimisel akent ei avata. Ava rakendus salve ikoonist.",
+ "attention_label": "Vilguta tegumiriba uue kirja korral",
+ "attention_mac_label": "Põrgata Docki ikooni uue kirja korral",
+ "attention_description": "Ainult siis, kui rakendus pole esiplaanil.",
+ "quiet_hours_label": "Vaikne aeg",
+ "quiet_hours_description": "Sel ajal teavitusi ega helisid pole. Kirjad saabuvad ikka.",
+ "quiet_hours_from": "Alates",
+ "quiet_hours_to": "kuni",
+ "show_shortcut_label": "Näita rakendust kiirklahviga",
+ "show_shortcut_description": "{keys} toob rakenduse esiplaanile igast teisest rakendusest.",
+ "show_shortcut_taken": "{keys} on teise rakenduse kasutuses. Sulge see rakendus või lülita see valik välja ja uuesti sisse.",
+ "default_app_label": "Vaikimisi e-posti rakendus",
+ "default_app_description": "Ava e-posti lingid ning .eml-, .ics- ja .vcf-failid selle rakendusega.",
+ "default_app_is_default": "See rakendus avab sinu e-posti lingid.",
+ "default_app_button": "Vali vaikerakendus…",
+ "new_mail": "Uus kiri",
+ "no_subject": "(teema puudub)",
+ "action_delete": "Kustuta",
+ "copy_as_file": "Kopeeri failina",
+ "copied": "Fail on kopeeritud. Saad selle kausta kleepida.",
+ "copy_failed": "Faili ei saanud kopeerida."
}
}
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 22a2c1d43..de207bb10 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -6561,5 +6561,35 @@
"label_description": "اجازهٔ خود را پس بگیرید. سرور باید دوباره درخواست کند.",
"revoke": "پس گرفتن",
"revoked": "اجازه پس گرفته شد"
+ },
+ "desktopOs": {
+ "section_title": "برنامه دسکتاپ",
+ "section_description": "نحوه کار برنامه با رایانه شما.",
+ "close_to_tray_label": "هنگام بستن در سینی سیستم اجرا بماند",
+ "close_to_tray_description": "نامههای جدید و یادآوریها پس از بستن پنجره هم میرسند.",
+ "start_at_login_label": "هنگام ورود به رایانه شروع شود",
+ "start_at_login_description": "برنامه هنگام ورود خودش شروع میشود.",
+ "start_minimized_label": "شروع در سینی سیستم",
+ "start_minimized_description": "هنگام ورود پنجرهای باز نمیشود. با نماد سینی سیستم آن را باز کنید.",
+ "attention_label": "چشمک نوار وظیفه هنگام نامه جدید",
+ "attention_mac_label": "جهش نماد Dock هنگام نامه جدید",
+ "attention_description": "فقط وقتی برنامه در جلو نیست.",
+ "quiet_hours_label": "ساعات سکوت",
+ "quiet_hours_description": "در این ساعات اعلان و صدایی نیست. نامهها همچنان میرسند.",
+ "quiet_hours_from": "از",
+ "quiet_hours_to": "تا",
+ "show_shortcut_label": "نمایش برنامه با میانبر صفحهکلید",
+ "show_shortcut_description": "{keys} برنامه را از هر برنامه دیگری به جلو میآورد.",
+ "show_shortcut_taken": "برنامه دیگری از {keys} استفاده میکند. آن را ببندید، یا این گزینه را خاموش و دوباره روشن کنید.",
+ "default_app_label": "برنامه پیشفرض ایمیل",
+ "default_app_description": "باز کردن پیوندهای ایمیل و پروندههای .eml، .ics و .vcf با این برنامه.",
+ "default_app_is_default": "این برنامه پیوندهای ایمیل شما را باز میکند.",
+ "default_app_button": "انتخاب برنامه پیشفرض…",
+ "new_mail": "نامه جدید",
+ "no_subject": "(بدون موضوع)",
+ "action_delete": "حذف",
+ "copy_as_file": "رونوشت بهصورت پرونده",
+ "copied": "پرونده رونوشت شد. میتوانید آن را در یک پوشه بچسبانید.",
+ "copy_failed": "رونوشت پرونده ممکن نشد."
}
}
diff --git a/locales/fr/common.json b/locales/fr/common.json
index e30800b16..b51367064 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -6561,5 +6561,35 @@
"label_description": "Retirez votre autorisation. Le serveur devra la redemander.",
"revoke": "Retirer",
"revoked": "Autorisation retirée"
+ },
+ "desktopOs": {
+ "section_title": "Application de bureau",
+ "section_description": "Comment l'application fonctionne avec votre ordinateur.",
+ "close_to_tray_label": "Rester dans la zone de notification à la fermeture",
+ "close_to_tray_description": "Le courrier et les rappels continuent d'arriver après la fermeture de la fenêtre.",
+ "start_at_login_label": "Démarrer à l'ouverture de session",
+ "start_at_login_description": "L'application démarre seule quand vous ouvrez votre session.",
+ "start_minimized_label": "Démarrer dans la zone de notification",
+ "start_minimized_description": "Aucune fenêtre ne s'ouvre à l'ouverture de session. Ouvrez-la avec l'icône de la zone de notification.",
+ "attention_label": "Faire clignoter la barre des tâches à l'arrivée de courrier",
+ "attention_mac_label": "Faire rebondir l'icône du Dock à l'arrivée de courrier",
+ "attention_description": "Seulement quand l'application n'est pas au premier plan.",
+ "quiet_hours_label": "Heures calmes",
+ "quiet_hours_description": "Ni notifications ni sons pendant ces heures. Le courrier continue d'arriver.",
+ "quiet_hours_from": "De",
+ "quiet_hours_to": "à",
+ "show_shortcut_label": "Afficher l'application avec un raccourci clavier",
+ "show_shortcut_description": "{keys} amène l'application au premier plan depuis n'importe quelle autre.",
+ "show_shortcut_taken": "Une autre application utilise {keys}. Fermez-la, ou désactivez puis réactivez cette option.",
+ "default_app_label": "Application de messagerie par défaut",
+ "default_app_description": "Ouvrir les liens de messagerie et les fichiers .eml, .ics et .vcf avec cette application.",
+ "default_app_is_default": "Cette application ouvre vos liens de messagerie.",
+ "default_app_button": "Choisir l'application par défaut…",
+ "new_mail": "Nouveau courrier",
+ "no_subject": "(sans objet)",
+ "action_delete": "Supprimer",
+ "copy_as_file": "Copier en tant que fichier",
+ "copied": "Fichier copié. Vous pouvez le coller dans un dossier.",
+ "copy_failed": "Le fichier n'a pas pu être copié."
}
}
diff --git a/locales/he/common.json b/locales/he/common.json
index 571f30e34..613e36392 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -6562,5 +6562,35 @@
"label_description": "בטל את ההרשאה שלך. השרת יצטרך לבקש שוב.",
"revoke": "ביטול",
"revoked": "ההרשאה בוטלה"
+ },
+ "desktopOs": {
+ "section_title": "יישום שולחן עבודה",
+ "section_description": "איך היישום עובד עם המחשב שלך.",
+ "close_to_tray_label": "להמשיך לפעול במגש המערכת בסגירה",
+ "close_to_tray_description": "דואר חדש ותזכורות ממשיכים להגיע אחרי סגירת החלון.",
+ "start_at_login_label": "להפעיל בכניסה למחשב",
+ "start_at_login_description": "היישום מופעל מעצמו כשנכנסים.",
+ "start_minimized_label": "להפעיל במגש המערכת",
+ "start_minimized_description": "בכניסה לא נפתח חלון. פותחים אותו מהסמל במגש המערכת.",
+ "attention_label": "הבהוב בשורת המשימות בדואר חדש",
+ "attention_mac_label": "קפיצת סמל ה-Dock בדואר חדש",
+ "attention_description": "רק כשהיישום לא בחזית.",
+ "quiet_hours_label": "שעות שקט",
+ "quiet_hours_description": "בשעות אלה אין התראות וצלילים. הדואר ממשיך להגיע.",
+ "quiet_hours_from": "מ-",
+ "quiet_hours_to": "עד",
+ "show_shortcut_label": "הצגת היישום בקיצור מקשים",
+ "show_shortcut_description": "{keys} מביא את היישום לחזית מכל יישום אחר.",
+ "show_shortcut_taken": "יישום אחר משתמש ב-{keys}. סגרו אותו, או כבו והפעילו מחדש את האפשרות הזו.",
+ "default_app_label": "יישום הדואר המוגדר כברירת מחדל",
+ "default_app_description": "פתיחת קישורי דואר וקובצי .eml, .ics ו-.vcf ביישום זה.",
+ "default_app_is_default": "יישום זה פותח את קישורי הדואר שלך.",
+ "default_app_button": "בחירת יישום ברירת מחדל…",
+ "new_mail": "דואר חדש",
+ "no_subject": "(ללא נושא)",
+ "action_delete": "מחיקה",
+ "copy_as_file": "העתקה כקובץ",
+ "copied": "הקובץ הועתק. אפשר להדביק אותו בתיקייה.",
+ "copy_failed": "לא ניתן להעתיק את הקובץ."
}
}
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 2ab59129e..b72bcfc0d 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -6561,5 +6561,35 @@
"label_description": "Vonja vissza az engedélyét. A kiszolgálónak újra kérnie kell.",
"revoke": "Visszavonás",
"revoked": "Engedély visszavonva"
+ },
+ "desktopOs": {
+ "section_title": "Asztali alkalmazás",
+ "section_description": "Hogyan működik az alkalmazás a számítógépével.",
+ "close_to_tray_label": "Bezáráskor fusson tovább a tálcán",
+ "close_to_tray_description": "Az új levelek és emlékeztetők az ablak bezárása után is megérkeznek.",
+ "start_at_login_label": "Indítás a számítógépre való bejelentkezéskor",
+ "start_at_login_description": "Az alkalmazás bejelentkezéskor magától elindul.",
+ "start_minimized_label": "Indítás a tálcán",
+ "start_minimized_description": "Bejelentkezéskor nem nyílik ablak. A tálcaikonnal nyithatja meg.",
+ "attention_label": "Tálca villogtatása új levélnél",
+ "attention_mac_label": "Dock-ikon ugráltatása új levélnél",
+ "attention_description": "Csak ha az alkalmazás nincs előtérben.",
+ "quiet_hours_label": "Csendes órák",
+ "quiet_hours_description": "Ekkor nincs értesítés és hang. A levelek továbbra is megérkeznek.",
+ "quiet_hours_from": "Ettől",
+ "quiet_hours_to": "eddig",
+ "show_shortcut_label": "Az alkalmazás megjelenítése billentyűparanccsal",
+ "show_shortcut_description": "{keys} bármely más alkalmazásból előtérbe hozza az alkalmazást.",
+ "show_shortcut_taken": "Egy másik alkalmazás használja ezt: {keys}. Zárja be azt, vagy kapcsolja ki és be ezt a beállítást.",
+ "default_app_label": "Alapértelmezett levelezőalkalmazás",
+ "default_app_description": "A levélhivatkozások és a .eml, .ics és .vcf fájlok megnyitása ezzel az alkalmazással.",
+ "default_app_is_default": "Ez az alkalmazás nyitja meg a levélhivatkozásait.",
+ "default_app_button": "Alapértelmezett alkalmazás kiválasztása…",
+ "new_mail": "Új levél",
+ "no_subject": "(nincs tárgy)",
+ "action_delete": "Törlés",
+ "copy_as_file": "Másolás fájlként",
+ "copied": "Fájl másolva. Beillesztheti egy mappába.",
+ "copy_failed": "A fájlt nem sikerült másolni."
}
}
diff --git a/locales/it/common.json b/locales/it/common.json
index a861d3390..421714d43 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -6561,5 +6561,35 @@
"label_description": "Ritira il tuo permesso. Il server dovrà chiederlo di nuovo.",
"revoke": "Ritira",
"revoked": "Permesso ritirato"
+ },
+ "desktopOs": {
+ "section_title": "App desktop",
+ "section_description": "Come l'app funziona con il tuo computer.",
+ "close_to_tray_label": "Resta nell'area di notifica alla chiusura",
+ "close_to_tray_description": "La nuova posta e i promemoria continuano ad arrivare dopo la chiusura della finestra.",
+ "start_at_login_label": "Avvia all'accesso al computer",
+ "start_at_login_description": "L'app si avvia da sola quando accedi.",
+ "start_minimized_label": "Avvia nell'area di notifica",
+ "start_minimized_description": "All'accesso non si apre alcuna finestra. Aprila con l'icona nell'area di notifica.",
+ "attention_label": "Fai lampeggiare la barra delle applicazioni con nuova posta",
+ "attention_mac_label": "Fai rimbalzare l'icona del Dock con nuova posta",
+ "attention_description": "Solo quando l'app non è in primo piano.",
+ "quiet_hours_label": "Ore di silenzio",
+ "quiet_hours_description": "Nessuna notifica o suono in queste ore. La posta continua ad arrivare.",
+ "quiet_hours_from": "Da",
+ "quiet_hours_to": "a",
+ "show_shortcut_label": "Mostra l'app con una scorciatoia da tastiera",
+ "show_shortcut_description": "{keys} porta l'app in primo piano da qualsiasi altra app.",
+ "show_shortcut_taken": "Un'altra app usa {keys}. Chiudila, oppure disattiva e riattiva questa opzione.",
+ "default_app_label": "App di posta predefinita",
+ "default_app_description": "Apri i link di posta e i file .eml, .ics e .vcf con questa app.",
+ "default_app_is_default": "Questa app apre i tuoi link di posta.",
+ "default_app_button": "Scegli app predefinita…",
+ "new_mail": "Nuova posta",
+ "no_subject": "(nessun oggetto)",
+ "action_delete": "Elimina",
+ "copy_as_file": "Copia come file",
+ "copied": "File copiato. Puoi incollarlo in una cartella.",
+ "copy_failed": "Impossibile copiare il file."
}
}
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 0606617f1..1b72f2fb8 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -6561,5 +6561,35 @@
"label_description": "許可を取り消します。サーバーは再度確認する必要があります。",
"revoke": "取り消す",
"revoked": "許可を取り消しました"
+ },
+ "desktopOs": {
+ "section_title": "デスクトップアプリ",
+ "section_description": "アプリとパソコンの連携方法です。",
+ "close_to_tray_label": "閉じてもトレイで実行を続ける",
+ "close_to_tray_description": "ウィンドウを閉じても新着メールとリマインダーは届きます。",
+ "start_at_login_label": "パソコンへのサインイン時に起動",
+ "start_at_login_description": "サインインするとアプリが自動で起動します。",
+ "start_minimized_label": "トレイで起動",
+ "start_minimized_description": "サインイン時にウィンドウは開きません。トレイのアイコンから開いてください。",
+ "attention_label": "新着メールでタスクバーを点滅",
+ "attention_mac_label": "新着メールで Dock アイコンを跳ねさせる",
+ "attention_description": "アプリが前面にないときのみ。",
+ "quiet_hours_label": "おやすみ時間",
+ "quiet_hours_description": "この時間は通知も音も出ません。メールは届きます。",
+ "quiet_hours_from": "開始",
+ "quiet_hours_to": "終了",
+ "show_shortcut_label": "キーボードショートカットでアプリを表示",
+ "show_shortcut_description": "{keys} で、ほかのアプリからこのアプリを前面に表示します。",
+ "show_shortcut_taken": "別のアプリが {keys} を使っています。そのアプリを閉じるか、この設定をオフにしてからオンに戻してください。",
+ "default_app_label": "既定のメールアプリ",
+ "default_app_description": "メールのリンクと .eml、.ics、.vcf ファイルをこのアプリで開きます。",
+ "default_app_is_default": "メールのリンクはこのアプリで開きます。",
+ "default_app_button": "既定のアプリを選択…",
+ "new_mail": "新着メール",
+ "no_subject": "(件名なし)",
+ "action_delete": "削除",
+ "copy_as_file": "ファイルとしてコピー",
+ "copied": "ファイルをコピーしました。フォルダーに貼り付けられます。",
+ "copy_failed": "ファイルをコピーできませんでした。"
}
}
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 7e0daa574..ce7ac48d8 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -6561,5 +6561,35 @@
"label_description": "허용을 취소합니다. 서버는 다시 요청해야 합니다.",
"revoke": "취소",
"revoked": "허용을 취소했습니다"
+ },
+ "desktopOs": {
+ "section_title": "데스크톱 앱",
+ "section_description": "앱이 컴퓨터와 함께 작동하는 방식입니다.",
+ "close_to_tray_label": "닫을 때 트레이에서 계속 실행",
+ "close_to_tray_description": "창을 닫아도 새 메일과 리마인더가 계속 도착합니다.",
+ "start_at_login_label": "컴퓨터에 로그인할 때 시작",
+ "start_at_login_description": "로그인하면 앱이 자동으로 시작됩니다.",
+ "start_minimized_label": "트레이에서 시작",
+ "start_minimized_description": "로그인할 때 창이 열리지 않습니다. 트레이 아이콘으로 여세요.",
+ "attention_label": "새 메일이 오면 작업 표시줄 깜박이기",
+ "attention_mac_label": "새 메일이 오면 Dock 아이콘 튀기기",
+ "attention_description": "앱이 앞에 있지 않을 때만 작동합니다.",
+ "quiet_hours_label": "방해 금지 시간",
+ "quiet_hours_description": "이 시간에는 알림과 소리가 없습니다. 메일은 계속 도착합니다.",
+ "quiet_hours_from": "시작",
+ "quiet_hours_to": "종료",
+ "show_shortcut_label": "키보드 단축키로 앱 표시",
+ "show_shortcut_description": "{keys} 키로 다른 앱에서도 이 앱을 앞으로 가져옵니다.",
+ "show_shortcut_taken": "다른 앱이 {keys} 키를 사용 중입니다. 그 앱을 닫거나 이 옵션을 껐다가 다시 켜세요.",
+ "default_app_label": "기본 메일 앱",
+ "default_app_description": "메일 링크와 .eml, .ics, .vcf 파일을 이 앱으로 엽니다.",
+ "default_app_is_default": "이 앱이 메일 링크를 엽니다.",
+ "default_app_button": "기본 앱 선택…",
+ "new_mail": "새 메일",
+ "no_subject": "(제목 없음)",
+ "action_delete": "삭제",
+ "copy_as_file": "파일로 복사",
+ "copied": "파일을 복사했습니다. 폴더에 붙여넣을 수 있습니다.",
+ "copy_failed": "파일을 복사할 수 없습니다."
}
}
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 05939fbc0..b45fabbb4 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -6561,5 +6561,35 @@
"label_description": "Atsauciet savu atļauju. Serverim būs jālūdz vēlreiz.",
"revoke": "Atsaukt",
"revoked": "Atļauja atsaukta"
+ },
+ "desktopOs": {
+ "section_title": "Darbvirsmas lietotne",
+ "section_description": "Kā lietotne darbojas ar jūsu datoru.",
+ "close_to_tray_label": "Aizverot turpināt darbu teknē",
+ "close_to_tray_description": "Jauns pasts un atgādinājumi turpina pienākt arī pēc loga aizvēršanas.",
+ "start_at_login_label": "Sākt, piesakoties datorā",
+ "start_at_login_description": "Lietotne sākas pati, kad piesakāties.",
+ "start_minimized_label": "Sākt teknē",
+ "start_minimized_description": "Piesakoties logs neatveras. Atveriet to ar teknes ikonu.",
+ "attention_label": "Mirgot uzdevumjoslā, pienākot pastam",
+ "attention_mac_label": "Lēkāt Dock ikonai, pienākot pastam",
+ "attention_description": "Tikai tad, ja lietotne nav priekšplānā.",
+ "quiet_hours_label": "Klusuma stundas",
+ "quiet_hours_description": "Šajā laikā nav paziņojumu un skaņu. Pasts turpina pienākt.",
+ "quiet_hours_from": "No",
+ "quiet_hours_to": "līdz",
+ "show_shortcut_label": "Rādīt lietotni ar īsinājumtaustiņu",
+ "show_shortcut_description": "{keys} parāda lietotni priekšplānā no jebkuras citas lietotnes.",
+ "show_shortcut_taken": "{keys} izmanto cita lietotne. Aizveriet to vai izslēdziet un atkal ieslēdziet šo iestatījumu.",
+ "default_app_label": "Noklusējuma pasta lietotne",
+ "default_app_description": "Atvērt pasta saites un .eml, .ics un .vcf failus ar šo lietotni.",
+ "default_app_is_default": "Šī lietotne atver jūsu pasta saites.",
+ "default_app_button": "Izvēlēties noklusējuma lietotni…",
+ "new_mail": "Jauns pasts",
+ "no_subject": "(nav tēmas)",
+ "action_delete": "Dzēst",
+ "copy_as_file": "Kopēt kā failu",
+ "copied": "Fails nokopēts. Varat to ielīmēt mapē.",
+ "copy_failed": "Failu neizdevās nokopēt."
}
}
diff --git a/locales/mn/common.json b/locales/mn/common.json
index dfa942d34..243f02639 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -6561,5 +6561,35 @@
"label_description": "Зөвшөөрлөө буцааж авна уу. Сервер дахин асуух шаардлагатай.",
"revoke": "Буцааж авах",
"revoked": "Зөвшөөрлийг буцааж авлаа"
+ },
+ "desktopOs": {
+ "section_title": "Ширээний апп",
+ "section_description": "Апп таны компьютертэй хэрхэн ажиллах.",
+ "close_to_tray_label": "Хаахад тавиур дээр үргэлжлүүлэн ажиллах",
+ "close_to_tray_description": "Цонхыг хаасны дараа ч шинэ захиа, сануулга ирсээр байна.",
+ "start_at_login_label": "Компьютерт нэвтрэхэд эхлүүлэх",
+ "start_at_login_description": "Нэвтрэхэд апп өөрөө эхэлнэ.",
+ "start_minimized_label": "Тавиур дээр эхлүүлэх",
+ "start_minimized_description": "Нэвтрэхэд цонх нээгдэхгүй. Тавиурын дүрсээр нээнэ үү.",
+ "attention_label": "Шинэ захиа ирэхэд ажлын самбарыг анивчуулах",
+ "attention_mac_label": "Шинэ захиа ирэхэд Dock дүрсийг үсрүүлэх",
+ "attention_description": "Зөвхөн апп урд талд байхгүй үед.",
+ "quiet_hours_label": "Чимээгүй цаг",
+ "quiet_hours_description": "Энэ үед мэдэгдэл, дуу гарахгүй. Захиа ирсээр байна.",
+ "quiet_hours_from": "Эхлэх",
+ "quiet_hours_to": "хүртэл",
+ "show_shortcut_label": "Гарын товчлолоор аппыг харуулах",
+ "show_shortcut_description": "{keys} нь аль ч аппаас энэ аппыг урд гаргана.",
+ "show_shortcut_taken": "{keys}-г өөр апп ашиглаж байна. Тэр аппыг хаах, эсвэл үүнийг унтраагаад дахин асаана уу.",
+ "default_app_label": "Үндсэн шуудангийн апп",
+ "default_app_description": "Шуудангийн холбоос болон .eml, .ics, .vcf файлыг энэ аппаар нээх.",
+ "default_app_is_default": "Энэ апп таны шуудангийн холбоосыг нээдэг.",
+ "default_app_button": "Үндсэн апп сонгох…",
+ "new_mail": "Шинэ захиа",
+ "no_subject": "(гарчиггүй)",
+ "action_delete": "Устгах",
+ "copy_as_file": "Файл болгож хуулах",
+ "copied": "Файлыг хууллаа. Хавтсанд буулгаж болно.",
+ "copy_failed": "Файлыг хуулж чадсангүй."
}
}
diff --git a/locales/nb/common.json b/locales/nb/common.json
index a0002acca..f2f0c0de7 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -6561,5 +6561,35 @@
"label_description": "Trekk tilbake tillatelsen din. Serveren må da spørre på nytt.",
"revoke": "Trekk tilbake",
"revoked": "Tillatelsen er trukket tilbake"
+ },
+ "desktopOs": {
+ "section_title": "Skrivebordsapp",
+ "section_description": "Slik samarbeider appen med datamaskinen din.",
+ "close_to_tray_label": "Fortsett i systemstatusfeltet ved lukking",
+ "close_to_tray_description": "Ny e-post og påminnelser fortsetter å komme etter at du lukker vinduet.",
+ "start_at_login_label": "Start når du logger på datamaskinen",
+ "start_at_login_description": "Appen starter av seg selv når du logger på.",
+ "start_minimized_label": "Start i systemstatusfeltet",
+ "start_minimized_description": "Det åpnes ikke noe vindu ved pålogging. Åpne appen med ikonet i systemstatusfeltet.",
+ "attention_label": "Blink i oppgavelinjen ved ny e-post",
+ "attention_mac_label": "La Dock-ikonet sprette ved ny e-post",
+ "attention_description": "Bare når appen ikke er foran.",
+ "quiet_hours_label": "Stille timer",
+ "quiet_hours_description": "Ingen varsler eller lyder i dette tidsrommet. E-post kommer fortsatt.",
+ "quiet_hours_from": "Fra",
+ "quiet_hours_to": "til",
+ "show_shortcut_label": "Vis appen med en hurtigtast",
+ "show_shortcut_description": "{keys} henter appen frem fra hvilken som helst annen app.",
+ "show_shortcut_taken": "En annen app bruker {keys}. Lukk den appen, eller slå dette av og på igjen.",
+ "default_app_label": "Standard e-postapp",
+ "default_app_description": "Åpne e-postlenker og .eml-, .ics- og .vcf-filer med denne appen.",
+ "default_app_is_default": "Denne appen åpner e-postlenkene dine.",
+ "default_app_button": "Velg standardapp…",
+ "new_mail": "Ny e-post",
+ "no_subject": "(uten emne)",
+ "action_delete": "Slett",
+ "copy_as_file": "Kopier som fil",
+ "copied": "Filen er kopiert. Du kan lime den inn i en mappe.",
+ "copy_failed": "Filen kunne ikke kopieres."
}
}
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 5524a36da..6ab31ef9b 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -6561,5 +6561,35 @@
"label_description": "Trek uw toestemming in. De server moet dan opnieuw vragen.",
"revoke": "Intrekken",
"revoked": "Toestemming ingetrokken"
+ },
+ "desktopOs": {
+ "section_title": "Desktop-app",
+ "section_description": "Hoe de app samenwerkt met je computer.",
+ "close_to_tray_label": "Bij sluiten doorgaan in het systeemvak",
+ "close_to_tray_description": "Nieuwe mail en herinneringen blijven komen nadat je het venster sluit.",
+ "start_at_login_label": "Starten als je je aanmeldt op de computer",
+ "start_at_login_description": "De app start vanzelf als je je aanmeldt.",
+ "start_minimized_label": "Starten in het systeemvak",
+ "start_minimized_description": "Bij het aanmelden opent geen venster. Open de app met het pictogram in het systeemvak.",
+ "attention_label": "Taakbalk laten knipperen bij nieuwe mail",
+ "attention_mac_label": "Dock-pictogram laten stuiteren bij nieuwe mail",
+ "attention_description": "Alleen als de app niet vooraan staat.",
+ "quiet_hours_label": "Stille uren",
+ "quiet_hours_description": "Geen meldingen of geluiden op deze tijden. Mail blijft binnenkomen.",
+ "quiet_hours_from": "Van",
+ "quiet_hours_to": "tot",
+ "show_shortcut_label": "App tonen met een sneltoets",
+ "show_shortcut_description": "{keys} haalt de app naar voren vanuit elke andere app.",
+ "show_shortcut_taken": "Een andere app gebruikt {keys}. Sluit die app, of zet dit uit en weer aan.",
+ "default_app_label": "Standaard-mailapp",
+ "default_app_description": "Maillinks en .eml-, .ics- en .vcf-bestanden openen met deze app.",
+ "default_app_is_default": "Deze app opent je maillinks.",
+ "default_app_button": "Standaard-app kiezen…",
+ "new_mail": "Nieuwe mail",
+ "no_subject": "(geen onderwerp)",
+ "action_delete": "Verwijderen",
+ "copy_as_file": "Kopiëren als bestand",
+ "copied": "Bestand gekopieerd. Je kunt het in een map plakken.",
+ "copy_failed": "Het bestand kon niet worden gekopieerd."
}
}
diff --git a/locales/pl/common.json b/locales/pl/common.json
index fa5b5481e..4590d48b3 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -6561,5 +6561,35 @@
"label_description": "Cofnij swoją zgodę. Serwer będzie musiał zapytać ponownie.",
"revoke": "Cofnij",
"revoked": "Zgoda została cofnięta"
+ },
+ "desktopOs": {
+ "section_title": "Aplikacja na komputer",
+ "section_description": "Jak aplikacja współpracuje z Twoim komputerem.",
+ "close_to_tray_label": "Po zamknięciu działaj dalej w zasobniku",
+ "close_to_tray_description": "Nowa poczta i przypomnienia docierają też po zamknięciu okna.",
+ "start_at_login_label": "Uruchamiaj po zalogowaniu do komputera",
+ "start_at_login_description": "Aplikacja uruchamia się sama po zalogowaniu.",
+ "start_minimized_label": "Uruchamiaj w zasobniku",
+ "start_minimized_description": "Po zalogowaniu nie otwiera się okno. Otwórz je ikoną w zasobniku.",
+ "attention_label": "Migaj na pasku zadań przy nowej poczcie",
+ "attention_mac_label": "Podskakuj ikoną w Docku przy nowej poczcie",
+ "attention_description": "Tylko gdy aplikacja nie jest na wierzchu.",
+ "quiet_hours_label": "Godziny ciszy",
+ "quiet_hours_description": "W tym czasie bez powiadomień i dźwięków. Poczta nadal dociera.",
+ "quiet_hours_from": "Od",
+ "quiet_hours_to": "do",
+ "show_shortcut_label": "Pokazuj aplikację skrótem klawiszowym",
+ "show_shortcut_description": "{keys} przywołuje aplikację z każdej innej aplikacji.",
+ "show_shortcut_taken": "Skrót {keys} jest używany przez inną aplikację. Zamknij ją albo wyłącz i włącz tę opcję.",
+ "default_app_label": "Domyślna aplikacja poczty",
+ "default_app_description": "Otwieraj linki pocztowe i pliki .eml, .ics i .vcf w tej aplikacji.",
+ "default_app_is_default": "Ta aplikacja otwiera Twoje linki pocztowe.",
+ "default_app_button": "Wybierz domyślną aplikację…",
+ "new_mail": "Nowa poczta",
+ "no_subject": "(bez tematu)",
+ "action_delete": "Usuń",
+ "copy_as_file": "Kopiuj jako plik",
+ "copied": "Plik skopiowany. Możesz wkleić go do folderu.",
+ "copy_failed": "Nie udało się skopiować pliku."
}
}
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 87a2e2ff1..97ed21d66 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -6561,5 +6561,35 @@
"label_description": "Retire a sua permissão. O servidor terá de pedir novamente.",
"revoke": "Retirar",
"revoked": "Permissão retirada"
+ },
+ "desktopOs": {
+ "section_title": "Aplicação de ambiente de trabalho",
+ "section_description": "Como a aplicação funciona com o seu computador.",
+ "close_to_tray_label": "Continuar na bandeja ao fechar",
+ "close_to_tray_description": "O correio novo e os lembretes continuam a chegar depois de fechar a janela.",
+ "start_at_login_label": "Iniciar ao entrar no computador",
+ "start_at_login_description": "A aplicação inicia sozinha quando entra.",
+ "start_minimized_label": "Iniciar na bandeja",
+ "start_minimized_description": "Não abre nenhuma janela ao entrar. Abra-a com o ícone da bandeja.",
+ "attention_label": "Piscar a barra de tarefas com correio novo",
+ "attention_mac_label": "Saltar o ícone da Dock com correio novo",
+ "attention_description": "Só quando a aplicação não está à frente.",
+ "quiet_hours_label": "Horas de silêncio",
+ "quiet_hours_description": "Sem notificações nem sons nestas horas. O correio continua a chegar.",
+ "quiet_hours_from": "De",
+ "quiet_hours_to": "até",
+ "show_shortcut_label": "Mostrar a aplicação com um atalho de teclado",
+ "show_shortcut_description": "{keys} traz a aplicação para a frente a partir de qualquer outra.",
+ "show_shortcut_taken": "Outra aplicação está a usar {keys}. Feche-a, ou desative e volte a ativar esta opção.",
+ "default_app_label": "Aplicação de correio predefinida",
+ "default_app_description": "Abrir ligações de correio e ficheiros .eml, .ics e .vcf com esta aplicação.",
+ "default_app_is_default": "Esta aplicação abre as suas ligações de correio.",
+ "default_app_button": "Escolher aplicação predefinida…",
+ "new_mail": "Correio novo",
+ "no_subject": "(sem assunto)",
+ "action_delete": "Eliminar",
+ "copy_as_file": "Copiar como ficheiro",
+ "copied": "Ficheiro copiado. Pode colá-lo numa pasta.",
+ "copy_failed": "Não foi possível copiar o ficheiro."
}
}
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 80c31fa28..0936d12f4 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -6561,5 +6561,35 @@
"label_description": "Retrageți permisiunea. Serverul va trebui să ceară din nou.",
"revoke": "Retrage",
"revoked": "Permisiune retrasă"
+ },
+ "desktopOs": {
+ "section_title": "Aplicație desktop",
+ "section_description": "Cum funcționează aplicația cu computerul tău.",
+ "close_to_tray_label": "Rămâne în zona de notificări la închidere",
+ "close_to_tray_description": "E-mailurile noi și mementourile continuă să sosească după închiderea ferestrei.",
+ "start_at_login_label": "Pornește la conectarea în computer",
+ "start_at_login_description": "Aplicația pornește singură când te conectezi.",
+ "start_minimized_label": "Pornește în zona de notificări",
+ "start_minimized_description": "La conectare nu se deschide nicio fereastră. Deschide-o din pictograma din zona de notificări.",
+ "attention_label": "Clipește în bara de activități la e-mail nou",
+ "attention_mac_label": "Sare pictograma din Dock la e-mail nou",
+ "attention_description": "Doar când aplicația nu este în față.",
+ "quiet_hours_label": "Ore de liniște",
+ "quiet_hours_description": "Fără notificări sau sunete în aceste ore. E-mailurile sosesc în continuare.",
+ "quiet_hours_from": "De la",
+ "quiet_hours_to": "până la",
+ "show_shortcut_label": "Arată aplicația cu o comandă rapidă de la tastatură",
+ "show_shortcut_description": "{keys} aduce aplicația în față din orice altă aplicație.",
+ "show_shortcut_taken": "Altă aplicație folosește {keys}. Închide-o sau dezactivează și reactivează această opțiune.",
+ "default_app_label": "Aplicația de e-mail implicită",
+ "default_app_description": "Deschide linkurile de e-mail și fișierele .eml, .ics și .vcf cu această aplicație.",
+ "default_app_is_default": "Această aplicație îți deschide linkurile de e-mail.",
+ "default_app_button": "Alege aplicația implicită…",
+ "new_mail": "E-mail nou",
+ "no_subject": "(fără subiect)",
+ "action_delete": "Șterge",
+ "copy_as_file": "Copiază ca fișier",
+ "copied": "Fișier copiat. Îl poți lipi într-un dosar.",
+ "copy_failed": "Fișierul nu a putut fi copiat."
}
}
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 55510f4c8..eb1641895 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -6561,5 +6561,35 @@
"label_description": "Отзовите своё разрешение. Серверу придётся запросить его снова.",
"revoke": "Отозвать",
"revoked": "Разрешение отозвано"
+ },
+ "desktopOs": {
+ "section_title": "Настольное приложение",
+ "section_description": "Как приложение работает с вашим компьютером.",
+ "close_to_tray_label": "При закрытии продолжать работу в трее",
+ "close_to_tray_description": "Новая почта и напоминания приходят и после закрытия окна.",
+ "start_at_login_label": "Запускать при входе в систему",
+ "start_at_login_description": "Приложение запускается само, когда вы входите.",
+ "start_minimized_label": "Запускать в трее",
+ "start_minimized_description": "При входе окно не открывается. Откройте его значком в трее.",
+ "attention_label": "Мигать на панели задач при новой почте",
+ "attention_mac_label": "Подпрыгивать значком в Dock при новой почте",
+ "attention_description": "Только когда приложение не на переднем плане.",
+ "quiet_hours_label": "Тихие часы",
+ "quiet_hours_description": "В это время нет уведомлений и звуков. Почта продолжает приходить.",
+ "quiet_hours_from": "С",
+ "quiet_hours_to": "до",
+ "show_shortcut_label": "Показывать приложение сочетанием клавиш",
+ "show_shortcut_description": "{keys} выводит приложение на передний план из любого другого.",
+ "show_shortcut_taken": "{keys} использует другое приложение. Закройте его или выключите и снова включите этот параметр.",
+ "default_app_label": "Почтовое приложение по умолчанию",
+ "default_app_description": "Открывать почтовые ссылки и файлы .eml, .ics и .vcf в этом приложении.",
+ "default_app_is_default": "Это приложение открывает ваши почтовые ссылки.",
+ "default_app_button": "Выбрать приложение по умолчанию…",
+ "new_mail": "Новая почта",
+ "no_subject": "(без темы)",
+ "action_delete": "Удалить",
+ "copy_as_file": "Копировать как файл",
+ "copied": "Файл скопирован. Его можно вставить в папку.",
+ "copy_failed": "Не удалось скопировать файл."
}
}
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 9e12b12d2..4431aa2a7 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -6561,5 +6561,35 @@
"label_description": "Odvolajte svoje povolenie. Server sa bude musieť opýtať znova.",
"revoke": "Odvolať",
"revoked": "Povolenie bolo odvolané"
+ },
+ "desktopOs": {
+ "section_title": "Počítačová aplikácia",
+ "section_description": "Ako aplikácia spolupracuje s vaším počítačom.",
+ "close_to_tray_label": "Po zatvorení bežať ďalej v oblasti oznámení",
+ "close_to_tray_description": "Nová pošta a pripomienky prichádzajú aj po zatvorení okna.",
+ "start_at_login_label": "Spustiť po prihlásení do počítača",
+ "start_at_login_description": "Aplikácia sa po prihlásení spustí sama.",
+ "start_minimized_label": "Spustiť v oblasti oznámení",
+ "start_minimized_description": "Po prihlásení sa neotvorí žiadne okno. Otvorte ju ikonou v oblasti oznámení.",
+ "attention_label": "Pri novej pošte blikať na paneli úloh",
+ "attention_mac_label": "Pri novej pošte poskakovať ikonou v Docku",
+ "attention_description": "Len keď aplikácia nie je v popredí.",
+ "quiet_hours_label": "Čas pokoja",
+ "quiet_hours_description": "V tomto čase žiadne oznámenia ani zvuky. Pošta prichádza ďalej.",
+ "quiet_hours_from": "Od",
+ "quiet_hours_to": "do",
+ "show_shortcut_label": "Zobraziť aplikáciu klávesovou skratkou",
+ "show_shortcut_description": "{keys} prenesie aplikáciu do popredia z akejkoľvek inej aplikácie.",
+ "show_shortcut_taken": "{keys} používa iná aplikácia. Zatvorte ju alebo túto možnosť vypnite a znova zapnite.",
+ "default_app_label": "Predvolená poštová aplikácia",
+ "default_app_description": "Otvárať poštové odkazy a súbory .eml, .ics a .vcf v tejto aplikácii.",
+ "default_app_is_default": "Táto aplikácia otvára vaše poštové odkazy.",
+ "default_app_button": "Vybrať predvolenú aplikáciu…",
+ "new_mail": "Nová pošta",
+ "no_subject": "(bez predmetu)",
+ "action_delete": "Odstrániť",
+ "copy_as_file": "Kopírovať ako súbor",
+ "copied": "Súbor skopírovaný. Môžete ho vložiť do priečinka.",
+ "copy_failed": "Súbor sa nepodarilo skopírovať."
}
}
diff --git a/locales/th/common.json b/locales/th/common.json
index 0be3db9db..47156ac85 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -6569,5 +6569,35 @@
"label_description": "ถอนการอนุญาตของคุณ เซิร์ฟเวอร์จะต้องขออีกครั้ง",
"revoke": "ถอน",
"revoked": "ถอนการอนุญาตแล้ว"
+ },
+ "desktopOs": {
+ "section_title": "แอปเดสก์ท็อป",
+ "section_description": "วิธีที่แอปทำงานกับคอมพิวเตอร์ของคุณ",
+ "close_to_tray_label": "ทำงานต่อในถาดระบบเมื่อปิด",
+ "close_to_tray_description": "เมลใหม่และการแจ้งเตือนยังเข้ามาหลังปิดหน้าต่าง",
+ "start_at_login_label": "เริ่มเมื่อลงชื่อเข้าใช้คอมพิวเตอร์",
+ "start_at_login_description": "แอปเริ่มเองเมื่อคุณลงชื่อเข้าใช้",
+ "start_minimized_label": "เริ่มในถาดระบบ",
+ "start_minimized_description": "ไม่มีหน้าต่างเปิดเมื่อลงชื่อเข้าใช้ เปิดด้วยไอคอนในถาดระบบ",
+ "attention_label": "กะพริบแถบงานเมื่อมีเมลใหม่",
+ "attention_mac_label": "ให้ไอคอน Dock เด้งเมื่อมีเมลใหม่",
+ "attention_description": "เฉพาะเมื่อแอปไม่ได้อยู่ด้านหน้า",
+ "quiet_hours_label": "ช่วงเวลาเงียบ",
+ "quiet_hours_description": "ไม่มีการแจ้งเตือนและเสียงในช่วงนี้ เมลยังเข้ามาตามปกติ",
+ "quiet_hours_from": "จาก",
+ "quiet_hours_to": "ถึง",
+ "show_shortcut_label": "แสดงแอปด้วยแป้นพิมพ์ลัด",
+ "show_shortcut_description": "{keys} นำแอปขึ้นมาด้านหน้าจากแอปอื่นใดก็ได้",
+ "show_shortcut_taken": "แอปอื่นใช้ {keys} อยู่ ปิดแอปนั้น หรือปิดแล้วเปิดตัวเลือกนี้อีกครั้ง",
+ "default_app_label": "แอปเมลเริ่มต้น",
+ "default_app_description": "เปิดลิงก์เมลและไฟล์ .eml, .ics และ .vcf ด้วยแอปนี้",
+ "default_app_is_default": "แอปนี้เปิดลิงก์เมลของคุณ",
+ "default_app_button": "เลือกแอปเริ่มต้น…",
+ "new_mail": "เมลใหม่",
+ "no_subject": "(ไม่มีหัวเรื่อง)",
+ "action_delete": "ลบ",
+ "copy_as_file": "คัดลอกเป็นไฟล์",
+ "copied": "คัดลอกไฟล์แล้ว คุณวางลงในโฟลเดอร์ได้",
+ "copy_failed": "คัดลอกไฟล์ไม่ได้"
}
}
diff --git a/locales/tr/common.json b/locales/tr/common.json
index fa74d9864..391215501 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -6561,5 +6561,35 @@
"label_description": "İzninizi geri alın. Sunucunun yeniden sorması gerekir.",
"revoke": "Geri al",
"revoked": "İzin geri alındı"
+ },
+ "desktopOs": {
+ "section_title": "Masaüstü uygulaması",
+ "section_description": "Uygulamanın bilgisayarınızla nasıl çalıştığı.",
+ "close_to_tray_label": "Kapatınca sistem tepsisinde çalışmaya devam et",
+ "close_to_tray_description": "Pencereyi kapattıktan sonra da yeni postalar ve anımsatıcılar gelmeye devam eder.",
+ "start_at_login_label": "Bilgisayarda oturum açınca başlat",
+ "start_at_login_description": "Oturum açtığınızda uygulama kendiliğinden başlar.",
+ "start_minimized_label": "Sistem tepsisinde başlat",
+ "start_minimized_description": "Oturum açarken pencere açılmaz. Sistem tepsisindeki simgeyle açın.",
+ "attention_label": "Yeni postada görev çubuğunu yanıp söndür",
+ "attention_mac_label": "Yeni postada Dock simgesini zıplat",
+ "attention_description": "Yalnızca uygulama önde değilken.",
+ "quiet_hours_label": "Sessiz saatler",
+ "quiet_hours_description": "Bu saatlerde bildirim ve ses yok. Postalar gelmeye devam eder.",
+ "quiet_hours_from": "Başlangıç",
+ "quiet_hours_to": "bitiş",
+ "show_shortcut_label": "Uygulamayı klavye kısayoluyla göster",
+ "show_shortcut_description": "{keys} uygulamayı başka herhangi bir uygulamadan öne getirir.",
+ "show_shortcut_taken": "{keys} başka bir uygulama tarafından kullanılıyor. O uygulamayı kapatın ya da bunu kapatıp yeniden açın.",
+ "default_app_label": "Varsayılan posta uygulaması",
+ "default_app_description": "Posta bağlantılarını ve .eml, .ics, .vcf dosyalarını bu uygulamayla aç.",
+ "default_app_is_default": "Posta bağlantılarınızı bu uygulama açıyor.",
+ "default_app_button": "Varsayılan uygulamayı seç…",
+ "new_mail": "Yeni posta",
+ "no_subject": "(konu yok)",
+ "action_delete": "Sil",
+ "copy_as_file": "Dosya olarak kopyala",
+ "copied": "Dosya kopyalandı. Bir klasöre yapıştırabilirsiniz.",
+ "copy_failed": "Dosya kopyalanamadı."
}
}
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 391a5c203..ebed90c4e 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -6561,5 +6561,35 @@
"label_description": "Відкличте свій дозвіл. Серверу доведеться запитати знову.",
"revoke": "Відкликати",
"revoked": "Дозвіл відкликано"
+ },
+ "desktopOs": {
+ "section_title": "Настільний застосунок",
+ "section_description": "Як застосунок працює з вашим комп’ютером.",
+ "close_to_tray_label": "Під час закриття працювати далі в треї",
+ "close_to_tray_description": "Нова пошта й нагадування надходять і після закриття вікна.",
+ "start_at_login_label": "Запускати під час входу в систему",
+ "start_at_login_description": "Застосунок запускається сам, коли ви входите.",
+ "start_minimized_label": "Запускати в треї",
+ "start_minimized_description": "Під час входу вікно не відкривається. Відкрийте його значком у треї.",
+ "attention_label": "Блимати на панелі завдань за нової пошти",
+ "attention_mac_label": "Підстрибувати значком у Dock за нової пошти",
+ "attention_description": "Лише коли застосунок не на передньому плані.",
+ "quiet_hours_label": "Тихі години",
+ "quiet_hours_description": "У цей час немає сповіщень і звуків. Пошта надходить далі.",
+ "quiet_hours_from": "З",
+ "quiet_hours_to": "до",
+ "show_shortcut_label": "Показувати застосунок сполученням клавіш",
+ "show_shortcut_description": "{keys} виводить застосунок на передній план із будь-якого іншого.",
+ "show_shortcut_taken": "{keys} використовує інший застосунок. Закрийте його або вимкніть і знову увімкніть цей параметр.",
+ "default_app_label": "Поштовий застосунок за замовчуванням",
+ "default_app_description": "Відкривати поштові посилання та файли .eml, .ics і .vcf у цьому застосунку.",
+ "default_app_is_default": "Цей застосунок відкриває ваші поштові посилання.",
+ "default_app_button": "Вибрати застосунок за замовчуванням…",
+ "new_mail": "Нова пошта",
+ "no_subject": "(без теми)",
+ "action_delete": "Видалити",
+ "copy_as_file": "Копіювати як файл",
+ "copied": "Файл скопійовано. Його можна вставити в папку.",
+ "copy_failed": "Не вдалося скопіювати файл."
}
}
diff --git a/locales/vi/common.json b/locales/vi/common.json
index 347fe2cd0..86de8e353 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -6573,5 +6573,35 @@
"label_description": "Thu hồi quyền của bạn. Máy chủ sẽ phải hỏi lại.",
"revoke": "Thu hồi",
"revoked": "Đã thu hồi quyền"
+ },
+ "desktopOs": {
+ "section_title": "Ứng dụng máy tính",
+ "section_description": "Cách ứng dụng hoạt động với máy tính của bạn.",
+ "close_to_tray_label": "Tiếp tục chạy ở khay hệ thống khi đóng",
+ "close_to_tray_description": "Thư mới và lời nhắc vẫn đến sau khi bạn đóng cửa sổ.",
+ "start_at_login_label": "Khởi động khi đăng nhập vào máy tính",
+ "start_at_login_description": "Ứng dụng tự khởi động khi bạn đăng nhập.",
+ "start_minimized_label": "Khởi động ở khay hệ thống",
+ "start_minimized_description": "Không có cửa sổ nào mở khi đăng nhập. Mở bằng biểu tượng ở khay hệ thống.",
+ "attention_label": "Nhấp nháy thanh tác vụ khi có thư mới",
+ "attention_mac_label": "Làm biểu tượng Dock nảy khi có thư mới",
+ "attention_description": "Chỉ khi ứng dụng không ở phía trước.",
+ "quiet_hours_label": "Giờ yên tĩnh",
+ "quiet_hours_description": "Không có thông báo hay âm thanh vào giờ này. Thư vẫn đến.",
+ "quiet_hours_from": "Từ",
+ "quiet_hours_to": "đến",
+ "show_shortcut_label": "Hiện ứng dụng bằng phím tắt",
+ "show_shortcut_description": "{keys} đưa ứng dụng lên trước từ bất kỳ ứng dụng nào khác.",
+ "show_shortcut_taken": "Ứng dụng khác đang dùng {keys}. Hãy đóng ứng dụng đó, hoặc tắt rồi bật lại tùy chọn này.",
+ "default_app_label": "Ứng dụng thư mặc định",
+ "default_app_description": "Mở liên kết thư và tệp .eml, .ics, .vcf bằng ứng dụng này.",
+ "default_app_is_default": "Ứng dụng này mở các liên kết thư của bạn.",
+ "default_app_button": "Chọn ứng dụng mặc định…",
+ "new_mail": "Thư mới",
+ "no_subject": "(không có chủ đề)",
+ "action_delete": "Xóa",
+ "copy_as_file": "Sao chép dưới dạng tệp",
+ "copied": "Đã sao chép tệp. Bạn có thể dán vào một thư mục.",
+ "copy_failed": "Không thể sao chép tệp."
}
}
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 97dacbb06..f3fb5a71a 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -6561,5 +6561,35 @@
"label_description": "收回你的許可。伺服器需要重新詢問。",
"revoke": "收回",
"revoked": "已收回許可"
+ },
+ "desktopOs": {
+ "section_title": "桌面應用程式",
+ "section_description": "應用程式如何與您的電腦配合。",
+ "close_to_tray_label": "關閉時繼續在系統匣中執行",
+ "close_to_tray_description": "關閉視窗後,新郵件和提醒仍會送達。",
+ "start_at_login_label": "登入電腦時啟動",
+ "start_at_login_description": "您登入時應用程式會自動啟動。",
+ "start_minimized_label": "在系統匣中啟動",
+ "start_minimized_description": "登入時不開啟視窗。請透過系統匣圖示開啟。",
+ "attention_label": "有新郵件時閃爍工作列",
+ "attention_mac_label": "有新郵件時讓 Dock 圖示跳動",
+ "attention_description": "僅在應用程式不在前景時。",
+ "quiet_hours_label": "勿擾時段",
+ "quiet_hours_description": "此時段沒有通知和聲音。郵件仍會送達。",
+ "quiet_hours_from": "從",
+ "quiet_hours_to": "到",
+ "show_shortcut_label": "使用鍵盤快速鍵顯示應用程式",
+ "show_shortcut_description": "{keys} 可從任何其他應用程式將此應用程式帶到前景。",
+ "show_shortcut_taken": "其他應用程式正在使用 {keys}。請關閉該應用程式,或關閉此選項後再開啟。",
+ "default_app_label": "預設郵件應用程式",
+ "default_app_description": "使用此應用程式開啟郵件連結以及 .eml、.ics 和 .vcf 檔案。",
+ "default_app_is_default": "此應用程式會開啟您的郵件連結。",
+ "default_app_button": "選擇預設應用程式…",
+ "new_mail": "新郵件",
+ "no_subject": "(無主旨)",
+ "action_delete": "刪除",
+ "copy_as_file": "複製為檔案",
+ "copied": "檔案已複製。您可以將其貼到資料夾中。",
+ "copy_failed": "無法複製檔案。"
}
}
diff --git a/locales/zh/common.json b/locales/zh/common.json
index de9e40f3c..3058a98c8 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -6561,5 +6561,35 @@
"label_description": "收回你的许可。服务器需要重新询问。",
"revoke": "收回",
"revoked": "已收回许可"
+ },
+ "desktopOs": {
+ "section_title": "桌面应用",
+ "section_description": "应用如何与你的电脑配合。",
+ "close_to_tray_label": "关闭时继续在托盘中运行",
+ "close_to_tray_description": "关闭窗口后,新邮件和提醒仍会到达。",
+ "start_at_login_label": "登录电脑时启动",
+ "start_at_login_description": "你登录时应用会自动启动。",
+ "start_minimized_label": "在托盘中启动",
+ "start_minimized_description": "登录时不打开窗口。请通过托盘图标打开。",
+ "attention_label": "有新邮件时闪烁任务栏",
+ "attention_mac_label": "有新邮件时让 Dock 图标跳动",
+ "attention_description": "仅在应用不在前台时。",
+ "quiet_hours_label": "免打扰时段",
+ "quiet_hours_description": "此时段没有通知和声音。邮件仍会到达。",
+ "quiet_hours_from": "从",
+ "quiet_hours_to": "到",
+ "show_shortcut_label": "使用键盘快捷键显示应用",
+ "show_shortcut_description": "{keys} 可从任何其他应用把此应用带到前台。",
+ "show_shortcut_taken": "其他应用正在使用 {keys}。请关闭该应用,或关闭此选项后重新打开。",
+ "default_app_label": "默认邮件应用",
+ "default_app_description": "用此应用打开邮件链接以及 .eml、.ics 和 .vcf 文件。",
+ "default_app_is_default": "此应用会打开你的邮件链接。",
+ "default_app_button": "选择默认应用…",
+ "new_mail": "新邮件",
+ "no_subject": "(无主题)",
+ "action_delete": "删除",
+ "copy_as_file": "复制为文件",
+ "copied": "文件已复制。你可以将其粘贴到文件夹中。",
+ "copy_failed": "无法复制文件。"
}
}