feat: open mail, calendar and contact files from the OS and share into a new draft

The installed app only handled mailto: and webcal: launches. The launch
consumer dropped files, and nothing could hand a File to the composer or
the import dialogs, because the pending mailto is JSON in sessionStorage.

- manifest: `shortcuts` named in the browser's language, `file_handlers`
  for .eml, .ics and .vcf, and `share_target`, all with the base path and
  per-domain branding. Lite's manifest is unchanged.
- `lib/launch-handoff.ts`: the one place files and links wait, in memory
  and in the Cache API, so they survive a sign-in that leaves the page.
  The desktop bridge can call the same functions, since launchQueue never
  fires in Electron. Sign-out clears it.
- launch consumer: routes files by type, and takes a shortcut to an open
  window, which focus-existing would otherwise only focus.
- .eml opens in a new host for the preview, also before sign-in, with the
  strictest device policy deciding about saving. .ics and .vcf open their
  import dialogs through a new `initialFile` prop. A .ics needs the server,
  so offline the dialog says so and carries on once back online.
- composer: `initialFiles` goes through `addFiles`, so plugin vetoes and
  size limits still apply, also from a Pro compose tab.
- `sw.js` takes the share POST before the offline check and keeps the files
  for the page. Without a worker, `/protocol/share` passes text and links
  on and the page says the files are lost. `protocol` joins the app
  sections and the handler pages join the shell, so a mailto: launch
  offline no longer gets a network error.

Browsers without launchQueue or a service worker behave as before.
This commit is contained in:
Linus Rath
2026-11-03 05:26:01 +01:00
parent 1dfe09d321
commit c65d18eaae
57 changed files with 2490 additions and 33 deletions
+51
View File
@@ -0,0 +1,51 @@
/**
* The manifest's share_target, when no service worker took it (public/sw.js
* normally answers first): the first visit, a browser without one, or a worker
* from before this existed. Nothing keeps files here, so the text and links go
* on in the address and the page says the files are gone
* (components/protocol/protocol-launch-handler-provider.tsx).
*
* Open to any origin, like a mailto: link: it writes nothing and acts as nobody.
*/
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
// A form with text and links is small. Anything bigger carries files, and
// isn't read into memory just to throw them away.
const MAX_READ_BYTES = 256 * 1024;
export const dynamic = "force-dynamic";
function land(query: string): Response {
return new Response(null, { status: 303, headers: { Location: `${BASE_PATH}/${query}` } });
}
export async function POST(request: Request): Promise<Response> {
const length = Number(request.headers.get("content-length") ?? "");
const params = new URLSearchParams({ share: "unavailable" });
if (!Number.isFinite(length) || length <= 0 || length > MAX_READ_BYTES) {
params.set("share_files", "1");
return land(`?${params}`);
}
try {
const form = await request.formData();
const field = (name: string, max: number) => {
const value = form.get(name);
if (typeof value === "string" && value) params.set(`share_${name}`, value.slice(0, max));
};
// Short enough for what proxies in front allow in an address.
field("title", 300);
field("text", 4000);
field("url", 2000);
if (form.getAll("files").some((file) => typeof file !== "string" && file.size > 0)) {
params.set("share_files", "1");
}
} catch {
params.set("share_files", "1");
}
return land(`?${params}`);
}
/** Nothing to share: the app. */
export function GET(): Response {
return land("");
}
+60 -1
View File
@@ -1,5 +1,8 @@
import type { MetadataRoute } from "next";
import { headers } from "next/headers";
import { getTranslations } from "next-intl/server";
import { localeFromAcceptLanguage } from "@/i18n/locale-matcher";
import { routing } from "@/i18n/routing";
import { configManager } from "@/lib/admin/config-manager";
import {
matchDomainBranding,
@@ -15,8 +18,27 @@ type WebAppProtocolHandler = {
url: string;
};
type WebAppFileHandler = {
action: string;
accept: Record<string, string[]>;
};
type WebAppShareTarget = {
action: string;
method: "POST";
enctype: "multipart/form-data";
params: {
title: string;
text: string;
url: string;
files: Array<{ name: string; accept: string[] }>;
};
};
type ExtendedManifest = MetadataRoute.Manifest & {
protocol_handlers?: WebAppProtocolHandler[];
file_handlers?: WebAppFileHandler[];
share_target?: WebAppShareTarget;
launch_handler?: {
client_mode?: "navigate-existing" | "auto" | "focus-existing" | "navigate-new"
| Array<"navigate-existing" | "auto" | "focus-existing" | "navigate-new">;
@@ -32,7 +54,8 @@ const withBase = (p: string) => `${BASE_PATH}${p}`;
export default async function manifest(): Promise<ExtendedManifest> {
await configManager.ensureLoaded();
const host = pickRequestHost(await headers());
const requestHeaders = await headers();
const host = pickRequestHost(requestHeaders);
const domainOverrides = matchDomainBranding(
host,
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
@@ -83,6 +106,17 @@ export default async function manifest(): Promise<ExtendedManifest> {
{ src: withBase("/icon-maskable-dark-512x512.png"), sizes: "512x512", type: "image/png", purpose: "maskable" },
];
// Shortcut names show in the OS menu, in the language the browser asks in.
const t = await getTranslations({
locale: localeFromAcceptLanguage(requestHeaders.get("accept-language"), routing.locales) ?? routing.defaultLocale,
namespace: "pwaLaunch",
});
const shortcutIcons = [{
src: withBase(hasCustomIcon ? "/api/pwa-icon/192" : "/icon-192x192.png"),
sizes: "192x192",
type: "image/png",
}];
return {
name: appName,
short_name: shortName,
@@ -118,6 +152,31 @@ export default async function manifest(): Promise<ExtendedManifest> {
{ protocol: "mailto", url: withBase("/protocol/mailto?url=%s") },
{ protocol: "webcal", url: withBase("/protocol/webcal?url=%s") },
],
shortcuts: [
{ name: t("shortcut_new_message"), url: withBase("/?compose=1"), icons: shortcutIcons },
{ name: t("shortcut_calendar"), url: withBase("/calendar"), icons: shortcutIcons },
{ name: t("shortcut_contacts"), url: withBase("/contacts"), icons: shortcutIcons },
],
// Where a file goes is decided by its type, in lib/launch-handoff.ts. The
// actions are the pages a new window should start on.
file_handlers: [
{ action: withBase("/mail"), accept: { "message/rfc822": [".eml"] } },
{ action: withBase("/calendar"), accept: { "text/calendar": [".ics", ".ical"] } },
{ action: withBase("/contacts"), accept: { "text/vcard": [".vcf", ".vcard"], "text/x-vcard": [".vcf", ".vcard"] } },
],
// Not under /api/: proxy.ts refuses a POST from outside there. The service
// worker takes it (public/sw.js); without one, app/(main)/protocol/share.
share_target: {
action: withBase("/protocol/share"),
method: "POST",
enctype: "multipart/form-data",
params: {
title: "title",
text: "text",
url: "url",
files: [{ name: "files", accept: ["*/*"] }],
},
},
launch_handler: {
client_mode: ["focus-existing", "navigate-new"],
},
@@ -0,0 +1,110 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useOfflineStore } from '@/lib/offline/state';
import { ICalImportModal } from '../ical-import-modal';
// A .ics opened from the OS. The server reads it, so without a connection the
// dialog has to say so instead of calling a good file invalid.
vi.mock('@/stores/calendar-store', () => ({
useCalendarStore: (selector: (state: { importEvents: () => Promise<number> }) => unknown) => selector({ importEvents: async () => 1 }),
}));
vi.mock('@/stores/settings-store', () => ({
useSettingsStore: (selector: (state: { timeFormat: string }) => unknown) => selector({ timeFormat: '24h' }),
}));
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
const calendars = [{ id: 'cal-1', name: 'Home', isDefault: true }] as never[];
const ics = () => new File(['BEGIN:VCALENDAR'], 'invite.ics', { type: 'text/calendar' });
function client(overrides: Record<string, unknown> = {}) {
return {
getServerUrl: () => 'https://mail.example.org',
getCalendarsAccountId: () => 'acc',
uploadBlob: vi.fn(async () => ({ blobId: 'blob-1' })),
parseCalendarEvents: vi.fn(async () => [{ title: 'Standup', start: '2026-11-03T09:00:00' }]),
...overrides,
} as never as { uploadBlob: ReturnType<typeof vi.fn>; parseCalendarEvents: ReturnType<typeof vi.fn> };
}
beforeEach(() => {
useOfflineStore.setState({ browserOffline: false, downServers: [] });
});
afterEach(() => {
useOfflineStore.setState({ browserOffline: false, downServers: [] });
});
describe('ICalImportModal with an initial file', () => {
it('reads the file at once and shows the preview', async () => {
const c = client();
render(<ICalImportModal calendars={calendars} client={c as never} onClose={vi.fn()} initialFile={ics()} />);
await screen.findByText('Standup');
expect(c.uploadBlob).toHaveBeenCalledTimes(1);
expect(c.parseCalendarEvents).toHaveBeenCalledWith('acc', 'blob-1');
});
it('reads nothing without one', async () => {
const c = client();
render(<ICalImportModal calendars={calendars} client={c as never} onClose={vi.fn()} />);
await act(async () => {});
expect(c.uploadBlob).not.toHaveBeenCalled();
});
it('says the server is needed when offline, and starts by itself once back online', async () => {
useOfflineStore.setState({ browserOffline: true });
const c = client();
render(<ICalImportModal calendars={calendars} client={c as never} onClose={vi.fn()} initialFile={ics()} />);
expect(await screen.findByText('ics_needs_server')).toBeInTheDocument();
expect(c.uploadBlob).not.toHaveBeenCalled();
useOfflineStore.setState({ browserOffline: false });
await act(async () => { window.dispatchEvent(new Event('online')); });
await screen.findByText('Standup');
expect(c.uploadBlob).toHaveBeenCalledTimes(1);
});
it('says the same when the connection fails while it reads', async () => {
const c = client({
uploadBlob: vi.fn(async () => {
useOfflineStore.setState({ downServers: ['https://mail.example.org'] });
throw new TypeError('Failed to fetch');
}),
});
render(<ICalImportModal calendars={calendars} client={c as never} onClose={vi.fn()} initialFile={ics()} />);
expect(await screen.findByText('ics_needs_server')).toBeInTheDocument();
});
it('still calls a bad file invalid while online', async () => {
const c = client({ parseCalendarEvents: vi.fn(async () => { throw new Error('parse'); }) });
render(<ICalImportModal calendars={calendars} client={c as never} onClose={vi.fn()} initialFile={ics()} />);
expect(await screen.findByText('invalid_format')).toBeInTheDocument();
});
it('applies the size limit to it', async () => {
const big = new File([new Uint8Array(10 * 1024 * 1024 + 1)], 'big.ics');
const c = client();
render(<ICalImportModal calendars={calendars} client={c as never} onClose={vi.fn()} initialFile={big} />);
expect(await screen.findByText('file_too_large')).toBeInTheDocument();
expect(c.uploadBlob).not.toHaveBeenCalled();
});
it('picks the default calendar when the calendars load after it opened', async () => {
const importEvents = vi.fn();
const c = client();
const view = render(<ICalImportModal calendars={[]} client={c as never} onClose={vi.fn()} initialFile={ics()} />);
await screen.findByText('Standup');
view.rerender(
<ICalImportModal
calendars={[{ id: 'a', name: 'Work' }, { id: 'b', name: 'Home', isDefault: true }] as never[]}
client={c as never}
onClose={vi.fn()}
initialFile={ics()}
/>,
);
await waitFor(() => expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe('b'));
expect(importEvents).not.toHaveBeenCalled();
});
});
+15
View File
@@ -93,6 +93,7 @@ import { useCompanyCalendars } from "./use-company-calendars";
import { sharedCalendarColorKey, autoAssignSharedCalendarColors } from "@/lib/shared-calendar-colors";
import { debug } from "@/lib/debug";
import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session";
import { useHandoffFiles } from "@/hooks/use-handoff-files";
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
import { appPath, buildCalendarPath, parseCalendarPath, type CalendarDeepLink } from "@/lib/deep-links";
import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
@@ -178,6 +179,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
// A .ics opened from the OS (lib/launch-handoff.ts). The standard page that
// renders while the Pro shell takes over leaves it for the Pro one. A demo
// has no import.
const launchedFile = useHandoffFiles("calendar", isAuthenticated && !!client && !demoMode && (isEmbedded || !proInterfaceActive));
const { quota, isPushConnected } = useEmailStore();
const {
calendars, events, selectedDate, viewMode, selectedCalendarIds,
@@ -2574,6 +2579,16 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
/>
)}
{launchedFile.file && client && (
<ICalImportModal
key={`${launchedFile.file.name}:${launchedFile.file.lastModified}`}
calendars={displayCalendars}
client={client}
initialFile={launchedFile.file}
onClose={launchedFile.done}
/>
)}
{showSubscriptionModal && client && (
<ICalSubscriptionModal
client={client}
+47 -4
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback, useRef, useId } from "react";
import { useState, useCallback, useEffect, useRef, useId } from "react";
import { useLocale, useTranslations } from "next-intl";
import { buildRecurrenceSummary } from "./recurrence-editor";
import { Button } from "@/components/ui/button";
@@ -16,12 +16,15 @@ import { toast } from "@/stores/toast-store";
import { apiFetch } from "@/lib/browser-navigation";
import { explainServerAuthError } from "@/lib/server-auth-status";
import { IS_LITE } from "@/lib/lite";
import { isOfflineNow } from "@/lib/offline/state";
interface ICalImportModalProps {
calendars: Calendar[];
client: IJMAPClient;
onClose: () => void;
initialUrl?: string;
/** A file opened from the OS: read at once, as if picked here. */
initialFile?: File | null;
}
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
@@ -30,8 +33,9 @@ const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
type ImportStep = "select" | "preview" | "importing";
type ImportMode = "file" | "url";
export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICalImportModalProps) {
export function ICalImportModal({ calendars, client, onClose, initialUrl, initialFile }: ICalImportModalProps) {
const t = useTranslations("calendar.import");
const tLaunch = useTranslations("pwaLaunch");
const tCal = useTranslations("calendar");
const locale = useLocale();
const tForm = useTranslations("calendar.form");
@@ -56,6 +60,15 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
const dropZoneRef = useRef<HTMLButtonElement>(null);
const urlInputRef = useRef<HTMLInputElement>(null);
const targetId = useId();
// The file that couldn't be read for lack of a connection, read again when
// it is back.
const waitingForServer = useRef<File | null>(null);
// Opened before the calendars had loaded, from a launch: the default once they have.
useEffect(() => {
if (calendarId || calendars.length === 0) return;
setCalendarId((calendars.find((c) => c.isDefault) ?? calendars[0]).id);
}, [calendars, calendarId]);
const validateFile = useCallback((file: File): string | null => {
if (file.size > MAX_FILE_SIZE) return t("file_too_large");
@@ -71,6 +84,16 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
return;
}
// The server reads the file (uploadBlob, then CalendarEvent/parse). Say so
// rather than call a good file invalid.
const serverUrl = client.getServerUrl();
if (isOfflineNow(serverUrl)) {
waitingForServer.current = file;
setError(tLaunch("ics_needs_server"));
return;
}
waitingForServer.current = null;
setError(null);
setIsParsing(true);
setStep("select");
@@ -91,11 +114,31 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
setSelectedIndices(new Set(events.map((_, i) => i)));
setStep("preview");
} catch {
setError(t("invalid_format"));
if (isOfflineNow(serverUrl)) {
waitingForServer.current = file;
setError(tLaunch("ics_needs_server"));
} else {
setError(t("invalid_format"));
}
} finally {
setIsParsing(false);
}
}, [client, validateFile, t]);
}, [client, validateFile, t, tLaunch]);
const initialFileRead = useRef(false);
useEffect(() => {
if (!initialFile || initialFileRead.current) return;
initialFileRead.current = true;
void handleFile(initialFile);
}, [initialFile, handleFile]);
useEffect(() => {
const retry = () => {
if (waitingForServer.current) void handleFile(waitingForServer.current);
};
window.addEventListener("online", retry);
return () => window.removeEventListener("online", retry);
}, [handleFile]);
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -0,0 +1,62 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useOfflineStore } from '@/lib/offline/state';
import { ContactImportDialog } from '../contact-import-dialog';
// A .vcf opened from the OS: it parses in the browser, also without a connection.
const VCARD = ['BEGIN:VCARD', 'VERSION:3.0', 'FN:Ada Lovelace', 'EMAIL:ada@example.org', 'END:VCARD', ''].join('\r\n');
const vcf = (body = VCARD) => {
const file = new File([body], 'people.vcf', { type: 'text/vcard' });
// jsdom's File has no text().
if (typeof file.text !== 'function') Object.defineProperty(file, 'text', { value: async () => body });
return file;
};
afterEach(() => {
useOfflineStore.setState({ browserOffline: false, downServers: [] });
});
describe('ContactImportDialog with an initial file', () => {
it('parses it at once and offers the cards', async () => {
render(<ContactImportDialog existingContacts={[]} onImport={vi.fn()} onClose={vi.fn()} initialFile={vcf()} />);
expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument();
});
it('parses it offline as well', async () => {
useOfflineStore.setState({ browserOffline: true });
render(<ContactImportDialog existingContacts={[]} onImport={vi.fn()} onClose={vi.fn()} initialFile={vcf()} />);
expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument();
});
it('applies the size limit to it', async () => {
const big = new File([new Uint8Array(5 * 1024 * 1024 + 1)], 'big.vcf');
render(<ContactImportDialog existingContacts={[]} onImport={vi.fn()} onClose={vi.fn()} initialFile={big} />);
expect(await screen.findByText('import.file_too_large')).toBeInTheDocument();
});
it('shows the file input without one', async () => {
const { container } = render(<ContactImportDialog existingContacts={[]} onImport={vi.fn()} onClose={vi.fn()} />);
expect(container.ownerDocument.querySelector('input[type="file"]')).not.toBeNull();
expect(screen.queryByText('Ada Lovelace')).toBeNull();
});
it('says the server is needed when the import fails offline', async () => {
const onImport = vi.fn(async () => {
useOfflineStore.setState({ browserOffline: true });
throw new TypeError('Failed to fetch');
});
render(<ContactImportDialog existingContacts={[]} onImport={onImport} onClose={vi.fn()} initialFile={vcf()} />);
await screen.findByText('Ada Lovelace');
fireEvent.click(screen.getByRole('button', { name: 'import.import_button' }));
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('contacts_need_server'));
});
it('calls a failed import failed while online', async () => {
const onImport = vi.fn(async () => { throw new Error('server said no'); });
render(<ContactImportDialog existingContacts={[]} onImport={onImport} onClose={vi.fn()} initialFile={vcf()} />);
await screen.findByText('Ada Lovelace');
fireEvent.click(screen.getByRole('button', { name: 'import.import_button' }));
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('import.failed'));
});
});
+20 -6
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { useState, useRef, useCallback, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Upload, FileText, AlertTriangle, Check } from "@/components/icons";
import { Button } from "@/components/ui/button";
@@ -9,19 +9,24 @@ import { cn } from "@/lib/utils";
import { parseVCard, detectDuplicates } from "@/lib/vcard";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { isOfflineNow } from "@/lib/offline/state";
interface ContactImportDialogProps {
existingContacts: ContactCard[];
onImport: (contacts: ContactCard[]) => Promise<number>;
onClose: () => void;
/** A file opened from the OS: read at once, as if picked here. */
initialFile?: File | null;
}
export function ContactImportDialog({
existingContacts,
onImport,
onClose,
initialFile,
}: ContactImportDialogProps) {
const t = useTranslations("contacts");
const tLaunch = useTranslations("pwaLaunch");
const fileRef = useRef<HTMLInputElement>(null);
const [parsed, setParsed] = useState<ContactCard[]>([]);
const [selected, setSelected] = useState<Set<number>>(new Set());
@@ -30,10 +35,7 @@ export function ContactImportDialog({
const [result, setResult] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const readFile = useCallback(async (file: File) => {
setError(null);
setResult(null);
@@ -66,6 +68,18 @@ export function ContactImportDialog({
}
}, [existingContacts, t]);
const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) await readFile(file);
}, [readFile]);
const initialFileRead = useRef(false);
useEffect(() => {
if (!initialFile || initialFileRead.current) return;
initialFileRead.current = true;
void readFile(initialFile);
}, [initialFile, readFile]);
const toggleSelect = (idx: number) => {
const next = new Set(selected);
if (next.has(idx)) {
@@ -94,7 +108,7 @@ export function ContactImportDialog({
setResult(count);
} catch (error) {
console.error('Failed to import contacts:', error);
setError(t("import.failed"));
setError(isOfflineNow() ? tLaunch("contacts_need_server") : t("import.failed"));
} finally {
setIsImporting(false);
}
+18
View File
@@ -51,6 +51,7 @@ import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/typ
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
import { appPath, buildContactsPath, parseContactsPath, type ContactDeepLink } from "@/lib/deep-links";
import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
import { useHandoffFiles } from "@/hooks/use-handoff-files";
import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
@@ -356,6 +357,14 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
// focused tab writes the address bar; the standard instance that renders
// while Pro takes over a route stays silent.
const proInterfaceActive = useProInterfaceActive();
// A .vcf opened from the OS (lib/launch-handoff.ts). It waits for the
// contacts, or everything would look new to the duplicate check. The
// standard page that renders while the Pro shell takes over leaves it for
// the Pro one.
const launchedFile = useHandoffFiles(
"contacts",
isAuthenticated && !!client && (contactsLoaded || !supportsSync) && (isEmbedded || !proInterfaceActive),
);
const isFocusedProTab = useIsFocusedProTab();
const contactsLinkPath = appPath(buildContactsPath({
contactId: view === 'detail' || view === 'edit' ? selectedContactId : null,
@@ -1572,6 +1581,15 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
if (movingContactIds) void handleDropContacts(movingContactIds, book);
}}
/>
{launchedFile.file && (
<ContactImportDialog
key={`${launchedFile.file.name}:${launchedFile.file.lastModified}`}
existingContacts={contacts}
onImport={handleImportContacts}
initialFile={launchedFile.file}
onClose={launchedFile.done}
/>
)}
{showImportDialog && (
<ContactImportDialog
existingContacts={contacts}
@@ -232,6 +232,62 @@ function pickFromFiles(nodes: FileNode[]) {
fireEvent.click(screen.getByTestId('fake-file-picker'));
}
describe('files handed to a new composer (a share, a launch)', () => {
afterEach(() => {
useAuthStore.setState({ client: null });
vi.clearAllMocks();
hoisted.beforeAttachmentUpload.mockImplementation(async () => true);
});
const uploading = () => filesClient({ uploadBlob: vi.fn(() => new Promise(() => {})), getMaxSizeUpload: () => 0 });
it('attaches them when it opens', async () => {
useAuthStore.setState({ client: uploading() as never });
render(<EmailComposer onClose={vi.fn()} initialFiles={[new File(['x'], 'shared.pdf', { type: 'application/pdf' })]} />);
expect(await screen.findByText('shared.pdf')).toBeInTheDocument();
});
it('waits for the client', async () => {
const files = [new File(['x'], 'shared.pdf')];
const view = render(<EmailComposer onClose={vi.fn()} initialFiles={files} />);
await act(async () => {});
expect(hoisted.beforeAttachmentUpload).not.toHaveBeenCalled();
useAuthStore.setState({ client: uploading() as never });
view.rerender(<EmailComposer key="with-client" onClose={vi.fn()} initialFiles={files} />);
expect(await screen.findByText('shared.pdf')).toBeInTheDocument();
});
it('attaches an array once, also when the composer remounts with it', async () => {
useAuthStore.setState({ client: uploading() as never });
const files = [new File(['x'], 'shared.pdf')];
const first = render(<EmailComposer onClose={vi.fn()} initialFiles={files} />);
await screen.findByText('shared.pdf');
first.unmount();
render(<EmailComposer onClose={vi.fn()} initialFiles={files} />);
await act(async () => {});
expect(hoisted.beforeAttachmentUpload).toHaveBeenCalledTimes(1);
expect(screen.queryByText('shared.pdf')).toBeNull();
});
it('runs the same checks as a picked file: a plugin may refuse it', async () => {
hoisted.beforeAttachmentUpload.mockImplementation(async (info) => info.name !== 'setup.exe');
useAuthStore.setState({ client: uploading() as never });
render(<EmailComposer onClose={vi.fn()} initialFiles={[new File(['x'], 'setup.exe'), new File(['x'], 'notes.pdf')]} />);
expect(await screen.findByText('notes.pdf')).toBeInTheDocument();
expect(screen.queryByText('setup.exe')).toBeNull();
});
it('runs the same checks as a picked file: the size limit', async () => {
useAuthStore.setState({ client: filesClient({ getMaxSizeUpload: () => 1 }) as never });
render(<EmailComposer onClose={vi.fn()} initialFiles={[new File(['too big'], 'big.pdf')]} />);
await act(async () => {});
expect(hoisted.toastError).toHaveBeenCalledWith('attachment_too_large');
expect(screen.queryByText('big.pdf')).toBeNull();
});
});
describe('attach from Files', () => {
afterEach(() => {
useAuthStore.setState({ client: null });
+16
View File
@@ -213,6 +213,9 @@ function createChipDragPreview(label: string): HTMLElement {
// inserts as a single chip and expands into its members on send.
type SuggestionItem = { name: string; email: string; group?: { id: string; memberCount: number } };
// Which `initialFiles` arrays are attached: a remount gets the same array.
const attachedInitialFiles = new WeakSet<File[]>();
export interface ComposerDraftData {
to: string;
cc: string;
@@ -360,6 +363,12 @@ interface EmailComposerProps {
sessionKey?: string;
/** Id of the message being replied to or forwarded, for onComposerOpen. */
sourceEmailId?: string;
/**
* Files to attach when the composer opens, from a share or a launch (see
* lib/launch-handoff.ts). They go through the same checks as a picked file.
* Attached once per array, also when the composer remounts with it.
*/
initialFiles?: File[] | null;
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
/**
* Email of the mailbox/account the user is viewing when they start a new
@@ -492,6 +501,7 @@ export function EmailComposer({
className,
initialDraftText: initialDraftTextProp,
initialData: initialDataProp,
initialFiles,
prefill,
sessionKey,
sourceEmailId,
@@ -2461,6 +2471,12 @@ export function EmailComposer({
}
}, [client, t]);
useEffect(() => {
if (!client || !initialFiles || initialFiles.length === 0 || attachedInitialFiles.has(initialFiles)) return;
attachedInitialFiles.add(initialFiles);
void addFiles(initialFiles);
}, [client, initialFiles, addFiles]);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!event.target.files) return;
await addFiles(Array.from(event.target.files));
+63 -12
View File
@@ -142,6 +142,7 @@ import { usePluginStore } from "@/stores/plugin-store";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { canDownload } from "@/lib/device-policy";
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
import { subscribeHandoff, takeHandoff } from "@/lib/launch-handoff";
import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from "@/lib/protocol-handlers/mailto";
import { PLUGIN_REPLY_EVENT, type PluginReplyRequest } from "@/lib/plugin-sandbox/host-api-compose";
import { onOpenEmail, type OpenEmailHandler, type OpenEmailOutcome } from "@/lib/plugin-sandbox/open-email";
@@ -230,6 +231,9 @@ const UNIFIED_LIST_PUSH = {
},
};
/** A draft from outside: a mailto:, or a share with files. ParsedMailto stays JSON, for sessionStorage. */
type DraftRequest = ParsedMailto & { initialFiles?: File[] };
export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Static Lite build: the route params are empty, read the link from the URL.
const linkSegments = useLiteLinkSegments('mail', routeSegments);
@@ -314,7 +318,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Office attachments open read-only in the configured WOPI editor (#1047).
const [officeAttachment, setOfficeAttachment] = useState<{ blobId: string; name: string; type?: string; size?: number; accountId: string; slot: number | null } | null>(null);
const wopiStatus = useWopiStatus(true);
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<DraftRequest | null>(null);
// Files from a share or a launch, for the next composer. It attaches an
// array once, so a stale one does nothing.
const [composerInitialFiles, setComposerInitialFiles] = useState<File[] | null>(null);
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastUndoToastSubmissionRef = useRef<string | null>(null);
@@ -1376,6 +1383,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
initialDraftText: composerDraftText,
initialData: pendingDraft,
prefill: composerPrefill,
initialFiles: composerInitialFiles,
sourceEmailId: selectedEmail?.id ?? null,
sourceEmailSource: selectedEmail ? emailWriteSource(selectedEmail, client) : null,
title,
@@ -1386,6 +1394,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setComposerDraftText("");
setPendingDraft(null);
setComposerPrefill(null);
setComposerInitialFiles(null);
setComposerQuoteHeader(null);
// We only react to the rising edge of `showComposer` here; the other
// variables read above are captured-but-stale-safe because the next
@@ -1465,18 +1474,19 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
return true;
}, [identities, t]);
const applyMailtoDraft = useCallback((pending: ParsedMailto, suppressOutgoingStash: boolean) => {
const applyMailtoDraft = useCallback((pending: DraftRequest, suppressOutgoingStash: boolean) => {
if (suppressOutgoingStash) {
suppressComposerStateSaveSessionRef.current = composerSessionId;
}
setComposerSessionId((id) => id + 1);
setPendingDraft(mailtoComposerDraft(pending, useSettingsStore.getState().plainTextMode));
setComposerInitialFiles(pending.initialFiles ?? null);
setComposerMode("compose");
setShowComposer(true);
if (isMobile) setActiveView("viewer");
}, [composerSessionId, isMobile, setActiveView]);
const openMailtoDraft = useCallback((pending: ParsedMailto) => withComposeGate('compose', {
const openMailtoDraft = useCallback((pending: DraftRequest) => withComposeGate('compose', {
to: pending.to, cc: pending.cc, subject: pending.subject, body: pending.body,
}, () => {
// A live composer owns text the user has not sent. Replacing it outright
@@ -1495,7 +1505,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
applyMailtoDraft(pending, showComposer);
}), [applyMailtoDraft, showComposer, withComposeGate]);
const openMailtoForAccount = useCallback(async (pending: ParsedMailto, accountId: string) => {
const openMailtoForAccount = useCallback(async (pending: DraftRequest, accountId: string) => {
setIsProtocolAccountSwitching(true);
try {
if (useAuthStore.getState().activeAccountId !== accountId) {
@@ -1519,13 +1529,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
});
}, []);
const handleMailtoProtocolRequest = useCallback(async (pending: ParsedMailto) => {
if (!(await mailtoAllowed(pending))) return;
// From another app of this webmail: the account in use, no question.
if (pending.inApp) {
openMailtoDraft(pending);
return;
}
// A draft that comes from outside: which account it goes out from.
const routeOutsideDraft = useCallback((pending: DraftRequest) => {
const protocolAccounts = getMailtoProtocolAccounts();
if (protocolAccounts.length > 1) {
setPendingMailtoAccountChoice(pending);
@@ -1539,7 +1544,52 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
openMailtoDraft(pending);
}, [activeAccountId, getMailtoProtocolAccounts, mailtoAllowed, openMailtoDraft, openMailtoForAccount]);
}, [activeAccountId, getMailtoProtocolAccounts, openMailtoDraft, openMailtoForAccount]);
const handleMailtoProtocolRequest = useCallback(async (pending: ParsedMailto) => {
if (!(await mailtoAllowed(pending))) return;
// From another app of this webmail: the account in use, no question.
if (pending.inApp) {
openMailtoDraft(pending);
return;
}
routeOutsideDraft(pending);
}, [mailtoAllowed, openMailtoDraft, routeOutsideDraft]);
// A new draft from a shortcut, a share or the desktop app
// (lib/launch-handoff.ts). Not a mailto:, so onMailtoIntercept stays out of
// it; onBeforeCompose still runs. The standard page that renders while the
// Pro shell takes over must leave it for the Pro one.
const proTakingOver = proInterfaceActive && !isEmbedded;
useEffect(() => {
if (!isAuthenticated || !client || proTakingOver) return;
const openHandoff = () => {
for (let handoff = takeHandoff('compose'); handoff; handoff = takeHandoff('compose')) {
if (!handoff.subject && !handoff.body && handoff.files.length === 0) {
void withComposeGate('compose', {}, () => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
});
continue;
}
routeOutsideDraft({
to: [],
cc: [],
bcc: [],
subject: handoff.subject ?? '',
body: handoff.body ?? '',
initialFiles: handoff.files,
});
}
};
openHandoff();
return subscribeHandoff('compose', openHandoff);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated, client, proTakingOver, routeOutsideDraft, withComposeGate, isMobile]);
useEffect(() => {
if (!isAuthenticated || !client) return;
@@ -4848,6 +4898,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
initialDraftText={composerDraftText}
initialData={pendingDraft}
prefill={composerPrefill}
initialFiles={composerInitialFiles}
sessionKey={`inline-${composerSessionId}`}
sourceEmailId={composerSourceEmail?.id}
onSaveState={(data) => {
+1
View File
@@ -244,6 +244,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
initialDraftText={data.initialDraftText}
initialData={data.initialData}
prefill={data.prefill}
initialFiles={data.initialFiles}
sessionKey={`pro-${tabId}-${data.sessionId}`}
sourceEmailId={data.sourceEmailId ?? undefined}
composeFromAccountEmail={composeFromAccountEmail}
@@ -0,0 +1,287 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FakeCaches } from '@/lib/__tests__/sw-harness';
// The launchQueue consumer. It is the only one the app may have: a second
// setConsumer replaces the first. Files used to be dropped here.
const hoisted = vi.hoisted(() => ({
push: vi.fn(),
pathname: { current: '/' },
toastError: vi.fn(),
savePendingMailto: vi.fn(),
protocolOpenMode: 'active-session',
}));
vi.mock('@/i18n/navigation', () => ({
useRouter: () => ({ push: hoisted.push, replace: vi.fn(), back: vi.fn() }),
usePathname: () => hoisted.pathname.current,
}));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Bulwark' }) }));
vi.mock('@/stores/toast-store', () => ({ toast: { error: hoisted.toastError, info: vi.fn(), success: vi.fn() } }));
vi.mock('@/stores/settings-store', () => ({
useSettingsStore: Object.assign(() => undefined, { getState: () => ({ protocolOpenMode: hoisted.protocolOpenMode }) }),
}));
vi.mock('@/lib/protocol-handlers/session', () => ({
listenForMailtoRequests: () => () => {},
notifyPendingMailto: vi.fn(),
notifyPendingWebcal: vi.fn(),
requestOpenMailtoInExistingClient: vi.fn(async () => false),
savePendingMailto: hoisted.savePendingMailto,
savePendingWebcal: vi.fn(),
}));
// The real modal has its own tests; here: does the launch reach it, and is
// its download gated by the same function.
vi.mock('@/components/files/file-preview-modal', () => ({
FilePreviewModal: function PreviewStub({ name, onClose, onDownload, getFileContent }: {
name: string; onClose: () => void; onDownload: () => void;
getFileContent: () => Promise<{ blob: Blob; contentType: string }>;
}) {
const [type, setType] = React.useState('');
React.useEffect(() => { void getFileContent().then((c) => setType(c.contentType)); }, [getFileContent]);
return (
<div data-testid="eml-viewer" data-type={type}>
{name}
<button onClick={onClose}>close</button>
<button onClick={onDownload}>save</button>
</div>
);
},
}));
const { clearHandoffs, pendingHandoffs, takeHandoff } = await import('@/lib/launch-handoff');
const { setDownloadGuard } = await import('@/lib/download-guard');
const { ProtocolLaunchHandlerProvider } = await import('../protocol-launch-handler-provider');
type Consumer = (params: { targetURL?: string; files?: Array<{ kind: string; getFile: () => Promise<File> }> }) => void;
let consumer: Consumer | null = null;
const setConsumer = vi.fn((fn: Consumer) => { consumer = fn; });
const handle = (name: string, type = '') => ({ kind: 'file', getFile: async () => new File(['x'], name, { type }) });
const flush = () => act(async () => { await new Promise((r) => setTimeout(r, 0)); });
function mount() {
return render(<ProtocolLaunchHandlerProvider><div>app</div></ProtocolLaunchHandlerProvider>);
}
beforeEach(() => {
consumer = null;
setConsumer.mockClear();
hoisted.push.mockClear();
hoisted.toastError.mockClear();
hoisted.savePendingMailto.mockClear();
hoisted.pathname.current = '/';
hoisted.protocolOpenMode = 'active-session';
sessionStorage.clear();
vi.stubGlobal('caches', new FakeCaches());
window.launchQueue = { setConsumer } as never;
});
afterEach(() => {
clearHandoffs();
delete window.launchQueue;
vi.unstubAllGlobals();
window.history.replaceState(null, '', '/');
});
describe('file launches', () => {
it('opens a .eml in the viewer without leaving the page', async () => {
mount();
consumer!({ targetURL: `${window.location.origin}/mail`, files: [handle('note.eml')] });
const viewer = await screen.findByTestId('eml-viewer');
expect(viewer).toHaveTextContent('note.eml');
await waitFor(() => expect(viewer).toHaveAttribute('data-type', 'message/rfc822'));
expect(hoisted.push).not.toHaveBeenCalled();
});
it('shows several .eml files one after the other', async () => {
mount();
consumer!({ files: [handle('one.eml'), handle('two.eml')] });
expect(await screen.findByTestId('eml-viewer')).toHaveTextContent('one.eml');
await act(async () => { screen.getByText('close').click(); });
expect(await screen.findByTestId('eml-viewer')).toHaveTextContent('two.eml');
await act(async () => { screen.getByText('close').click(); });
expect(screen.queryByTestId('eml-viewer')).toBeNull();
});
it("saves a .eml only if the device policy allows downloads", async () => {
const created = vi.fn(() => 'blob:x');
vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: created, revokeObjectURL: vi.fn() }));
setDownloadGuard(() => false);
mount();
consumer!({ files: [handle('note.eml')] });
await screen.findByTestId('eml-viewer');
await act(async () => { screen.getByText('save').click(); });
expect(created).not.toHaveBeenCalled();
setDownloadGuard(null);
});
it('takes a .ics to the calendar with the file waiting', async () => {
mount();
consumer!({ targetURL: `${window.location.origin}/calendar`, files: [handle('invite.ics')] });
await flush();
expect(hoisted.push).toHaveBeenCalledWith('/calendar');
expect(takeHandoff('calendar')?.files[0].name).toBe('invite.ics');
});
it('takes a .vcf to the contacts with the file waiting', async () => {
mount();
consumer!({ files: [handle('people.vcf')] });
await flush();
expect(hoisted.push).toHaveBeenCalledWith('/contacts');
expect(takeHandoff('contacts')?.files[0].name).toBe('people.vcf');
});
it('stays when already on the right page', async () => {
hoisted.pathname.current = '/calendar';
mount();
consumer!({ files: [handle('invite.ics')] });
await flush();
expect(hoisted.push).not.toHaveBeenCalled();
});
it('goes to the page in the Pro shell too, which has one route', async () => {
hoisted.pathname.current = '/pro';
mount();
consumer!({ files: [handle('invite.ics')] });
await flush();
expect(hoisted.push).toHaveBeenCalledWith('/calendar');
});
it('says so for a file it cannot handle', async () => {
mount();
consumer!({ files: [handle('photo.png', 'image/png')] });
await flush();
expect(hoisted.toastError).toHaveBeenCalledWith('unsupported_file');
expect(pendingHandoffs()).toEqual([]);
});
it('says so for a file it cannot read', async () => {
mount();
consumer!({ files: [{ kind: 'file', getFile: async () => { throw new Error('denied'); } }] });
await flush();
expect(hoisted.toastError).toHaveBeenCalledWith('open_failed');
});
it('waits on the sign-in page, and goes on after it', async () => {
hoisted.pathname.current = '/login';
const view = mount();
consumer!({ files: [handle('invite.ics')] });
await flush();
expect(hoisted.push).not.toHaveBeenCalled();
hoisted.pathname.current = '/';
view.rerender(<ProtocolLaunchHandlerProvider><div>app</div></ProtocolLaunchHandlerProvider>);
await flush();
expect(hoisted.push).toHaveBeenCalledTimes(1);
expect(hoisted.push).toHaveBeenCalledWith('/calendar');
});
it('goes to the page only once for a handoff, so it does not trap a later visit elsewhere', async () => {
hoisted.pathname.current = '/settings';
const view = mount();
consumer!({ files: [handle('invite.ics')] });
await flush();
expect(hoisted.push).toHaveBeenCalledTimes(1);
hoisted.pathname.current = '/mail';
view.rerender(<ProtocolLaunchHandlerProvider><div>app</div></ProtocolLaunchHandlerProvider>);
await flush();
expect(hoisted.push).toHaveBeenCalledTimes(1);
});
});
describe('launches that are not files', () => {
it('still handles mailto:', async () => {
mount();
consumer!({ targetURL: `${window.location.origin}/protocol/mailto?url=${encodeURIComponent('mailto:a@example.org')}` });
await waitFor(() => expect(hoisted.savePendingMailto).toHaveBeenCalled());
expect(hoisted.savePendingMailto.mock.calls[0][0].to).toEqual(['a@example.org']);
});
it('opens a new message from the shortcut in an open window', async () => {
hoisted.pathname.current = '/settings';
mount();
consumer!({ targetURL: `${window.location.origin}/?compose=1` });
await flush();
expect(hoisted.push).toHaveBeenCalledWith('/');
expect(takeHandoff('compose')).toMatchObject({ files: [] });
});
it('goes to the calendar from its shortcut in an open window', async () => {
mount();
consumer!({ targetURL: `${window.location.origin}/calendar` });
expect(hoisted.push).toHaveBeenCalledWith('/calendar');
});
it('leaves an open window where it is for the plain app icon', async () => {
hoisted.pathname.current = '/settings';
mount();
consumer!({ targetURL: `${window.location.origin}/` });
await flush();
expect(hoisted.push).not.toHaveBeenCalled();
expect(pendingHandoffs()).toEqual([]);
});
it('ignores a launch of the share target: the service worker has that', async () => {
mount();
consumer!({ targetURL: `${window.location.origin}/protocol/share` });
await flush();
expect(hoisted.push).not.toHaveBeenCalled();
expect(hoisted.toastError).not.toHaveBeenCalled();
});
it('asks launchQueue once per consumer: never a second one on one render', () => {
mount();
expect(setConsumer).toHaveBeenCalledTimes(1);
});
});
describe('the address a launch lands on', () => {
it('opens a new message once and removes the parameter', async () => {
window.history.replaceState(null, '', '/?compose=1&slot=2#x');
mount();
expect(window.location.search).toBe('?slot=2');
expect(window.location.hash).toBe('#x');
expect(takeHandoff('compose')).toMatchObject({ files: [] });
expect(takeHandoff('compose')).toBeNull();
});
it('counts the same launch once when launchQueue names it too', async () => {
window.history.replaceState(null, '', '/?compose=1');
mount();
consumer!({ targetURL: `${window.location.origin}/?compose=1` });
await flush();
expect(takeHandoff('compose')).not.toBeNull();
expect(takeHandoff('compose')).toBeNull();
});
it('turns text in the address into a draft and says the files were lost', async () => {
window.history.replaceState(null, '', '/?share=unavailable&share_title=T&share_text=B&share_files=1');
mount();
expect(window.location.search).toBe('');
expect(takeHandoff('compose')).toMatchObject({ subject: 'T', body: 'B' });
expect(hoisted.toastError).toHaveBeenCalledWith('share_files_lost');
});
it('says so when what was shared is gone', async () => {
window.history.replaceState(null, '', '/?share=abc');
mount();
expect(window.location.search).toBe('');
await flush();
expect(hoisted.toastError).toHaveBeenCalledWith('share_gone');
});
});
describe('a browser without launchQueue', () => {
it('renders the app and does nothing else', async () => {
delete window.launchQueue;
mount();
await flush();
expect(screen.getByText('app')).toBeInTheDocument();
expect(setConsumer).not.toHaveBeenCalled();
expect(hoisted.push).not.toHaveBeenCalled();
expect(pendingHandoffs()).toEqual([]);
});
});
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { useCallback, useEffect, useState, type ComponentType, type ComponentProps } from "react";
import { useTranslations } from "next-intl";
import type { FilePreviewModal as FilePreviewModalType } from "@/components/files/file-preview-modal";
import { useHandoffFiles } from "@/hooks/use-handoff-files";
import { saveBlobAs } from "@/lib/download-guard";
import { toast } from "@/stores/toast-store";
type PreviewComponent = ComponentType<ComponentProps<typeof FilePreviewModalType>>;
/**
* Shows a .eml opened from the OS. It parses in the browser, so it works
* offline and before sign-in. `policyAccount` stays out: the strictest device
* policy of the accounts signed in here decides about saving (FilePreviewModal
* and saveBlobAs check it).
*
* The preview is loaded when a file arrives, to keep it off the layout's
* bundle, and by hand rather than with next/dynamic: without a connection and
* without the offline shell its chunk can't load, which has to end in a
* message, not in the error page, and work again later.
*/
export function LaunchFileHost() {
const t = useTranslations("pwaLaunch");
const { file, done } = useHandoffFiles("eml", true);
const [Preview, setPreview] = useState<PreviewComponent | null>(null);
useEffect(() => {
if (!file || Preview) return;
let stale = false;
import("@/components/files/file-preview-modal")
.then((module) => {
if (!stale) setPreview(() => module.FilePreviewModal);
})
.catch(() => {
if (stale) return;
toast.error(t("open_failed"));
done();
});
return () => { stale = true; };
}, [file, Preview, done, t]);
const getFileContent = useCallback(async () => {
if (!file) throw new Error("no file");
return { blob: file, contentType: "message/rfc822" };
}, [file]);
const onDownload = useCallback(() => {
if (file) saveBlobAs(file, file.name, { kind: "attachment" });
}, [file]);
if (!file || !Preview) return null;
return (
<Preview
key={`${file.name}:${file.lastModified}`}
name={file.name}
onClose={done}
onDownload={onDownload}
getFileContent={getFileContent}
/>
);
}
@@ -1,11 +1,25 @@
"use client";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import type { ReactNode } from "react";
import { useTranslations } from "next-intl";
import { usePathname, useRouter } from "@/i18n/navigation";
import { useConfig } from "@/hooks/use-config";
import { getPathPrefix } from "@/lib/browser-navigation";
import { matchSurface, type AppSurface } from "@/lib/deep-links";
import {
adoptStoredHandoff,
handOffDraft,
handOffLaunchFiles,
handOffNewMessage,
parseLaunchLanding,
pendingHandoffs,
restoreStoredHandoffs,
shortcutFromTargetUrl,
stripLandingParams,
subscribeHandoff,
type HandoffKind,
} from "@/lib/launch-handoff";
import { productName } from "@/lib/product-name";
import { parseMailto } from "@/lib/protocol-handlers/mailto";
import { parseWebcal } from "@/lib/protocol-handlers/webcal";
@@ -18,8 +32,13 @@ import {
savePendingWebcal,
} from "@/lib/protocol-handlers/session";
import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
import { LaunchFileHost } from "./launch-file-host";
type LaunchParams = { targetURL?: string };
type LaunchParams = {
targetURL?: string;
files?: ReadonlyArray<{ kind?: string; getFile?: () => Promise<File> }>;
};
type StandaloneNavigator = Navigator & { standalone?: boolean };
declare global {
@@ -51,6 +70,13 @@ function getProtocolLaunch(targetURL: string):
return null;
}
/** The page a handoff waits for. A .eml has none: it opens over any page. */
const HANDOFF_TARGET: Partial<Record<HandoffKind, { surface: AppSurface; path: string }>> = {
compose: { surface: "mail", path: "/" },
calendar: { surface: "calendar", path: "/calendar" },
contacts: { surface: "contacts", path: "/contacts" },
};
function isStandaloneDisplayMode() {
return window.matchMedia?.("(display-mode: standalone)").matches
|| (navigator as StandaloneNavigator).standalone === true;
@@ -73,6 +99,50 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle
const appName = productName(useConfig().appName);
const router = useRouter();
const pathname = usePathname();
const tLaunch = useTranslations("pwaLaunch");
const sentToTarget = useRef(new Set<string>());
// What the page's own address asks for: a shortcut's new message, or what
// was shared. Once: it leaves the address before anything else happens.
useEffect(() => {
const landing = parseLaunchLanding(window.location.search);
if (landing) {
window.history.replaceState(
window.history.state,
"",
window.location.pathname + stripLandingParams(window.location.search) + window.location.hash,
);
}
if (landing?.type === "new-message") {
handOffNewMessage();
} else if (landing?.type === "inline") {
if (landing.subject || landing.body) handOffDraft({ subject: landing.subject, body: landing.body });
if (landing.filesLost) toast.error(tLaunch("share_files_lost"));
} else if (landing?.type === "stored") {
void adoptStoredHandoff(landing.id).then((found) => {
if (!found) toast.error(tLaunch("share_gone"));
});
}
void restoreStoredHandoffs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Takes a handoff to the page that handles it, once each: on arrival, or
// when the sign-in it had to wait for is done.
useEffect(() => {
const goToTarget = () => {
if (pathname === "/login" || pathname.startsWith("/login/") || pathname.startsWith("/auth/")) return;
for (const handoff of pendingHandoffs()) {
const target = HANDOFF_TARGET[handoff.kind];
if (!target || sentToTarget.current.has(handoff.id)) continue;
sentToTarget.current.add(handoff.id);
if (matchSurface(pathname)?.surface !== target.surface) router.push(target.path);
return;
}
};
goToTarget();
return subscribeHandoff("any", goToTarget);
}, [pathname, router]);
useEffect(() => {
if (pathname.startsWith("/protocol/")) return;
@@ -93,10 +163,23 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle
if (typeof window === "undefined" || !window.launchQueue) return;
window.launchQueue.setConsumer((launchParams) => {
if (launchParams.files && launchParams.files.length > 0) {
void handOffLaunchFiles(launchParams.files).then(({ unsupported, failed }) => {
for (const name of unsupported) toast.error(tLaunch("unsupported_file", { name }));
if (failed > 0) toast.error(tLaunch("open_failed"));
});
return;
}
if (!launchParams.targetURL) return;
const launch = getProtocolLaunch(launchParams.targetURL);
if (!launch) return;
if (!launch) {
const shortcut = shortcutFromTargetUrl(launchParams.targetURL, window.location.origin);
if (shortcut === "new-message") handOffNewMessage();
else if (shortcut && matchSurface(pathname)?.surface !== shortcut) router.push(`/${shortcut}`);
return;
}
if (launch.kind === "mailto") {
const parsed = parseMailto(launch.raw);
@@ -130,7 +213,12 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle
notifyPendingWebcal();
if (pathname !== "/calendar") router.push("/calendar");
});
}, [pathname, router]);
}, [pathname, router, tLaunch]);
return children;
return (
<>
{children}
<LaunchFileHost />
</>
);
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { subscribeHandoff, takeHandoff, type HandoffKind } from "@/lib/launch-handoff";
/**
* The files handed to this part of the app (lib/launch-handoff.ts), one at a
* time. Nothing is taken while `active` is false, for a surface that isn't
* ready for them yet. `done` moves on to the next file.
*/
export function useHandoffFiles(kind: HandoffKind, active: boolean): { file: File | null; done: () => void } {
const [files, setFiles] = useState<File[]>([]);
useEffect(() => {
if (!active) return;
const take = () => {
for (let handoff = takeHandoff(kind); handoff; handoff = takeHandoff(kind)) {
const received = handoff.files;
if (received.length > 0) setFiles((waiting) => [...waiting, ...received]);
}
};
take();
return subscribeHandoff(kind, take);
}, [kind, active]);
const done = useCallback(() => setFiles((waiting) => waiting.slice(1)), []);
return { file: files[0] ?? null, done };
}
+346
View File
@@ -0,0 +1,346 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FakeCaches, loadWorker } from './sw-harness';
import {
HANDOFF_CACHE,
HANDOFF_TTL_MS,
adoptStoredHandoff,
clearHandoffs,
handOffDraft,
handOffFiles,
handOffLaunchFiles,
handOffNewMessage,
kindForFile,
parseLaunchLanding,
pendingHandoffs,
restoreStoredHandoffs,
shareDraft,
shortcutFromTargetUrl,
stripLandingParams,
subscribeHandoff,
takeHandoff,
} from '../launch-handoff';
// Files and links from outside the app on their way to what handles them.
const file = (name: string, type = '', body = 'x') => new File([body], name, { type });
const handle = (f: File) => ({ kind: 'file', getFile: async () => f });
const settle = () => new Promise((r) => setTimeout(r, 0));
function memoryStorage() {
const backing = new Map<string, string>();
return {
getItem: (key: string) => backing.get(key) ?? null,
setItem: (key: string, value: string) => { backing.set(key, value); },
removeItem: (key: string) => { backing.delete(key); },
};
}
let caches: FakeCaches;
beforeEach(() => {
caches = new FakeCaches();
vi.stubGlobal('caches', caches);
vi.stubGlobal('sessionStorage', memoryStorage());
});
afterEach(() => {
clearHandoffs();
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('where a launched file goes', () => {
it.each([
['mail.eml', '', 'eml'],
['MAIL.EML', '', 'eml'],
['invite.ics', '', 'calendar'],
['invite.ical', '', 'calendar'],
['people.vcf', '', 'contacts'],
['people.vcard', '', 'contacts'],
['noextension', 'message/rfc822', 'eml'],
['noextension', 'text/calendar', 'calendar'],
['noextension', 'text/x-vcard', 'contacts'],
['photo.png', 'image/png', null],
])('%s (%s) goes to %s', (name, type, kind) => {
expect(kindForFile(file(name, type))).toBe(kind);
});
it('splits one launch by type and names what nothing handles', async () => {
const eml = file('a.eml');
const ics = file('b.ics');
const vcf = file('c.vcf');
const result = await handOffLaunchFiles([handle(eml), handle(ics), handle(vcf), handle(file('d.png'))]);
expect(result).toEqual({ unsupported: ['d.png'], failed: 0 });
expect(takeHandoff('eml')?.files).toEqual([eml]);
expect(takeHandoff('calendar')?.files).toEqual([ics]);
expect(takeHandoff('contacts')?.files).toEqual([vcf]);
});
it('counts a file it could not read', async () => {
const result = await handOffLaunchFiles([{ kind: 'file', getFile: async () => { throw new Error('gone'); } }]);
expect(result).toEqual({ unsupported: [], failed: 1 });
expect(pendingHandoffs()).toEqual([]);
});
it('skips a directory handle', async () => {
const result = await handOffLaunchFiles([{ kind: 'directory' }]);
expect(result).toEqual({ unsupported: [], failed: 0 });
expect(pendingHandoffs()).toEqual([]);
});
});
describe('handing off', () => {
it('gives a handoff to one taker, once', () => {
const ics = file('a.ics');
handOffFiles([ics], 'calendar');
expect(takeHandoff('contacts')).toBeNull();
expect(takeHandoff('calendar')?.files).toEqual([ics]);
expect(takeHandoff('calendar')).toBeNull();
});
it('tells the app first, while the handoff is still pending, then the taker', () => {
const order: string[] = [];
const offKind = subscribeHandoff('contacts', () => {
order.push(`taker:${takeHandoff('contacts') ? 'got it' : 'nothing'}`);
});
const offAny = subscribeHandoff('any', (kind) => {
order.push(`app:${kind}:${pendingHandoffs().length}`);
});
handOffFiles([file('a.vcf')], 'contacts');
expect(order).toEqual(['app:contacts:1', 'taker:got it']);
offKind();
offAny();
});
it('stops telling a listener that unsubscribed', () => {
const listener = vi.fn();
subscribeHandoff('calendar', listener)();
handOffFiles([file('a.ics')], 'calendar');
expect(listener).not.toHaveBeenCalled();
});
it('does nothing for no files', () => {
handOffFiles([], 'calendar');
expect(pendingHandoffs()).toEqual([]);
});
it('drops what waited too long', () => {
vi.useFakeTimers({ toFake: ['Date'] });
handOffFiles([file('a.ics')], 'calendar');
vi.advanceTimersByTime(HANDOFF_TTL_MS + 1);
expect(takeHandoff('calendar')).toBeNull();
expect(pendingHandoffs()).toEqual([]);
});
it('counts a new message that arrives twice in one launch once', () => {
handOffNewMessage();
handOffNewMessage();
expect(takeHandoff('compose')).toMatchObject({ files: [] });
expect(takeHandoff('compose')).toBeNull();
});
it('carries the text of a draft', () => {
handOffDraft({ subject: 'Hi', body: 'There', files: [file('a.pdf')] });
const handoff = takeHandoff('compose');
expect(handoff).toMatchObject({ subject: 'Hi', body: 'There' });
expect(handoff?.files.map((f) => f.name)).toEqual(['a.pdf']);
});
});
describe('across a page load', () => {
// A sign-in through SSO leaves the page. A new module state stands in for
// the new page: the queue is emptied, sessionStorage and the cache stay.
async function newPage() {
pendingHandoffs();
while (takeHandoffQuietly()) { /* the old page's memory goes */ }
}
function takeHandoffQuietly() {
const list = pendingHandoffs() as unknown as unknown[];
return list.length > 0 ? list.splice(0, list.length) : null;
}
it('brings back a file that waited for a sign-in, with its name and type', async () => {
handOffFiles([file('a.ics', 'text/calendar', 'BEGIN:VCALENDAR')], 'calendar');
await settle();
await newPage();
expect(takeHandoff('calendar')).toBeNull();
await restoreStoredHandoffs();
const handoff = takeHandoff('calendar');
expect(handoff?.files[0].name).toBe('a.ics');
expect(handoff?.files[0].type).toBe('text/calendar');
expect(await handoff?.files[0].text()).toBe('BEGIN:VCALENDAR');
});
it('removes the stored copy once it is taken', async () => {
handOffFiles([file('a.ics')], 'calendar');
await settle();
const store = await caches.open(HANDOFF_CACHE);
expect((await store.keys()).length).toBe(2);
takeHandoff('calendar');
await settle();
await settle();
expect((await store.keys()).length).toBe(0);
expect(sessionStorage.getItem('bulwark:launch-handoffs')).toBeNull();
});
it('never stores a .eml: it opens at once and needs no sign-in', async () => {
handOffFiles([file('a.eml')], 'eml');
await settle();
expect(await caches.has(HANDOFF_CACHE)).toBe(false);
});
it("leaves another window's handoff alone", async () => {
handOffFiles([file('a.ics')], 'calendar');
await settle();
await newPage();
// Another window: its own sessionStorage, the same cache.
vi.stubGlobal('sessionStorage', memoryStorage());
await restoreStoredHandoffs();
expect(takeHandoff('calendar')).toBeNull();
expect((await (await caches.open(HANDOFF_CACHE)).keys()).length).toBe(2);
});
it('sweeps what nobody took, and keeps what is recent', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
handOffFiles([file('old.ics')], 'calendar');
await settle();
await newPage();
vi.stubGlobal('sessionStorage', memoryStorage());
vi.advanceTimersByTime(HANDOFF_TTL_MS + 1);
handOffFiles([file('new.vcf')], 'contacts');
await settle();
await restoreStoredHandoffs();
const store = await caches.open(HANDOFF_CACHE);
const urls = (await store.keys()).map((key) => key.url);
expect(urls).toHaveLength(2);
const meta = await store.match(urls.find((url) => url.endsWith('/meta'))!);
expect(((await meta!.json()) as { kind: string }).kind).toBe('contacts');
});
it('keeps working in memory without the Cache API', async () => {
vi.stubGlobal('caches', undefined);
const ics = file('a.ics');
handOffFiles([ics], 'calendar');
await settle();
expect(takeHandoff('calendar')?.files).toEqual([ics]);
expect(await adoptStoredHandoff('anything')).toBe(false);
});
it('keeps the file in memory when the cache is full', async () => {
const original = caches.open.bind(caches);
vi.spyOn(caches, 'open').mockImplementation(async (name: string) => ({
...(await original(name)),
put: async () => { throw new DOMException('full', 'QuotaExceededError'); },
}));
const ics = file('a.ics');
handOffFiles([ics], 'calendar');
await settle();
expect(sessionStorage.getItem('bulwark:launch-handoffs')).toBeNull();
expect(takeHandoff('calendar')?.files).toEqual([ics]);
});
});
describe('sign-out', () => {
it('forgets what is waiting, in memory and stored', async () => {
handOffFiles([file('a.ics')], 'calendar');
await settle();
clearHandoffs();
await settle();
expect(pendingHandoffs()).toEqual([]);
expect(await caches.has(HANDOFF_CACHE)).toBe(false);
expect(sessionStorage.getItem('bulwark:launch-handoffs')).toBeNull();
});
});
describe('what the service worker stored', () => {
it('becomes a draft with the files, under the id in the address', async () => {
// The real worker writes it, so this fails if the two layouts drift apart.
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
const form = new FormData();
form.set('title', 'A link');
form.set('text', 'Look at this');
form.set('url', 'https://example.org/page');
form.append('files', file('photo.png', 'image/png', 'bytes'));
const answer = await worker.request({ url: 'https://mail.example.com/protocol/share', method: 'POST', mode: 'navigate', formData: async () => form } as never);
const landing = parseLaunchLanding(new URL(answer!.headers.get('location')!).search);
expect(landing?.type).toBe('stored');
const { id } = landing as { id: string };
expect(await adoptStoredHandoff(id)).toBe(true);
const handoff = takeHandoff('compose');
expect(handoff).toMatchObject({ subject: 'A link', body: 'Look at this\n\nhttps://example.org/page' });
expect(handoff?.files[0].name).toBe('photo.png');
expect(handoff?.files[0].type).toBe('image/png');
expect(await handoff?.files[0].text()).toBe('bytes');
await settle();
await settle();
expect((await (await caches.open(HANDOFF_CACHE)).keys()).length).toBe(0);
});
it('is gone when the id names nothing', async () => {
expect(await adoptStoredHandoff('11111111-2222')).toBe(false);
});
it('is gone when it is too old', async () => {
const store = await caches.open(HANDOFF_CACHE);
await store.put('/__bulwark/handoff/old/meta', new Response(JSON.stringify({ kind: 'compose', createdAt: Date.now() - HANDOFF_TTL_MS - 1, files: [] })));
expect(await adoptStoredHandoff('old')).toBe(false);
expect((await store.keys()).length).toBe(0);
});
});
describe('text of a share', () => {
it('puts the link under the text', () => {
expect(shareDraft({ title: ' T ', text: 'Hello', url: 'https://a.example' })).toEqual({ subject: 'T', body: 'Hello\n\nhttps://a.example' });
});
it('does not repeat a link that is in the text already', () => {
expect(shareDraft({ text: 'See https://a.example now', url: 'https://a.example' }).body).toBe('See https://a.example now');
});
it('copes with only a link', () => {
expect(shareDraft({ url: 'https://a.example' })).toEqual({ subject: '', body: 'https://a.example' });
});
});
describe('the address a launch lands on', () => {
it.each([
['?compose=1', { type: 'new-message' }],
['?share=abc-123', { type: 'stored', id: 'abc-123' }],
['?share=unavailable&share_title=T&share_text=B&share_files=1', { type: 'inline', subject: 'T', body: 'B', filesLost: true }],
['?share=unavailable&share_url=https%3A%2F%2Fa.example', { type: 'inline', subject: '', body: 'https://a.example', filesLost: false }],
['?share=..%2F..%2Fetc', null],
['?compose=0', null],
['?openLatestUnread=1', null],
['', null],
])('%s', (search, expected) => {
expect(parseLaunchLanding(search)).toEqual(expected);
});
it('leaves only what is not about the launch', () => {
expect(stripLandingParams('?compose=1')).toBe('');
expect(stripLandingParams('?share=unavailable&share_text=x&slot=2')).toBe('?slot=2');
});
});
describe('a shortcut reaching an open window', () => {
const origin = 'https://mail.example.com';
it.each([
[`${origin}/?compose=1`, 'new-message'],
[`${origin}/en/?compose=1`, 'new-message'],
[`${origin}/calendar`, 'calendar'],
[`${origin}/de/contacts`, 'contacts'],
// The plain app icon and links into the app don't move an open window.
[`${origin}/`, null],
[`${origin}/calendar/some-event`, null],
[`${origin}/settings`, null],
['https://evil.example/calendar', null],
])('%s', (target, expected) => {
expect(shortcutFromTargetUrl(target, origin)).toBe(expected);
});
});
+200
View File
@@ -0,0 +1,200 @@
// @vitest-environment node
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Shortcuts, file handlers and the share target in the manifest, with what
// the route already did (per-domain branding, the deployment subpath).
const hoisted = vi.hoisted(() => ({
requestHeaders: {} as Record<string, string>,
config: {} as Record<string, unknown>,
sources: {} as Record<string, { source: string }>,
}));
vi.mock('next/headers', () => ({
headers: async () => new Headers(hoisted.requestHeaders),
}));
// The real strings, so a missing or misnamed key fails here.
vi.mock('next-intl/server', () => ({
getTranslations: async ({ locale, namespace }: { locale: string; namespace: string }) => {
const messages = JSON.parse(readFileSync(join(process.cwd(), 'locales', locale, 'common.json'), 'utf8'));
return (key: string) => {
const value = messages[namespace]?.[key];
if (typeof value !== 'string') throw new Error(`missing ${namespace}.${key} in ${locale}`);
return value;
};
},
}));
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: async () => {},
get: (key: string, fallback: unknown) => hoisted.config[key] ?? fallback,
getAllWithSources: () => hoisted.sources,
},
}));
type Manifest = {
name: string;
shortcuts: Array<{ name: string; url: string; icons: Array<{ src: string }> }>;
file_handlers: Array<{ action: string; accept: Record<string, string[]> }>;
share_target: { action: string; method: string; enctype: string; params: Record<string, unknown> };
protocol_handlers: Array<{ protocol: string; url: string }>;
};
async function load(): Promise<Manifest> {
vi.resetModules();
const { default: manifest } = await import('@/app/manifest');
return (await manifest()) as unknown as Manifest;
}
beforeEach(() => {
hoisted.requestHeaders = {};
hoisted.config = {};
hoisted.sources = {
pwaIconUrl: { source: 'default' },
faviconUrl: { source: 'default' },
pwaScreenshotMobileUrl: { source: 'default' },
pwaScreenshotDesktopUrl: { source: 'default' },
};
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe('manifest launch fields', () => {
it('offers the three shortcuts', async () => {
const m = await load();
expect(m.shortcuts.map((s) => [s.name, s.url])).toEqual([
['New message', '/?compose=1'],
['Calendar', '/calendar'],
['Contacts', '/contacts'],
]);
});
it('names the shortcuts in the language the browser asks in', async () => {
hoisted.requestHeaders = { 'accept-language': 'de-DE,de;q=0.9,en;q=0.8' };
expect((await load()).shortcuts.map((s) => s.name)).toEqual(['Neue Nachricht', 'Kalender', 'Kontakte']);
});
it('falls back to English for a language it does not have', async () => {
hoisted.requestHeaders = { 'accept-language': 'xx' };
expect((await load()).shortcuts[0].name).toBe('New message');
});
it('handles .eml, .ics and .vcf', async () => {
const m = await load();
const extensions = (action: string) => Object.values(m.file_handlers.find((h) => h.action === action)?.accept ?? {}).flat();
expect(extensions('/mail')).toEqual(['.eml']);
expect(extensions('/calendar')).toEqual(expect.arrayContaining(['.ics']));
expect(extensions('/contacts')).toEqual(expect.arrayContaining(['.vcf']));
expect(m.file_handlers).toHaveLength(3);
});
it('takes shares as multipart POST outside /api/, with files and links', async () => {
const { share_target: share } = await load();
expect(share.action).toBe('/protocol/share');
expect(share.action.startsWith('/api/')).toBe(false);
expect(share.method).toBe('POST');
expect(share.enctype).toBe('multipart/form-data');
expect(share.params).toEqual({ title: 'title', text: 'text', url: 'url', files: [{ name: 'files', accept: ['*/*'] }] });
});
it('prefixes every new address with the subpath', async () => {
vi.stubEnv('NEXT_PUBLIC_BASE_PATH', '/webmail');
const m = await load();
expect(m.shortcuts.map((s) => s.url)).toEqual(['/webmail/?compose=1', '/webmail/calendar', '/webmail/contacts']);
expect(m.shortcuts[0].icons[0].src).toBe('/webmail/icon-192x192.png');
expect(m.file_handlers.map((h) => h.action)).toEqual(['/webmail/mail', '/webmail/calendar', '/webmail/contacts']);
expect(m.share_target.action).toBe('/webmail/protocol/share');
});
it("keeps a domain's branding, its icon on the shortcuts too", async () => {
hoisted.requestHeaders = { host: 'mail.acme.example' };
hoisted.config = { domainBranding: [{ host: 'mail.acme.example', appName: 'Acme Mail', pwaIconUrl: 'https://cdn.example/i.png' }] };
const m = await load();
expect(m.name).toBe('Acme Mail');
expect(m.shortcuts[0].icons[0].src).toBe('/api/pwa-icon/192');
expect(m.file_handlers).toHaveLength(3);
expect(m.share_target.action).toBe('/protocol/share');
});
it('leaves the protocol handlers as they are', async () => {
expect((await load()).protocol_handlers.map((h) => h.url)).toEqual(['/protocol/mailto?url=%s', '/protocol/webcal?url=%s']);
});
});
describe('Lite', () => {
it('has no share target or file handlers: no service worker, no launch consumer behind them', async () => {
const { buildManifest } = await import('@/scripts/lite/lib.mjs');
const lite = buildManifest({ appName: 'Acme' });
expect(lite).not.toHaveProperty('share_target');
expect(lite).not.toHaveProperty('file_handlers');
expect(lite).not.toHaveProperty('shortcuts');
});
});
describe('share route without a service worker', () => {
const route = () => import('@/app/(main)/protocol/share/route');
function post(form: FormData | null, contentLength?: string) {
const request = new Request('https://mail.example.com/protocol/share', { method: 'POST', ...(form ? { body: form } : {}) });
if (contentLength !== undefined) {
const headers = new Headers(request.headers);
headers.set('content-length', contentLength);
return { headers, formData: () => request.formData() } as unknown as Request;
}
return request;
}
it('sends text and links on to a new draft, and goes to the app with a 303', async () => {
const { POST } = await route();
const form = new FormData();
form.set('title', 'T');
form.set('text', 'B');
form.set('url', 'https://example.org/');
const res = await POST(post(form, '500'));
expect(res.status).toBe(303);
const landing = new URL(res.headers.get('location')!, 'https://x.example');
expect(landing.pathname).toBe('/');
expect(Object.fromEntries(landing.searchParams)).toEqual({ share: 'unavailable', share_title: 'T', share_text: 'B', share_url: 'https://example.org/' });
});
it('says files were lost', async () => {
const { POST } = await route();
const form = new FormData();
form.set('text', 'B');
form.append('files', new File(['x'], 'a.png'));
const res = await POST(post(form, '500'));
expect(new URL(res.headers.get('location')!, 'https://x.example').searchParams.get('share_files')).toBe('1');
});
it('does not read a large upload into memory', async () => {
const { POST } = await route();
const form = new FormData();
form.set('text', 'B');
const spy = vi.fn(async () => form);
const request = { headers: new Headers({ 'content-length': String(50 * 1024 * 1024) }), formData: spy } as unknown as Request;
const res = await POST(request);
expect(spy).not.toHaveBeenCalled();
expect(new URL(res.headers.get('location')!, 'https://x.example').searchParams.get('share_files')).toBe('1');
});
it('keeps the address short', async () => {
const { POST } = await route();
const form = new FormData();
form.set('text', 'x'.repeat(100_000));
const res = await POST(post(form, '100000'));
expect(res.headers.get('location')!.length).toBeLessThan(4200);
});
it('honours the subpath', async () => {
vi.stubEnv('NEXT_PUBLIC_BASE_PATH', '/webmail');
vi.resetModules();
const { GET } = await route();
expect(GET().headers.get('location')).toBe('/webmail/');
});
});
+8
View File
@@ -6,6 +6,7 @@ vi.mock('@/lib/plugin-storage', () => ({
import { fileStorage } from '@/lib/plugin-storage';
import { broadcastSignOut, onSignedOutElsewhere, purgeSignedOutData } from '@/lib/sign-out-cleanup';
import { handOffFiles, pendingHandoffs } from '@/lib/launch-handoff';
// A full sign-out left search history, open tabs (with subjects), Files
// favourites and staged attachment bytes behind for the next person at the
@@ -14,6 +15,13 @@ import { broadcastSignOut, onSignedOutElsewhere, purgeSignedOutData } from '@/li
beforeEach(() => localStorage.clear());
describe('purgeSignedOutData', () => {
it('forgets files and links on their way into an account', () => {
handOffFiles([new File(['x'], 'a.ics')], 'calendar');
expect(pendingHandoffs()).toHaveLength(1);
purgeSignedOutData();
expect(pendingHandoffs()).toHaveLength(0);
});
it("removes the accounts' leftovers and keeps device preferences", () => {
for (const key of [
'email-snapshot', 'pro-tabs', 'search-history-storage', 'calendar-storage',
+3 -3
View File
@@ -29,17 +29,17 @@ export class FakeCaches {
}
}
export function loadWorker(opts: { buildId: string; caches: FakeCaches; clients: Array<{ id: string }>; fetch?: (input: unknown) => Promise<Response> }) {
export function loadWorker(opts: { buildId: string; caches: FakeCaches; clients: Array<{ id: string }>; fetch?: (input: unknown) => Promise<Response>; location?: string }) {
const listeners = new Map<string, (event: unknown) => void>();
let skipped = 0;
const self = {
location: { href: `https://mail.example.com/sw.js?v=${opts.buildId}`, origin: 'https://mail.example.com' },
location: { href: opts.location ?? `https://mail.example.com/sw.js?v=${opts.buildId}`, origin: 'https://mail.example.com' },
addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn),
skipWaiting: () => { skipped++; },
clients: { claim: async () => {}, matchAll: async () => opts.clients },
registration: { showNotification: async () => {}, getNotifications: async () => [] },
};
const context = vm.createContext({ self, caches: opts.caches, fetch: opts.fetch ?? (async () => { throw new TypeError('offline'); }), URL, URLSearchParams, Request, Response, Headers, Date, console, AbortController, setTimeout, clearTimeout });
const context = vm.createContext({ self, caches: opts.caches, fetch: opts.fetch ?? (async () => { throw new TypeError('offline'); }), URL, URLSearchParams, Request, Response, Headers, Date, console, AbortController, setTimeout, clearTimeout, crypto: globalThis.crypto });
vm.runInContext(SW_SOURCE, context);
async function dispatch(type: string, event: Record<string, unknown> = {}) {
const waits: Promise<unknown>[] = [];
+152
View File
@@ -0,0 +1,152 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest';
import { FakeCaches, loadWorker } from './sw-harness';
// The manifest's share_target POST, in the service worker, and a mailto:
// launch while offline. Before: a POST went to the server when offline mode was off.
const ORIGIN = 'https://mail.example.com';
const settle = () => new Promise((r) => setTimeout(r, 0));
function shareRequest(form: FormData, overrides: Record<string, unknown> = {}) {
return { url: `${ORIGIN}/protocol/share`, method: 'POST', mode: 'navigate', formData: async () => form, ...overrides } as never;
}
function sharedForm(): FormData {
const form = new FormData();
form.set('title', 'A link');
form.set('text', 'Look');
form.set('url', 'https://example.org/');
form.append('files', new File(['bytes'], 'photo.png', { type: 'image/png' }));
return form;
}
async function shellOn(caches: FakeCaches, on: boolean) {
const cache = await caches.open('bulwark-sw-state-v1');
await cache.put('/__bulwark/offline-shell', new Response(JSON.stringify({ on })));
}
function htmlPage(body: string) {
const res = new Response(body, { status: 200, headers: { 'Content-Type': 'text/html' } });
Object.defineProperty(res, 'type', { value: 'basic' });
return res;
}
describe('share target', () => {
it.each([[false], [true]])('takes the POST with offline mode on=%s, keeps the files and goes to the app', async (on) => {
const caches = new FakeCaches();
await shellOn(caches, on);
const fetchMock = vi.fn();
const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: fetchMock });
await settle();
const answer = await worker.request(shareRequest(sharedForm()));
expect(fetchMock).not.toHaveBeenCalled();
expect(answer?.status).toBe(303);
const landing = new URL(answer!.headers.get('location')!);
expect(`${landing.origin}${landing.pathname}`).toBe(`${ORIGIN}/`);
const id = landing.searchParams.get('share')!;
expect(id).toMatch(/^[\w-]+$/);
const store = await caches.open('bulwark-launch-handoff-v1');
const meta = (await (await store.match(`/__bulwark/handoff/${id}/meta`))!.json()) as Record<string, unknown>;
expect(meta).toMatchObject({ kind: 'compose', title: 'A link', text: 'Look', url: 'https://example.org/', files: [{ name: 'photo.png', type: 'image/png' }] });
expect(await (await store.match(`/__bulwark/handoff/${id}/0`))!.text()).toBe('bytes');
});
it('answers while the worker has only just started and has not read its state', async () => {
const worker = loadWorker({ buildId: 'a', caches: new FakeCaches(), clients: [] });
const answer = await worker.request(shareRequest(sharedForm()));
expect(answer?.status).toBe(303);
});
it('works under a subpath', async () => {
const worker = loadWorker({ buildId: 'a', caches: new FakeCaches(), clients: [], location: `${ORIGIN}/webmail/sw.js?v=a` });
expect(await worker.request(shareRequest(sharedForm()))).toBeNull();
const answer = await worker.request(shareRequest(sharedForm(), { url: `${ORIGIN}/webmail/protocol/share` }));
expect(answer!.headers.get('location')).toContain(`${ORIGIN}/webmail/?share=`);
});
it('skips empty file fields', async () => {
const caches = new FakeCaches();
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
const form = new FormData();
form.set('url', 'https://example.org/');
form.append('files', new File([], ''));
const answer = await worker.request(shareRequest(form));
const id = new URL(answer!.headers.get('location')!).searchParams.get('share')!;
const store = await caches.open('bulwark-launch-handoff-v1');
const meta = (await (await store.match(`/__bulwark/handoff/${id}/meta`))!.json()) as { files: unknown[] };
expect(meta.files).toEqual([]);
});
it('keeps the text and says the files are lost when there is no room', async () => {
const caches = new FakeCaches();
const original = caches.open.bind(caches);
const store = await original('bulwark-launch-handoff-v1');
vi.spyOn(caches, 'open').mockImplementation(async (name: string) => (name === 'bulwark-launch-handoff-v1'
? { ...store, put: async () => { throw new Error('QuotaExceededError'); } }
: original(name)));
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
const answer = await worker.request(shareRequest(sharedForm()));
const landing = new URL(answer!.headers.get('location')!).searchParams;
expect(landing.get('share')).toBe('unavailable');
expect(landing.get('share_files')).toBe('1');
expect(landing.get('share_title')).toBe('A link');
expect(landing.get('share_url')).toBe('https://example.org/');
expect((await store.keys()).length).toBe(0);
});
it('says so when the form cannot be read', async () => {
const worker = loadWorker({ buildId: 'a', caches: new FakeCaches(), clients: [] });
const answer = await worker.request(shareRequest(new FormData(), { formData: async () => { throw new TypeError('bad body'); } }));
const landing = new URL(answer!.headers.get('location')!).searchParams;
expect(landing.get('share')).toBe('unavailable');
expect(landing.get('share_files')).toBe('1');
});
it('leaves other requests alone', async () => {
const caches = new FakeCaches();
await shellOn(caches, false);
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
await settle();
// A fetch from a page, a POST elsewhere, another origin, and a plain GET.
expect(await worker.request(shareRequest(sharedForm(), { mode: 'cors' }))).toBeNull();
expect(await worker.request(shareRequest(sharedForm(), { url: `${ORIGIN}/api/share` }))).toBeNull();
expect(await worker.request(shareRequest(sharedForm(), { url: 'https://other.example/protocol/share' }))).toBeNull();
expect(await worker.request({ url: `${ORIGIN}/protocol/share`, mode: 'navigate' })).toBeNull();
expect(await caches.has('bulwark-launch-handoff-v1')).toBe(false);
});
});
describe('a mailto: launch without a connection', () => {
it('gets the kept handler page instead of a network error', async () => {
const caches = new FakeCaches();
await shellOn(caches, true);
const online = vi.fn(async () => htmlPage('<html>mailto page'));
const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: online });
await settle();
await worker.dispatch('message', { data: { type: 'offline-shell', on: true, urls: ['/protocol/mailto'], buildId: 'a' } });
await settle();
online.mockImplementation(async () => { throw new TypeError('offline'); });
const answer = await worker.request({ url: `${ORIGIN}/protocol/mailto?url=mailto%3Aa%40example.org`, mode: 'navigate' });
expect(await answer!.text()).toBe('<html>mailto page');
});
it('falls back to the start page when that page was never kept', async () => {
const caches = new FakeCaches();
await shellOn(caches, true);
const shell = await caches.open('bulwark-shell-a');
await shell.put(ORIGIN, htmlPage('<html>start'));
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
await settle();
const answer = await worker.request({ url: `${ORIGIN}/protocol/webcal?url=x`, mode: 'navigate' });
expect(await answer!.text()).toBe('<html>start');
});
});
+375
View File
@@ -0,0 +1,375 @@
/**
* Files and links that arrive from outside the app, on their way to the part
* of the app that handles them: a .eml to the viewer, a .ics to the calendar
* import, a .vcf to the contacts import, shared files and links to a new
* draft.
*
* Three things feed it: the launchQueue consumer
* (components/protocol/protocol-launch-handler-provider.tsx), the service
* worker's share target (public/sw.js) and, later, the desktop app's bridge,
* because launchQueue never fires in Electron. A bridge only has to call
* handOffFiles, handOffDraft or handOffNewMessage.
*
* The pending mailto next to it (lib/protocol-handlers/session.ts) is JSON in
* sessionStorage, which can't hold a File. So a handoff lives in this module's
* memory, and everything except a .eml also goes into the Cache API: signing
* in with SSO, or after a session ended, leaves the page, and the file has to
* still be there afterwards. sessionStorage only holds which stored handoffs
* belong to this tab, so a second window doesn't take them. What is stored
* goes when it is taken, when it is too old, or at sign-out.
*/
import { matchSurface } from '@/lib/deep-links';
export type HandoffKind = 'compose' | 'eml' | 'calendar' | 'contacts';
export interface Handoff {
id: string;
kind: HandoffKind;
files: File[];
/** New draft only. */
subject?: string;
body?: string;
createdAt: number;
}
/** The service worker writes this cache too, in the same layout. */
export const HANDOFF_CACHE = 'bulwark-launch-handoff-v1';
const STORE_PREFIX = '/__bulwark/handoff/';
const TAB_IDS_KEY = 'bulwark:launch-handoffs';
export const HANDOFF_TTL_MS = 15 * 60 * 1000;
/** A launch can arrive twice: as launchQueue's target and as the page's own address. */
const NEW_MESSAGE_REPEAT_MS = 2000;
type Listener = (kind: HandoffKind) => void;
const queue: Handoff[] = [];
const stored = new Map<string, Promise<void>>();
const kindListeners = new Map<HandoffKind, Set<Listener>>();
const anyListeners = new Set<Listener>();
let lastNewMessageAt = 0;
function newId(): string {
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
function cacheApi(): CacheStorage | null {
return typeof caches !== 'undefined' && caches && typeof caches.open === 'function' ? caches : null;
}
function readTabIds(): string[] {
try {
const parsed: unknown = JSON.parse(sessionStorage.getItem(TAB_IDS_KEY) ?? '[]');
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [];
} catch {
return [];
}
}
function writeTabIds(ids: string[]): void {
try {
if (ids.length > 0) sessionStorage.setItem(TAB_IDS_KEY, JSON.stringify(ids));
else sessionStorage.removeItem(TAB_IDS_KEY);
} catch {
// Hardened or private modes: the handoff stays in memory only.
}
}
async function writeStored(handoff: Handoff): Promise<void> {
const api = cacheApi();
if (!api) return;
const prefix = STORE_PREFIX + handoff.id + '/';
try {
const cache = await api.open(HANDOFF_CACHE);
// The list first: the sweep goes by it, so no file can stay behind unlisted.
await cache.put(prefix + 'meta', new Response(JSON.stringify({
kind: handoff.kind,
createdAt: handoff.createdAt,
title: handoff.subject ?? '',
text: handoff.body ?? '',
url: '',
files: handoff.files.map((file) => ({ name: file.name, type: file.type })),
})));
await Promise.all(handoff.files.map((file, index) => cache.put(prefix + index, new Response(file))));
writeTabIds([...readTabIds().filter((id) => id !== handoff.id), handoff.id]);
} catch {
// Quota, or no Cache API here: the handoff stays in memory only.
await deleteStored(handoff.id);
}
}
async function deleteStored(id: string): Promise<void> {
writeTabIds(readTabIds().filter((other) => other !== id));
const api = cacheApi();
if (!api) return;
try {
if (!(await api.has(HANDOFF_CACHE))) return;
const cache = await api.open(HANDOFF_CACHE);
const prefix = STORE_PREFIX + id + '/';
const keys = await cache.keys();
const own = keys.filter((key) => new URL(key.url, 'https://x.invalid').pathname.startsWith(prefix));
// The list last, for the sweep.
await Promise.all(own.filter((key) => !key.url.endsWith('/meta')).map((key) => cache.delete(key)));
await cache.delete(prefix + 'meta');
} catch {
// The sweep gets it.
}
}
function notify(kind: HandoffKind): void {
// The app first, so it can go to the right page while the handoff is still
// pending, then whoever takes it.
for (const listener of [...anyListeners]) listener(kind);
for (const listener of [...(kindListeners.get(kind) ?? [])]) listener(kind);
}
function enqueue(handoff: Handoff, persist: boolean): void {
queue.push(handoff);
if (persist) stored.set(handoff.id, writeStored(handoff));
notify(handoff.kind);
}
/** The kind a file from the OS goes to; null when nothing here handles it. */
export function kindForFile(file: { name: string; type?: string }): HandoffKind | null {
const dot = file.name.lastIndexOf('.');
const extension = dot >= 0 ? file.name.slice(dot).toLowerCase() : '';
if (extension === '.eml') return 'eml';
if (extension === '.ics' || extension === '.ical') return 'calendar';
if (extension === '.vcf' || extension === '.vcard') return 'contacts';
const type = (file.type ?? '').toLowerCase();
if (type === 'message/rfc822') return 'eml';
if (type === 'text/calendar') return 'calendar';
if (type === 'text/vcard' || type === 'text/x-vcard') return 'contacts';
return null;
}
/** Hands files to a part of the app, which opens when they arrive. */
export function handOffFiles(files: File[], kind: HandoffKind): void {
if (files.length === 0) return;
// A .eml opens at once and needs no sign-in, so it never waits for one.
enqueue({ id: newId(), kind, files, createdAt: Date.now() }, kind !== 'eml');
}
/** Opens a new draft with this text and these files. */
export function handOffDraft(draft: { subject?: string; body?: string; files?: File[] }): void {
enqueue({
id: newId(),
kind: 'compose',
files: draft.files ?? [],
...(draft.subject ? { subject: draft.subject } : {}),
...(draft.body ? { body: draft.body } : {}),
createdAt: Date.now(),
}, true);
}
/** Opens an empty new message. */
export function handOffNewMessage(): void {
const now = Date.now();
if (now - lastNewMessageAt < NEW_MESSAGE_REPEAT_MS) return;
lastNewMessageAt = now;
enqueue({ id: newId(), kind: 'compose', files: [], createdAt: now }, false);
}
/**
* Routes files from a launch by their type. Returns the names nothing handles
* and how many couldn't be read.
*/
export async function handOffLaunchFiles(
handles: ReadonlyArray<{ kind?: string; getFile?: () => Promise<File> }>,
): Promise<{ unsupported: string[]; failed: number }> {
const byKind = new Map<HandoffKind, File[]>();
const unsupported: string[] = [];
let failed = 0;
for (const handle of handles) {
if (typeof handle.getFile !== 'function') continue;
try {
const file = await handle.getFile();
const kind = kindForFile(file);
if (!kind) {
unsupported.push(file.name);
continue;
}
byKind.set(kind, [...(byKind.get(kind) ?? []), file]);
} catch {
failed += 1;
}
}
for (const [kind, files] of byKind) handOffFiles(files, kind);
return { unsupported, failed };
}
/** The oldest handoff of this kind; it is gone from here after this. */
export function takeHandoff(kind: HandoffKind): Handoff | null {
const now = Date.now();
for (let index = 0; index < queue.length; index += 1) {
if (queue[index].kind !== kind) continue;
const [handoff] = queue.splice(index, 1);
index -= 1;
void (stored.get(handoff.id) ?? Promise.resolve()).then(() => deleteStored(handoff.id));
stored.delete(handoff.id);
if (now - handoff.createdAt <= HANDOFF_TTL_MS) return handoff;
}
return null;
}
/** What is waiting, oldest first. Nothing is taken. */
export function pendingHandoffs(): readonly Handoff[] {
return queue;
}
/** `kind` "any" hears every handoff, before those listening for that kind. */
export function subscribeHandoff(kind: HandoffKind | 'any', listener: Listener): () => void {
if (kind === 'any') {
anyListeners.add(listener);
return () => { anyListeners.delete(listener); };
}
const set = kindListeners.get(kind) ?? new Set<Listener>();
kindListeners.set(kind, set);
set.add(listener);
return () => { set.delete(listener); };
}
/** Subject and body of a draft from what the OS shared. */
export function shareDraft(shared: { title?: string; text?: string; url?: string }): { subject: string; body: string } {
const text = (shared.text ?? '').trim();
const url = (shared.url ?? '').trim();
// Android often puts the link in the text as well.
const body = url && !text.includes(url) ? [text, url].filter(Boolean).join('\n\n') : text;
return { subject: (shared.title ?? '').trim(), body };
}
/** Brings a stored handoff into this tab. False when it is gone. */
export async function adoptStoredHandoff(id: string): Promise<boolean> {
if (queue.some((handoff) => handoff.id === id)) return true;
const api = cacheApi();
if (!api) return false;
const prefix = STORE_PREFIX + id + '/';
try {
if (!(await api.has(HANDOFF_CACHE))) return false;
const cache = await api.open(HANDOFF_CACHE);
const metaResponse = await cache.match(prefix + 'meta');
if (!metaResponse) return false;
const meta = (await metaResponse.json()) as {
kind: HandoffKind; createdAt: number; title?: string; text?: string; url?: string;
files?: Array<{ name: string; type?: string }>;
};
if (Date.now() - meta.createdAt > HANDOFF_TTL_MS) {
await deleteStored(id);
return false;
}
const files: File[] = [];
for (const [index, entry] of (meta.files ?? []).entries()) {
const response = await cache.match(prefix + index);
if (!response) continue;
files.push(new File([await response.blob()], entry.name, { type: entry.type ?? '' }));
}
const { subject, body } = shareDraft(meta);
// A second await above: another caller may have got here first.
if (queue.some((handoff) => handoff.id === id)) return true;
if (!readTabIds().includes(id)) writeTabIds([...readTabIds(), id]);
enqueue({
id,
kind: meta.kind,
files,
...(subject ? { subject } : {}),
...(body ? { body } : {}),
createdAt: meta.createdAt,
}, false);
return true;
} catch {
return false;
}
}
/** After a page load: this tab's stored handoffs come back, what is too old goes. */
export async function restoreStoredHandoffs(): Promise<void> {
await Promise.all(readTabIds().map(async (id) => {
if (!(await adoptStoredHandoff(id))) writeTabIds(readTabIds().filter((other) => other !== id));
}));
const api = cacheApi();
if (!api) return;
try {
if (!(await api.has(HANDOFF_CACHE))) return;
const cache = await api.open(HANDOFF_CACHE);
for (const key of await cache.keys()) {
const match = /\/__bulwark\/handoff\/([^/]+)\/meta$/.exec(key.url);
if (!match) continue;
const response = await cache.match(key);
const meta = response ? ((await response.json()) as { createdAt?: number }) : null;
if (!meta || typeof meta.createdAt !== 'number' || Date.now() - meta.createdAt > HANDOFF_TTL_MS) {
await deleteStored(match[1]);
}
}
} catch {
// Tried again at the next page load.
}
}
/** Sign-out: nothing that was on its way into an account stays. */
export function clearHandoffs(): void {
queue.length = 0;
stored.clear();
writeTabIds([]);
lastNewMessageAt = 0;
const api = cacheApi();
if (api) void api.delete(HANDOFF_CACHE).catch(() => {});
}
export type LaunchLanding =
| { type: 'new-message' }
/** The service worker stored what was shared. */
| { type: 'stored'; id: string }
/** It couldn't be stored, or no worker ran: the text came along in the address. */
| { type: 'inline'; subject: string; body: string; filesLost: boolean };
const LANDING_PARAMS = ['compose', 'share', 'share_title', 'share_text', 'share_url', 'share_files'];
/** What the page's own address asks for, once. See stripLandingParams. */
export function parseLaunchLanding(search: string): LaunchLanding | null {
const params = new URLSearchParams(search);
if (params.get('compose') === '1') return { type: 'new-message' };
const share = params.get('share');
if (!share) return null;
if (share === 'unavailable') {
return {
type: 'inline',
...shareDraft({
title: params.get('share_title') ?? '',
text: params.get('share_text') ?? '',
url: params.get('share_url') ?? '',
}),
filesLost: params.get('share_files') === '1',
};
}
return /^[\w-]{1,64}$/.test(share) ? { type: 'stored', id: share } : null;
}
/** The query without what parseLaunchLanding read, so a reload doesn't do it again. */
export function stripLandingParams(search: string): string {
const params = new URLSearchParams(search);
for (const name of LANDING_PARAMS) params.delete(name);
const rest = params.toString();
return rest ? `?${rest}` : '';
}
/**
* What a launch's address asks of a window that is already open. A shortcut
* with launch_handler focus-existing only focuses that window and passes the
* address here. Anything else, the start page included, stays as it is.
*/
export function shortcutFromTargetUrl(targetURL: string, origin: string): 'new-message' | 'calendar' | 'contacts' | null {
let url: URL;
try {
url = new URL(targetURL, origin);
} catch {
return null;
}
if (url.origin !== origin) return null;
const match = matchSurface(url.pathname);
if (!match || match.segments.length > 0) return null;
if (match.surface === 'mail') return url.searchParams.get('compose') === '1' ? 'new-message' : null;
if (match.surface === 'calendar' || match.surface === 'contacts') return match.surface;
return null;
}
+26
View File
@@ -0,0 +1,26 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
// A mailto: or webcal: launch without a connection needs its handler page in
// the app shell. Its address is what opens the draft; the start page alone
// doesn't read it outside an installed app.
vi.mock('@/lib/browser-navigation', () => ({
apiFetch: async () => new Response(JSON.stringify({ buildId: 'build-1', files: ['/_next/static/a.js'] })),
withBasePath: (path: string) => path,
}));
vi.mock('@/lib/app-update', () => ({ currentBuildId: () => 'build-1', SW_SCOPE: '/' }));
const { tellServiceWorker } = await import('../shell');
afterEach(() => vi.unstubAllGlobals());
describe('app shell pages', () => {
it('includes the protocol handler pages', async () => {
const postMessage = vi.fn();
vi.stubGlobal('navigator', { serviceWorker: { getRegistration: async () => ({ active: { postMessage } }) } });
await tellServiceWorker(true);
expect(postMessage.mock.calls[0][0].urls).toEqual(expect.arrayContaining(['/', '/protocol/mailto', '/protocol/webcal']));
});
});
+1 -1
View File
@@ -8,7 +8,7 @@ import { apiFetch, withBasePath } from '@/lib/browser-navigation';
import { currentBuildId, SW_SCOPE } from '@/lib/app-update';
/** The app's pages kept for a start without the network. */
const SHELL_PAGES = ['/', '/mail', '/settings'];
const SHELL_PAGES = ['/', '/mail', '/settings', '/protocol/mailto', '/protocol/webcal'];
/**
* The files of this tab's build. After a deploy the server lists the new
+3
View File
@@ -1,4 +1,5 @@
import { fileStorage } from '@/lib/plugin-storage';
import { clearHandoffs } from '@/lib/launch-handoff';
import { clearAllCheckpoints } from '@/lib/mail-import/checkpoints';
import { sweepPrivateExports } from '@/lib/mail-export/sinks';
@@ -114,6 +115,8 @@ export function purgeSignedOutData(): void {
/* storage unavailable */
}
forgetOfflineShell();
// Files and links on their way into a draft or an import.
clearHandoffs();
for (const cleanup of signedOutCleanups) {
try {
cleanup();
+11
View File
@@ -249,6 +249,17 @@
"active_account": "نشط",
"switching_account": "جارٍ تبديل الحساب..."
},
"pwaLaunch": {
"shortcut_new_message": "رسالة جديدة",
"shortcut_calendar": "التقويم",
"shortcut_contacts": "جهات الاتصال",
"unsupported_file": "لا يمكن فتح {name} هنا.",
"open_failed": "تعذر فتح الملف. حاول فتحه مرة أخرى.",
"share_gone": "ما شاركته لم يعد موجودًا. يرجى مشاركته مرة أخرى.",
"share_files_lost": "تعذر الاحتفاظ بالملفات المشتركة. أضفها إلى الرسالة مرة أخرى.",
"ics_needs_server": "يحتاج استيراد ملف تقويم إلى اتصال بالخادم. يبدأ الاستيراد عند عودتك إلى الاتصال بالإنترنت.",
"contacts_need_server": "يحتاج استيراد جهات الاتصال إلى اتصال بالخادم. حاول مرة أخرى عند عودتك إلى الاتصال بالإنترنت."
},
"sidebar_apps": {
"modal_title": "تطبيقات الشريط الجانبي",
"add_new": "إضافة تطبيق",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "Actiu",
"switching_account": "Canviant de compte..."
},
"pwaLaunch": {
"shortcut_new_message": "Missatge nou",
"shortcut_calendar": "Calendari",
"shortcut_contacts": "Contactes",
"unsupported_file": "{name} no es pot obrir aquí.",
"open_failed": "No s'ha pogut obrir el fitxer. Torneu a obrir-lo.",
"share_gone": "El que heu compartit ja no hi és. Torneu a compartir-ho.",
"share_files_lost": "No s'han pogut conservar els fitxers compartits. Torneu-los a afegir al missatge.",
"ics_needs_server": "Per importar un fitxer de calendari cal connexió amb el servidor. La importació començarà quan torneu a estar en línia.",
"contacts_need_server": "Per importar contactes cal connexió amb el servidor. Torneu-ho a provar quan torneu a estar en línia."
},
"sidebar_apps": {
"modal_title": "Aplicacions de la barra lateral",
"add_new": "Afegeix una aplicació",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "Aktivní",
"switching_account": "Přepínání účtu..."
},
"pwaLaunch": {
"shortcut_new_message": "Nová zpráva",
"shortcut_calendar": "Kalendář",
"shortcut_contacts": "Kontakty",
"unsupported_file": "{name} zde nelze otevřít.",
"open_failed": "Soubor se nepodařilo otevřít. Zkuste ho otevřít znovu.",
"share_gone": "To, co jste sdíleli, už tu není. Sdílejte to prosím znovu.",
"share_files_lost": "Sdílené soubory se nepodařilo uchovat. Přidejte je do zprávy znovu.",
"ics_needs_server": "Import souboru kalendáře vyžaduje připojení k serveru. Import začne, až budete znovu online.",
"contacts_need_server": "Import kontaktů vyžaduje připojení k serveru. Zkuste to znovu, až budete online."
},
"sidebar_apps": {
"modal_title": "Aplikace postranního panelu",
"add_new": "Přidat aplikaci",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "Aktiv",
"switching_account": "Skifter konto..."
},
"pwaLaunch": {
"shortcut_new_message": "Ny besked",
"shortcut_calendar": "Kalender",
"shortcut_contacts": "Kontakter",
"unsupported_file": "{name} kan ikke åbnes her.",
"open_failed": "Filen kunne ikke åbnes. Prøv at åbne den igen.",
"share_gone": "Det, du delte, er her ikke længere. Del det venligst igen.",
"share_files_lost": "De delte filer kunne ikke gemmes. Tilføj dem til beskeden igen.",
"ics_needs_server": "Import af en kalenderfil kræver forbindelse til serveren. Importen starter, når du er online igen.",
"contacts_need_server": "Import af kontakter kræver forbindelse til serveren. Prøv igen, når du er online igen."
},
"sidebar_apps": {
"modal_title": "Sidepanel-apps",
"add_new": "Tilføj app",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "Aktiv",
"switching_account": "Account wird gewechselt..."
},
"pwaLaunch": {
"shortcut_new_message": "Neue Nachricht",
"shortcut_calendar": "Kalender",
"shortcut_contacts": "Kontakte",
"unsupported_file": "{name} kann hier nicht geöffnet werden.",
"open_failed": "Die Datei konnte nicht geöffnet werden. Öffnen Sie sie bitte noch einmal.",
"share_gone": "Das Geteilte ist nicht mehr vorhanden. Bitte teilen Sie es noch einmal.",
"share_files_lost": "Die geteilten Dateien konnten nicht aufbewahrt werden. Fügen Sie sie der Nachricht bitte noch einmal hinzu.",
"ics_needs_server": "Zum Importieren einer Kalenderdatei wird eine Verbindung zum Server gebraucht. Der Import beginnt, sobald Sie wieder online sind.",
"contacts_need_server": "Zum Importieren von Kontakten wird eine Verbindung zum Server gebraucht. Versuchen Sie es noch einmal, wenn Sie wieder online sind."
},
"sidebar_apps": {
"modal_title": "Sidebar-Apps",
"add_new": "App hinzufügen",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "Active",
"switching_account": "Switching account..."
},
"pwaLaunch": {
"shortcut_new_message": "New message",
"shortcut_calendar": "Calendar",
"shortcut_contacts": "Contacts",
"unsupported_file": "{name} can't be opened here.",
"open_failed": "The file couldn't be opened. Try opening it again.",
"share_gone": "What you shared is no longer here. Please share it again.",
"share_files_lost": "The shared files couldn't be kept. Add them to the message again.",
"ics_needs_server": "Importing a calendar file needs a connection to the server. The import starts when you're back online.",
"contacts_need_server": "Importing contacts needs a connection to the server. Try again when you're back online."
},
"sidebar_apps": {
"modal_title": "Sidebar apps",
"add_new": "Add app",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "Activa",
"switching_account": "Cambiando de cuenta..."
},
"pwaLaunch": {
"shortcut_new_message": "Mensaje nuevo",
"shortcut_calendar": "Calendario",
"shortcut_contacts": "Contactos",
"unsupported_file": "{name} no se puede abrir aquí.",
"open_failed": "No se pudo abrir el archivo. Intenta abrirlo de nuevo.",
"share_gone": "Lo que compartiste ya no está aquí. Compártelo de nuevo.",
"share_files_lost": "No se pudieron conservar los archivos compartidos. Añádelos de nuevo al mensaje.",
"ics_needs_server": "Importar un archivo de calendario requiere conexión con el servidor. La importación empezará cuando vuelvas a estar en línea.",
"contacts_need_server": "Importar contactos requiere conexión con el servidor. Inténtalo de nuevo cuando vuelvas a estar en línea."
},
"sidebar_apps": {
"modal_title": "Aplicaciones de la barra lateral",
"add_new": "Añadir aplicación",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "Aktiivne",
"switching_account": "Konto vahetamine..."
},
"pwaLaunch": {
"shortcut_new_message": "Uus kiri",
"shortcut_calendar": "Kalender",
"shortcut_contacts": "Kontaktid",
"unsupported_file": "Faili {name} ei saa siin avada.",
"open_failed": "Faili ei saanud avada. Proovi see uuesti avada.",
"share_gone": "See, mida jagasid, pole enam siin. Jaga seda palun uuesti.",
"share_files_lost": "Jagatud faile ei saanud alles hoida. Lisa need kirjale uuesti.",
"ics_needs_server": "Kalendrifaili importimiseks on vaja ühendust serveriga. Import algab, kui oled jälle võrgus.",
"contacts_need_server": "Kontaktide importimiseks on vaja ühendust serveriga. Proovi uuesti, kui oled jälle võrgus."
},
"sidebar_apps": {
"modal_title": "Külgriba rakendused",
"add_new": "Lisa rakendus",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "فعال",
"switching_account": "در حال تعویض حساب..."
},
"pwaLaunch": {
"shortcut_new_message": "پیام جدید",
"shortcut_calendar": "تقویم",
"shortcut_contacts": "مخاطبان",
"unsupported_file": "{name} اینجا باز نمی‌شود.",
"open_failed": "فایل باز نشد. دوباره آن را باز کنید.",
"share_gone": "چیزی که هم‌رسانی کردید دیگر اینجا نیست. لطفاً دوباره آن را هم‌رسانی کنید.",
"share_files_lost": "فایل‌های هم‌رسانی‌شده نگه داشته نشدند. دوباره آن‌ها را به پیام اضافه کنید.",
"ics_needs_server": "وارد کردن فایل تقویم به اتصال به سرور نیاز دارد. وقتی دوباره آنلاین شوید، وارد کردن شروع می‌شود.",
"contacts_need_server": "وارد کردن مخاطبان به اتصال به سرور نیاز دارد. وقتی دوباره آنلاین شدید، دوباره امتحان کنید."
},
"sidebar_apps": {
"modal_title": "برنامه‌های نوار کناری",
"add_new": "افزودن برنامه",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "Actif",
"switching_account": "Changement de compte..."
},
"pwaLaunch": {
"shortcut_new_message": "Nouveau message",
"shortcut_calendar": "Calendrier",
"shortcut_contacts": "Contacts",
"unsupported_file": "{name} ne peut pas être ouvert ici.",
"open_failed": "Le fichier n'a pas pu être ouvert. Essayez de l'ouvrir à nouveau.",
"share_gone": "Ce que vous avez partagé n'est plus disponible. Partagez-le à nouveau.",
"share_files_lost": "Les fichiers partagés n'ont pas pu être conservés. Ajoutez-les de nouveau au message.",
"ics_needs_server": "L'importation d'un fichier de calendrier nécessite une connexion au serveur. Elle démarrera dès que vous serez de nouveau en ligne.",
"contacts_need_server": "L'importation de contacts nécessite une connexion au serveur. Réessayez quand vous serez de nouveau en ligne."
},
"sidebar_apps": {
"modal_title": "Applications de la barre latérale",
"add_new": "Ajouter une application",
+11
View File
@@ -5272,6 +5272,17 @@
"active_account": "פעיל",
"switching_account": "החלפת חשבון…"
},
"pwaLaunch": {
"shortcut_new_message": "הודעה חדשה",
"shortcut_calendar": "לוח שנה",
"shortcut_contacts": "אנשי קשר",
"unsupported_file": "לא ניתן לפתוח כאן את {name}.",
"open_failed": "לא ניתן היה לפתוח את הקובץ. נסו לפתוח אותו שוב.",
"share_gone": "מה ששיתפתם כבר לא נמצא כאן. שתפו אותו שוב.",
"share_files_lost": "לא ניתן היה לשמור את הקבצים ששותפו. הוסיפו אותם שוב להודעה.",
"ics_needs_server": "ייבוא קובץ לוח שנה דורש חיבור לשרת. הייבוא יתחיל כשתחזרו להיות מחוברים.",
"contacts_need_server": "ייבוא אנשי קשר דורש חיבור לשרת. נסו שוב כשתחזרו להיות מחוברים."
},
"mailbox_context_menu": {
"mark_folder_read": "סמן תיקייה כקרויה",
"mark_folder_tree_read": "סמן תיקייה ותת־תיקיות כקרויות",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "Aktív",
"switching_account": "Fiók váltása..."
},
"pwaLaunch": {
"shortcut_new_message": "Új üzenet",
"shortcut_calendar": "Naptár",
"shortcut_contacts": "Névjegyek",
"unsupported_file": "A(z) {name} itt nem nyitható meg.",
"open_failed": "A fájlt nem sikerült megnyitni. Próbálja meg újra megnyitni.",
"share_gone": "Amit megosztott, már nincs itt. Kérjük, ossza meg újra.",
"share_files_lost": "A megosztott fájlokat nem sikerült megőrizni. Adja hozzá őket újra az üzenethez.",
"ics_needs_server": "A naptárfájl importálásához kapcsolat kell a szerverrel. Az importálás akkor indul el, amikor újra online lesz.",
"contacts_need_server": "A névjegyek importálásához kapcsolat kell a szerverrel. Próbálja újra, amikor ismét online lesz."
},
"sidebar_apps": {
"modal_title": "Oldalsáv alkalmazások",
"add_new": "Alkalmazás hozzáadása",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "Attivo",
"switching_account": "Cambio account..."
},
"pwaLaunch": {
"shortcut_new_message": "Nuovo messaggio",
"shortcut_calendar": "Calendario",
"shortcut_contacts": "Contatti",
"unsupported_file": "{name} non può essere aperto qui.",
"open_failed": "Non è stato possibile aprire il file. Prova ad aprirlo di nuovo.",
"share_gone": "Ciò che hai condiviso non è più disponibile. Condividilo di nuovo.",
"share_files_lost": "Non è stato possibile conservare i file condivisi. Aggiungili di nuovo al messaggio.",
"ics_needs_server": "Per importare un file di calendario serve una connessione al server. L'importazione inizierà quando tornerai online.",
"contacts_need_server": "Per importare i contatti serve una connessione al server. Riprova quando tornerai online."
},
"sidebar_apps": {
"modal_title": "App della barra laterale",
"add_new": "Aggiungi app",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "アクティブ",
"switching_account": "アカウントを切り替えています..."
},
"pwaLaunch": {
"shortcut_new_message": "新規メッセージ",
"shortcut_calendar": "カレンダー",
"shortcut_contacts": "連絡先",
"unsupported_file": "{name} はここでは開けません。",
"open_failed": "ファイルを開けませんでした。もう一度開いてください。",
"share_gone": "共有した内容はもうありません。もう一度共有してください。",
"share_files_lost": "共有したファイルを保持できませんでした。メッセージにもう一度追加してください。",
"ics_needs_server": "カレンダーファイルのインポートにはサーバーへの接続が必要です。オンラインに戻るとインポートが始まります。",
"contacts_need_server": "連絡先のインポートにはサーバーへの接続が必要です。オンラインに戻ってからもう一度お試しください。"
},
"sidebar_apps": {
"modal_title": "サイドバーアプリ",
"add_new": "アプリを追加",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "활성",
"switching_account": "계정 전환 중..."
},
"pwaLaunch": {
"shortcut_new_message": "새 메시지",
"shortcut_calendar": "캘린더",
"shortcut_contacts": "연락처",
"unsupported_file": "{name}은(는) 여기에서 열 수 없습니다.",
"open_failed": "파일을 열 수 없습니다. 다시 열어 보세요.",
"share_gone": "공유한 내용이 더 이상 없습니다. 다시 공유해 주세요.",
"share_files_lost": "공유한 파일을 보관할 수 없습니다. 메시지에 다시 추가해 주세요.",
"ics_needs_server": "캘린더 파일을 가져오려면 서버에 연결되어 있어야 합니다. 다시 온라인 상태가 되면 가져오기가 시작됩니다.",
"contacts_need_server": "연락처를 가져오려면 서버에 연결되어 있어야 합니다. 다시 온라인 상태가 되면 다시 시도해 주세요."
},
"sidebar_apps": {
"modal_title": "사이드바 앱",
"add_new": "앱 추가",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "Aktīvs",
"switching_account": "Notiek konta pārslēgšana..."
},
"pwaLaunch": {
"shortcut_new_message": "Jauna ziņa",
"shortcut_calendar": "Kalendārs",
"shortcut_contacts": "Kontakti",
"unsupported_file": "Failu {name} šeit nevar atvērt.",
"open_failed": "Failu neizdevās atvērt. Mēģiniet to atvērt vēlreiz.",
"share_gone": "Tas, ko kopīgojāt, šeit vairs nav. Lūdzu, kopīgojiet to vēlreiz.",
"share_files_lost": "Kopīgotos failus neizdevās saglabāt. Pievienojiet tos ziņai vēlreiz.",
"ics_needs_server": "Kalendāra faila importēšanai vajadzīgs savienojums ar serveri. Importēšana sāksies, kad atkal būsiet tiešsaistē.",
"contacts_need_server": "Kontaktu importēšanai vajadzīgs savienojums ar serveri. Mēģiniet vēlreiz, kad atkal būsiet tiešsaistē."
},
"sidebar_apps": {
"modal_title": "Sānu joslas lietotnes",
"add_new": "Pievienot lietotni",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "Идэвхтэй",
"switching_account": "Бүртгэл сэлгэж байна..."
},
"pwaLaunch": {
"shortcut_new_message": "Шинэ зурвас",
"shortcut_calendar": "Хуанли",
"shortcut_contacts": "Харилцагчид",
"unsupported_file": "{name}-г энд нээх боломжгүй.",
"open_failed": "Файлыг нээж чадсангүй. Дахин нээж үзнэ үү.",
"share_gone": "Таны хуваалцсан зүйл энд байхгүй болсон. Дахин хуваалцана уу.",
"share_files_lost": "Хуваалцсан файлуудыг хадгалж чадсангүй. Зурваст дахин нэмнэ үү.",
"ics_needs_server": "Хуанлийн файл импортлоход сервертэй холболт хэрэгтэй. Та дахин онлайн болоход импорт эхэлнэ.",
"contacts_need_server": "Харилцагчдыг импортлоход сервертэй холболт хэрэгтэй. Дахин онлайн болсон үедээ оролдоно уу."
},
"sidebar_apps": {
"modal_title": "Sidebar програмууд",
"add_new": "Апп нэмэх",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "Aktiv",
"switching_account": "Bytter konto..."
},
"pwaLaunch": {
"shortcut_new_message": "Ny melding",
"shortcut_calendar": "Kalender",
"shortcut_contacts": "Kontakter",
"unsupported_file": "{name} kan ikke åpnes her.",
"open_failed": "Filen kunne ikke åpnes. Prøv å åpne den på nytt.",
"share_gone": "Det du delte, er ikke her lenger. Del det på nytt.",
"share_files_lost": "De delte filene kunne ikke tas vare på. Legg dem til i meldingen på nytt.",
"ics_needs_server": "Import av en kalenderfil krever tilkobling til serveren. Importen starter når du er på nett igjen.",
"contacts_need_server": "Import av kontakter krever tilkobling til serveren. Prøv igjen når du er på nett igjen."
},
"sidebar_apps": {
"modal_title": "Apper i sidefeltet",
"add_new": "Legg til app",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "Actief",
"switching_account": "Account wisselen..."
},
"pwaLaunch": {
"shortcut_new_message": "Nieuw bericht",
"shortcut_calendar": "Agenda",
"shortcut_contacts": "Contacten",
"unsupported_file": "{name} kan hier niet worden geopend.",
"open_failed": "Het bestand kon niet worden geopend. Probeer het opnieuw te openen.",
"share_gone": "Wat je hebt gedeeld, is er niet meer. Deel het opnieuw.",
"share_files_lost": "De gedeelde bestanden konden niet worden bewaard. Voeg ze opnieuw aan het bericht toe.",
"ics_needs_server": "Voor het importeren van een agendabestand is een verbinding met de server nodig. Het importeren begint zodra je weer online bent.",
"contacts_need_server": "Voor het importeren van contacten is een verbinding met de server nodig. Probeer het opnieuw als je weer online bent."
},
"sidebar_apps": {
"modal_title": "Zijbalk-apps",
"add_new": "App toevoegen",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "Aktywne",
"switching_account": "Przełączanie konta..."
},
"pwaLaunch": {
"shortcut_new_message": "Nowa wiadomość",
"shortcut_calendar": "Kalendarz",
"shortcut_contacts": "Kontakty",
"unsupported_file": "Nie można tu otworzyć pliku {name}.",
"open_failed": "Nie udało się otworzyć pliku. Spróbuj otworzyć go ponownie.",
"share_gone": "To, co udostępniono, już tu nie istnieje. Udostępnij to ponownie.",
"share_files_lost": "Nie udało się zachować udostępnionych plików. Dodaj je ponownie do wiadomości.",
"ics_needs_server": "Import pliku kalendarza wymaga połączenia z serwerem. Import zacznie się, gdy znów będziesz online.",
"contacts_need_server": "Import kontaktów wymaga połączenia z serwerem. Spróbuj ponownie, gdy znów będziesz online."
},
"sidebar_apps": {
"modal_title": "Aplikacje paska bocznego",
"add_new": "Dodaj aplikację",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "Ativa",
"switching_account": "Alternando conta..."
},
"pwaLaunch": {
"shortcut_new_message": "Nova mensagem",
"shortcut_calendar": "Calendário",
"shortcut_contacts": "Contactos",
"unsupported_file": "Não é possível abrir {name} aqui.",
"open_failed": "Não foi possível abrir o ficheiro. Tente abri-lo novamente.",
"share_gone": "O que partilhou já não está aqui. Partilhe novamente.",
"share_files_lost": "Não foi possível guardar os ficheiros partilhados. Adicione-os novamente à mensagem.",
"ics_needs_server": "Importar um ficheiro de calendário precisa de uma ligação ao servidor. A importação começa quando voltar a estar online.",
"contacts_need_server": "Importar contactos precisa de uma ligação ao servidor. Tente novamente quando voltar a estar online."
},
"sidebar_apps": {
"modal_title": "Apps da barra lateral",
"add_new": "Adicionar app",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "Activ",
"switching_account": "Se schimbă contul..."
},
"pwaLaunch": {
"shortcut_new_message": "Mesaj nou",
"shortcut_calendar": "Calendar",
"shortcut_contacts": "Contacte",
"unsupported_file": "{name} nu poate fi deschis aici.",
"open_failed": "Fișierul nu a putut fi deschis. Încercați să îl deschideți din nou.",
"share_gone": "Ce ați partajat nu mai este aici. Partajați din nou.",
"share_files_lost": "Fișierele partajate nu au putut fi păstrate. Adăugați-le din nou la mesaj.",
"ics_needs_server": "Importul unui fișier de calendar are nevoie de conexiune la server. Importul începe când reveniți online.",
"contacts_need_server": "Importul contactelor are nevoie de conexiune la server. Încercați din nou când reveniți online."
},
"sidebar_apps": {
"modal_title": "Aplicații din bara laterală",
"add_new": "Adăugați aplicația",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "Активен",
"switching_account": "Переключение аккаунта..."
},
"pwaLaunch": {
"shortcut_new_message": "Новое письмо",
"shortcut_calendar": "Календарь",
"shortcut_contacts": "Контакты",
"unsupported_file": "{name} нельзя открыть здесь.",
"open_failed": "Не удалось открыть файл. Попробуйте открыть его ещё раз.",
"share_gone": "То, чем вы поделились, больше недоступно. Поделитесь ещё раз.",
"share_files_lost": "Не удалось сохранить полученные файлы. Добавьте их в письмо ещё раз.",
"ics_needs_server": "Для импорта файла календаря нужно подключение к серверу. Импорт начнётся, когда вы снова будете в сети.",
"contacts_need_server": "Для импорта контактов нужно подключение к серверу. Попробуйте ещё раз, когда снова будете в сети."
},
"sidebar_apps": {
"modal_title": "Приложения боковой панели",
"add_new": "Добавить приложение",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "Aktívny",
"switching_account": "Prepínanie účtu..."
},
"pwaLaunch": {
"shortcut_new_message": "Nová správa",
"shortcut_calendar": "Kalendár",
"shortcut_contacts": "Kontakty",
"unsupported_file": "{name} sa tu nedá otvoriť.",
"open_failed": "Súbor sa nepodarilo otvoriť. Skúste ho otvoriť znova.",
"share_gone": "To, čo ste zdieľali, tu už nie je. Zdieľajte to prosím znova.",
"share_files_lost": "Zdieľané súbory sa nepodarilo uchovať. Pridajte ich do správy znova.",
"ics_needs_server": "Import súboru kalendára vyžaduje pripojenie k serveru. Import sa začne, keď budete znova online.",
"contacts_need_server": "Import kontaktov vyžaduje pripojenie k serveru. Skúste to znova, keď budete online."
},
"sidebar_apps": {
"modal_title": "Aplikácie postranného panela",
"add_new": "Pridať aplikáciu",
+11
View File
@@ -2382,6 +2382,17 @@
"active_account": "ใช้งานอยู่",
"switching_account": "กำลังสลับบัญชี..."
},
"pwaLaunch": {
"shortcut_new_message": "ข้อความใหม่",
"shortcut_calendar": "ปฏิทิน",
"shortcut_contacts": "รายชื่อติดต่อ",
"unsupported_file": "ไม่สามารถเปิด {name} ที่นี่ได้",
"open_failed": "เปิดไฟล์ไม่ได้ ลองเปิดอีกครั้ง",
"share_gone": "สิ่งที่คุณแชร์ไม่อยู่ที่นี่แล้ว โปรดแชร์อีกครั้ง",
"share_files_lost": "เก็บไฟล์ที่แชร์ไว้ไม่ได้ โปรดเพิ่มไฟล์ลงในข้อความอีกครั้ง",
"ics_needs_server": "การนำเข้าไฟล์ปฏิทินต้องเชื่อมต่อกับเซิร์ฟเวอร์ การนำเข้าจะเริ่มเมื่อคุณกลับมาออนไลน์",
"contacts_need_server": "การนำเข้ารายชื่อติดต่อต้องเชื่อมต่อกับเซิร์ฟเวอร์ โปรดลองอีกครั้งเมื่อคุณกลับมาออนไลน์"
},
"sidebar_apps": {
"modal_title": "แอปพลิเคชันแถบด้านข้าง",
"add_new": "เพิ่มแอป",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "Etkin",
"switching_account": "Hesap değiştiriliyor..."
},
"pwaLaunch": {
"shortcut_new_message": "Yeni ileti",
"shortcut_calendar": "Takvim",
"shortcut_contacts": "Kişiler",
"unsupported_file": "{name} burada açılamıyor.",
"open_failed": "Dosya açılamadı. Yeniden açmayı deneyin.",
"share_gone": "Paylaştığınız içerik artık burada değil. Lütfen yeniden paylaşın.",
"share_files_lost": "Paylaşılan dosyalar saklanamadı. Onları iletiye yeniden ekleyin.",
"ics_needs_server": "Takvim dosyasını içe aktarmak için sunucu bağlantısı gerekir. İçe aktarma, yeniden çevrimiçi olduğunuzda başlar.",
"contacts_need_server": "Kişileri içe aktarmak için sunucu bağlantısı gerekir. Yeniden çevrimiçi olduğunuzda tekrar deneyin."
},
"sidebar_apps": {
"modal_title": "Kenar çubuğu uygulamaları",
"add_new": "Uygulama ekle",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "Активний",
"switching_account": "Перемикання акаунта..."
},
"pwaLaunch": {
"shortcut_new_message": "Новий лист",
"shortcut_calendar": "Календар",
"shortcut_contacts": "Контакти",
"unsupported_file": "{name} не можна відкрити тут.",
"open_failed": "Не вдалося відкрити файл. Спробуйте відкрити його ще раз.",
"share_gone": "Те, чим ви поділилися, більше недоступне. Поділіться ще раз.",
"share_files_lost": "Не вдалося зберегти отримані файли. Додайте їх до листа ще раз.",
"ics_needs_server": "Для імпорту файлу календаря потрібне підключення до сервера. Імпорт почнеться, коли ви знову будете в мережі.",
"contacts_need_server": "Для імпорту контактів потрібне підключення до сервера. Спробуйте ще раз, коли знову будете в мережі."
},
"sidebar_apps": {
"modal_title": "Програми бічної панелі",
"add_new": "Додати додаток",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "Đang hoạt động",
"switching_account": "Đang chuyển đổi tài khoản..."
},
"pwaLaunch": {
"shortcut_new_message": "Thư mới",
"shortcut_calendar": "Lịch",
"shortcut_contacts": "Danh bạ",
"unsupported_file": "Không thể mở {name} tại đây.",
"open_failed": "Không thể mở tệp. Hãy thử mở lại.",
"share_gone": "Nội dung bạn đã chia sẻ không còn ở đây. Vui lòng chia sẻ lại.",
"share_files_lost": "Không thể giữ các tệp đã chia sẻ. Hãy thêm lại chúng vào thư.",
"ics_needs_server": "Nhập tệp lịch cần kết nối với máy chủ. Quá trình nhập sẽ bắt đầu khi bạn trực tuyến trở lại.",
"contacts_need_server": "Nhập danh bạ cần kết nối với máy chủ. Hãy thử lại khi bạn trực tuyến trở lại."
},
"sidebar_apps": {
"modal_title": "Ứng dụng thanh bên",
"add_new": "Thêm ứng dụng",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "使用中",
"switching_account": "正在切換帳號…"
},
"pwaLaunch": {
"shortcut_new_message": "新郵件",
"shortcut_calendar": "行事曆",
"shortcut_contacts": "聯絡人",
"unsupported_file": "無法在此處開啟 {name}。",
"open_failed": "無法開啟檔案。請再次嘗試開啟。",
"share_gone": "您分享的內容已不在此處。請重新分享。",
"share_files_lost": "無法保留分享的檔案。請將它們重新加入郵件。",
"ics_needs_server": "匯入行事曆檔案需要連線到伺服器。您恢復連線後將開始匯入。",
"contacts_need_server": "匯入聯絡人需要連線到伺服器。請在恢復連線後再試一次。"
},
"sidebar_apps": {
"modal_title": "側邊欄應用程式",
"add_new": "新增應用程式",
+11
View File
@@ -249,6 +249,17 @@
"active_account": "活动",
"switching_account": "正在切换账户..."
},
"pwaLaunch": {
"shortcut_new_message": "新邮件",
"shortcut_calendar": "日历",
"shortcut_contacts": "联系人",
"unsupported_file": "无法在此处打开 {name}。",
"open_failed": "无法打开文件。请再次尝试打开。",
"share_gone": "您分享的内容已不在此处。请重新分享。",
"share_files_lost": "无法保留分享的文件。请将它们重新添加到邮件中。",
"ics_needs_server": "导入日历文件需要连接到服务器。您恢复联网后将开始导入。",
"contacts_need_server": "导入联系人需要连接到服务器。请在恢复联网后重试。"
},
"sidebar_apps": {
"modal_title": "侧边栏应用",
"add_new": "添加应用",
+55 -1
View File
@@ -108,7 +108,7 @@ if (typeof caches !== "undefined" && caches && typeof caches.open === "function"
readOfflineShell();
}
const APP_SECTIONS = new Set(["", "mail", "settings", "calendar", "contacts", "files", "pro", "apps"]);
const APP_SECTIONS = new Set(["", "mail", "settings", "calendar", "contacts", "files", "pro", "apps", "protocol"]);
/** The page path inside the app ("/mail/inbox"), or null for anything that isn't a page of the app. */
function appPagePath(url) {
@@ -311,7 +311,61 @@ async function deleteStaleShellCaches() {
}
}
// ---- Share target: what the OS shares goes into a new draft ----
//
// The manifest's share_target POSTs here. Files can't travel through a
// redirect, so they wait in the Cache API until the page takes them (same
// layout as lib/launch-handoff.ts, which also removes them). Without a worker
// the POST reaches app/(main)/protocol/share/route.ts.
const HANDOFF_CACHE = "bulwark-launch-handoff-v1";
function isShareTarget(request) {
if (request.method !== "POST" || request.mode !== "navigate") return false;
const url = new URL(request.url);
return url.origin === self.location.origin && url.pathname.replace(/\/+$/, "") === BASE_PATH + "/protocol/share";
}
async function receiveShare(request) {
const land = (params) => Response.redirect(self.location.origin + BASE_PATH + "/?" + new URLSearchParams(params), 303);
let form;
try {
form = await request.formData();
} catch (_) {
return land({ share: "unavailable", share_files: "1" });
}
const field = (name) => {
const value = form.get(name);
return typeof value === "string" ? value : "";
};
const shared = { title: field("title"), text: field("text"), url: field("url") };
const files = form.getAll("files").filter((file) => typeof file !== "string" && file.size > 0);
const id = crypto.randomUUID();
const prefix = "/__bulwark/handoff/" + id + "/";
let cache = null;
try {
cache = await caches.open(HANDOFF_CACHE);
const list = files.map((file) => ({ name: file.name, type: file.type }));
await cache.put(prefix + "meta", new Response(JSON.stringify({ kind: "compose", createdAt: Date.now(), ...shared, files: list })));
await Promise.all(files.map((file, index) => cache.put(prefix + index, new Response(file))));
return land({ share: id });
} catch (_) {
// No room for the files. The text can still come along in the address.
if (cache) await Promise.all([...files.map((_file, index) => cache.delete(prefix + index)), cache.delete(prefix + "meta")]).catch(() => {});
return land({
share: "unavailable",
share_title: shared.title.slice(0, 300),
share_text: shared.text.slice(0, 4000),
share_url: shared.url.slice(0, 2000),
...(files.length > 0 ? { share_files: "1" } : {}),
});
}
}
self.addEventListener("fetch", (event) => {
if (isShareTarget(event.request)) {
event.respondWith(receiveShare(event.request));
return;
}
// Offline mode off (known): the network, untouched, as without a worker.
if (offlineShellOn === false) return;
const route = offlineRoute(event.request);
+2
View File
@@ -65,6 +65,8 @@ export interface ProComposeTabData {
initialData?: ComposerDraftData | null;
/** Fields a plugin filled in through onBeforeCompose; dropped on the first state save. */
prefill?: ComposePrefill | null;
/** Files from a share or a launch, attached when the composer opens. */
initialFiles?: File[] | null;
sourceEmailId?: string | null;
/** The login and account of that message, for its $answered / $forwarded flag. */
sourceEmailSource?: EmailWriteSource | null;