From c65d18eaaeb23253eab7bedf19d221f5534df479 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:26:01 +0100 Subject: [PATCH 1/3] 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. --- app/(main)/protocol/share/route.ts | 51 +++ app/manifest.ts | 61 ++- .../ical-import-initial-file.test.tsx | 110 +++++ components/calendar/calendar-app.tsx | 15 + components/calendar/ical-import-modal.tsx | 51 ++- .../contact-import-initial-file.test.tsx | 62 +++ components/contacts/contact-import-dialog.tsx | 26 +- components/contacts/contacts-app.tsx | 18 + .../composer-attach-from-files.test.tsx | 56 +++ components/email/email-composer.tsx | 16 + components/mail/mail-app.tsx | 75 +++- components/pro/pro-compose-tab-body.tsx | 1 + .../protocol-launch-handler-provider.test.tsx | 287 ++++++++++++++ components/protocol/launch-file-host.tsx | 62 +++ .../protocol-launch-handler-provider.tsx | 98 ++++- hooks/use-handoff-files.ts | 28 ++ lib/__tests__/launch-handoff.test.ts | 346 ++++++++++++++++ lib/__tests__/manifest-launch.test.ts | 200 ++++++++++ lib/__tests__/sign-out-cleanup.test.ts | 8 + lib/__tests__/sw-harness.ts | 6 +- lib/__tests__/sw-share-target.test.ts | 152 +++++++ lib/launch-handoff.ts | 375 ++++++++++++++++++ lib/offline/__tests__/shell-pages.test.ts | 26 ++ lib/offline/shell.ts | 2 +- lib/sign-out-cleanup.ts | 3 + locales/ar/common.json | 11 + locales/ca/common.json | 11 + locales/cs/common.json | 11 + locales/da/common.json | 11 + locales/de/common.json | 11 + locales/en/common.json | 11 + locales/es/common.json | 11 + locales/et/common.json | 11 + locales/fa/common.json | 11 + locales/fr/common.json | 11 + locales/he/common.json | 11 + locales/hu/common.json | 11 + locales/it/common.json | 11 + locales/ja/common.json | 11 + locales/ko/common.json | 11 + locales/lv/common.json | 11 + locales/mn/common.json | 11 + locales/nb/common.json | 11 + locales/nl/common.json | 11 + locales/pl/common.json | 11 + locales/pt/common.json | 11 + locales/ro/common.json | 11 + locales/ru/common.json | 11 + locales/sk/common.json | 11 + locales/th/common.json | 11 + locales/tr/common.json | 11 + locales/uk/common.json | 11 + locales/vi/common.json | 11 + locales/zh-TW/common.json | 11 + locales/zh/common.json | 11 + public/sw.js | 56 ++- stores/pro-tab-store.ts | 2 + 57 files changed, 2490 insertions(+), 33 deletions(-) create mode 100644 app/(main)/protocol/share/route.ts create mode 100644 components/calendar/__tests__/ical-import-initial-file.test.tsx create mode 100644 components/contacts/__tests__/contact-import-initial-file.test.tsx create mode 100644 components/protocol/__tests__/protocol-launch-handler-provider.test.tsx create mode 100644 components/protocol/launch-file-host.tsx create mode 100644 hooks/use-handoff-files.ts create mode 100644 lib/__tests__/launch-handoff.test.ts create mode 100644 lib/__tests__/manifest-launch.test.ts create mode 100644 lib/__tests__/sw-share-target.test.ts create mode 100644 lib/launch-handoff.ts create mode 100644 lib/offline/__tests__/shell-pages.test.ts diff --git a/app/(main)/protocol/share/route.ts b/app/(main)/protocol/share/route.ts new file mode 100644 index 000000000..ea0ed58c5 --- /dev/null +++ b/app/(main)/protocol/share/route.ts @@ -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 { + 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(""); +} diff --git a/app/manifest.ts b/app/manifest.ts index 1a06f3b58..53376bc12 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -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; +}; + +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 { await configManager.ensureLoaded(); - const host = pickRequestHost(await headers()); + const requestHeaders = await headers(); + const host = pickRequestHost(requestHeaders); const domainOverrides = matchDomainBranding( host, parseDomainBranding(configManager.get("domainBranding", [])), @@ -83,6 +106,17 @@ export default async function manifest(): Promise { { 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 { { 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"], }, diff --git a/components/calendar/__tests__/ical-import-initial-file.test.tsx b/components/calendar/__tests__/ical-import-initial-file.test.tsx new file mode 100644 index 000000000..72a8959aa --- /dev/null +++ b/components/calendar/__tests__/ical-import-initial-file.test.tsx @@ -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 }) => 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 }) =>
{children}
, +})); + +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 = {}) { + 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; parseCalendarEvents: ReturnType }; +} + +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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + await screen.findByText('Standup'); + view.rerender( + , + ); + await waitFor(() => expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe('b')); + expect(importEvents).not.toHaveBeenCalled(); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 1bccba921..00e4a0c1d 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -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 && ( + + )} + {showSubscriptionModal && client && ( 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(null); const urlInputRef = useRef(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(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) => { const file = e.target.files?.[0]; diff --git a/components/contacts/__tests__/contact-import-initial-file.test.tsx b/components/contacts/__tests__/contact-import-initial-file.test.tsx new file mode 100644 index 000000000..2a424dedc --- /dev/null +++ b/components/contacts/__tests__/contact-import-initial-file.test.tsx @@ -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(); + expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument(); + }); + + it('parses it offline as well', async () => { + useOfflineStore.setState({ browserOffline: true }); + render(); + 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(); + expect(await screen.findByText('import.file_too_large')).toBeInTheDocument(); + }); + + it('shows the file input without one', async () => { + const { container } = render(); + 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(); + 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(); + await screen.findByText('Ada Lovelace'); + fireEvent.click(screen.getByRole('button', { name: 'import.import_button' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('import.failed')); + }); +}); diff --git a/components/contacts/contact-import-dialog.tsx b/components/contacts/contact-import-dialog.tsx index 91c2dbc64..dca624055 100644 --- a/components/contacts/contact-import-dialog.tsx +++ b/components/contacts/contact-import-dialog.tsx @@ -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; 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(null); const [parsed, setParsed] = useState([]); const [selected, setSelected] = useState>(new Set()); @@ -30,10 +35,7 @@ export function ContactImportDialog({ const [result, setResult] = useState(null); const [error, setError] = useState(null); - const handleFileChange = useCallback(async (e: React.ChangeEvent) => { - 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) => { + 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); } diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index 1b371b44a..0f9da0954 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -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 && ( + + )} {showImportDialog && ( { + 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(); + expect(await screen.findByText('shared.pdf')).toBeInTheDocument(); + }); + + it('waits for the client', async () => { + const files = [new File(['x'], 'shared.pdf')]; + const view = render(); + await act(async () => {}); + expect(hoisted.beforeAttachmentUpload).not.toHaveBeenCalled(); + + useAuthStore.setState({ client: uploading() as never }); + view.rerender(); + 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(); + await screen.findByText('shared.pdf'); + first.unmount(); + + render(); + 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(); + 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(); + 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 }); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 77163d04b..93932e246 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -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(); + 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) => { if (!event.target.files) return; await addFiles(Array.from(event.target.files)); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index c69d7c2c7..52f90a2f6 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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(null); + const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState(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(null); const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false); const markAsReadTimeoutRef = useRef(null); const lastUndoToastSubmissionRef = useRef(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) => { diff --git a/components/pro/pro-compose-tab-body.tsx b/components/pro/pro-compose-tab-body.tsx index a49e0f2df..9a3b51ae1 100644 --- a/components/pro/pro-compose-tab-body.tsx +++ b/components/pro/pro-compose-tab-body.tsx @@ -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} diff --git a/components/protocol/__tests__/protocol-launch-handler-provider.test.tsx b/components/protocol/__tests__/protocol-launch-handler-provider.test.tsx new file mode 100644 index 000000000..49c451447 --- /dev/null +++ b/components/protocol/__tests__/protocol-launch-handler-provider.test.tsx @@ -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 ( +
+ {name} + + +
+ ); + }, +})); + +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 }> }) => 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(
app
); +} + +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(
app
); + 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(
app
); + 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([]); + }); +}); diff --git a/components/protocol/launch-file-host.tsx b/components/protocol/launch-file-host.tsx new file mode 100644 index 000000000..4c18d2fd5 --- /dev/null +++ b/components/protocol/launch-file-host.tsx @@ -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>; + +/** + * 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(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 ( + + ); +} diff --git a/components/protocol/protocol-launch-handler-provider.tsx b/components/protocol/protocol-launch-handler-provider.tsx index 885203711..69dcf5eb3 100644 --- a/components/protocol/protocol-launch-handler-provider.tsx +++ b/components/protocol/protocol-launch-handler-provider.tsx @@ -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 }>; +}; 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> = { + 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()); + + // 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} + + + ); } diff --git a/hooks/use-handoff-files.ts b/hooks/use-handoff-files.ts new file mode 100644 index 000000000..9ef0eea8e --- /dev/null +++ b/hooks/use-handoff-files.ts @@ -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([]); + + 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 }; +} diff --git a/lib/__tests__/launch-handoff.test.ts b/lib/__tests__/launch-handoff.test.ts new file mode 100644 index 000000000..5b21f7abe --- /dev/null +++ b/lib/__tests__/launch-handoff.test.ts @@ -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(); + 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); + }); +}); diff --git a/lib/__tests__/manifest-launch.test.ts b/lib/__tests__/manifest-launch.test.ts new file mode 100644 index 000000000..124f67b55 --- /dev/null +++ b/lib/__tests__/manifest-launch.test.ts @@ -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, + config: {} as Record, + sources: {} as Record, +})); + +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 }>; + share_target: { action: string; method: string; enctype: string; params: Record }; + protocol_handlers: Array<{ protocol: string; url: string }>; +}; + +async function load(): Promise { + 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/'); + }); +}); diff --git a/lib/__tests__/sign-out-cleanup.test.ts b/lib/__tests__/sign-out-cleanup.test.ts index 270fc1606..355f623e6 100644 --- a/lib/__tests__/sign-out-cleanup.test.ts +++ b/lib/__tests__/sign-out-cleanup.test.ts @@ -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', diff --git a/lib/__tests__/sw-harness.ts b/lib/__tests__/sw-harness.ts index d54631a62..3f3479d40 100644 --- a/lib/__tests__/sw-harness.ts +++ b/lib/__tests__/sw-harness.ts @@ -29,17 +29,17 @@ export class FakeCaches { } } -export function loadWorker(opts: { buildId: string; caches: FakeCaches; clients: Array<{ id: string }>; fetch?: (input: unknown) => Promise }) { +export function loadWorker(opts: { buildId: string; caches: FakeCaches; clients: Array<{ id: string }>; fetch?: (input: unknown) => Promise; location?: string }) { const listeners = new Map 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 = {}) { const waits: Promise[] = []; diff --git a/lib/__tests__/sw-share-target.test.ts b/lib/__tests__/sw-share-target.test.ts new file mode 100644 index 000000000..799517e37 --- /dev/null +++ b/lib/__tests__/sw-share-target.test.ts @@ -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 = {}) { + 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; + 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('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('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('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('start'); + }); +}); diff --git a/lib/launch-handoff.ts b/lib/launch-handoff.ts new file mode 100644 index 000000000..a072cf0ac --- /dev/null +++ b/lib/launch-handoff.ts @@ -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>(); +const kindListeners = new Map>(); +const anyListeners = new Set(); +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 { + 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 { + 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 }>, +): Promise<{ unsupported: string[]; failed: number }> { + const byKind = new Map(); + 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(); + 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 { + 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 { + 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; +} diff --git a/lib/offline/__tests__/shell-pages.test.ts b/lib/offline/__tests__/shell-pages.test.ts new file mode 100644 index 000000000..20a8e3716 --- /dev/null +++ b/lib/offline/__tests__/shell-pages.test.ts @@ -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'])); + }); +}); diff --git a/lib/offline/shell.ts b/lib/offline/shell.ts index 88aa07b4e..295bdc325 100644 --- a/lib/offline/shell.ts +++ b/lib/offline/shell.ts @@ -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 diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts index 2b00aaf51..fbdd44507 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -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(); diff --git a/locales/ar/common.json b/locales/ar/common.json index cc8420d14..8bc955352 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -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": "إضافة تطبيق", diff --git a/locales/ca/common.json b/locales/ca/common.json index 6e15f53fc..57dfafc11 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -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ó", diff --git a/locales/cs/common.json b/locales/cs/common.json index d8426dce2..b564e4a67 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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", diff --git a/locales/da/common.json b/locales/da/common.json index cf66a7294..9935f289d 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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", diff --git a/locales/de/common.json b/locales/de/common.json index 3772dbab8..0466fccf5 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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", diff --git a/locales/en/common.json b/locales/en/common.json index 57a58dab4..b87b0ec93 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/es/common.json b/locales/es/common.json index cc786a268..6a5f1f06f 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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", diff --git a/locales/et/common.json b/locales/et/common.json index 2743f7f83..4353289be 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -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", diff --git a/locales/fa/common.json b/locales/fa/common.json index 539f81c1a..9b939d48b 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -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": "افزودن برنامه", diff --git a/locales/fr/common.json b/locales/fr/common.json index fb7d148ec..22b8087bf 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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", diff --git a/locales/he/common.json b/locales/he/common.json index 73d75426a..95af29824 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -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": "סמן תיקייה ותת־תיקיות כקרויות", diff --git a/locales/hu/common.json b/locales/hu/common.json index b45fe495e..0564585b8 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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", diff --git a/locales/it/common.json b/locales/it/common.json index a021b7397..a82800289 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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", diff --git a/locales/ja/common.json b/locales/ja/common.json index f2ea41909..0e567d3ca 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -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": "アプリを追加", diff --git a/locales/ko/common.json b/locales/ko/common.json index cbde91d32..51cfc0fed 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -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": "앱 추가", diff --git a/locales/lv/common.json b/locales/lv/common.json index 02d30c2f9..60fbbb89b 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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", diff --git a/locales/mn/common.json b/locales/mn/common.json index 4dd86dea2..649c9e7ac 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -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": "Апп нэмэх", diff --git a/locales/nb/common.json b/locales/nb/common.json index d98bd1756..0ddf770f4 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -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", diff --git a/locales/nl/common.json b/locales/nl/common.json index 4d541f358..ab2c81582 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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", diff --git a/locales/pl/common.json b/locales/pl/common.json index a6a7cd520..1fd914ac1 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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ę", diff --git a/locales/pt/common.json b/locales/pt/common.json index 685a98aea..aca70eda7 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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", diff --git a/locales/ro/common.json b/locales/ro/common.json index 45c3fefed..31a66a763 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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", diff --git a/locales/ru/common.json b/locales/ru/common.json index b40069b61..8790a5cf5 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -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": "Добавить приложение", diff --git a/locales/sk/common.json b/locales/sk/common.json index aa0304452..c186d6754 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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", diff --git a/locales/th/common.json b/locales/th/common.json index 4d2c52754..fadbbffd1 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -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": "เพิ่มแอป", diff --git a/locales/tr/common.json b/locales/tr/common.json index 4f25997fa..ff89c598e 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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", diff --git a/locales/uk/common.json b/locales/uk/common.json index 408e920da..8d6f02fc1 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -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": "Додати додаток", diff --git a/locales/vi/common.json b/locales/vi/common.json index 6952aec2e..f8f43adae 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -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", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 1236173cf..391355f2e 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -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": "新增應用程式", diff --git a/locales/zh/common.json b/locales/zh/common.json index f51d10c28..2fabc2fe3 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -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": "添加应用", diff --git a/public/sw.js b/public/sw.js index 3dc7ff7b8..6a313d86e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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); diff --git a/stores/pro-tab-store.ts b/stores/pro-tab-store.ts index ecc393c05..36051d2a8 100644 --- a/stores/pro-tab-store.ts +++ b/stores/pro-tab-store.ts @@ -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; From c02103164206b67b85d1687203f3eff123345f8b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:26:03 +0100 Subject: [PATCH 2/3] test: cover app launches and shares through the service worker on both servers --- integration/tests/23-do-pwa-launch.spec.ts | 444 +++++++++++++++++++++ 1 file changed, 444 insertions(+) create mode 100644 integration/tests/23-do-pwa-launch.spec.ts diff --git a/integration/tests/23-do-pwa-launch.spec.ts b/integration/tests/23-do-pwa-launch.spec.ts new file mode 100644 index 000000000..dd898c0e9 --- /dev/null +++ b/integration/tests/23-do-pwa-launch.spec.ts @@ -0,0 +1,444 @@ +import { test, expect, type Browser, type BrowserContext, type Page } from '@playwright/test'; +import { accountSwitcher, neutralizeDevOverlay } from './helpers/app'; +import { ENT, ENT_PASSWORD, adminContext, contextOptions, getPolicy, putPolicy, requireEntEnv, shot } from './helpers/ent'; + +/** + * The installed app's launches through the real UI: shortcuts, files opened + * from the OS, and shares. Chromium can't fire launchQueue from a test, so a + * stand-in catches the consumer the app registers, and the spec calls that + * consumer the way the browser would. Shares go through the real service + * worker as a real form POST. Needs a production build, the only one that + * registers the worker. + * + * BUILD=1 bash desktop-offline-rig/start-prod.sh 3230 pwa-launch-1.0 + * bash desktop-offline-rig/run-spec.sh 3230 1.0 integration/tests/23-do-pwa-launch.spec.ts + */ + +test.describe.configure({ mode: 'serial' }); +test.setTimeout(180_000); + +const RUN = Date.now().toString(36); +const composer = (page: Page) => page.locator('[data-testid="email-composer"]').first(); +const SHARE_CACHE = 'bulwark-launch-handoff-v1'; + +type LaunchFile = { name: string; type: string; body: string }; + +const EML: LaunchFile = { + name: 'note.eml', + type: 'message/rfc822', + body: [ + 'From: Bob ', + 'To: Alice ', + `Subject: Launched message ${RUN}`, + 'Date: Tue, 03 Nov 2026 10:00:00 +0000', + 'Content-Type: text/plain; charset=utf-8', + '', + 'Opened from the file system.', + '', + ].join('\r\n'), +}; + +const ICS: LaunchFile = { + name: 'invite.ics', + type: 'text/calendar', + body: [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Bulwark//Test//EN', + 'BEGIN:VEVENT', + `UID:launch-${RUN}@example.org`, + 'DTSTAMP:20261103T100000Z', + 'DTSTART:20261110T100000Z', + 'DTEND:20261110T110000Z', + `SUMMARY:Launched event ${RUN}`, + 'END:VEVENT', + 'END:VCALENDAR', + '', + ].join('\r\n'), +}; + +const VCF: LaunchFile = { + name: 'people.vcf', + type: 'text/vcard', + body: ['BEGIN:VCARD', 'VERSION:3.0', `FN:Launched Person ${RUN}`, `EMAIL:launched-${RUN}@example.org`, 'END:VCARD', ''].join('\r\n'), +}; + +/** Stands in for the browser's launchQueue, keeping the consumer the app sets. */ +function catchLaunchConsumer() { + Object.defineProperty(window, 'launchQueue', { + configurable: true, + value: { + setConsumer(fn: unknown) { + (window as unknown as { __launch: unknown }).__launch = fn; + }, + }, + }); +} + +async function launch(page: Page, opts: { target?: string; files?: LaunchFile[] }): Promise { + await page.waitForFunction(() => typeof (window as unknown as { __launch?: unknown }).__launch === 'function'); + await page.evaluate(({ target, files }) => { + const consumer = (window as unknown as { __launch: (params: unknown) => void }).__launch; + consumer({ + targetURL: new URL(target, window.location.origin).href, + ...(files.length > 0 ? { + files: files.map((f) => ({ kind: 'file', getFile: async () => new File([f.body], f.name, { type: f.type }) })), + } : {}), + }); + }, { target: opts.target ?? '/', files: opts.files ?? [] }); +} + +async function signIn(page: Page, email: string): Promise { + await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 }); + await page.fill('#username', email); + await page.fill('#password', ENT_PASSWORD); + await page.click('button[type="submit"]'); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 }); +} + +/** A new window lands signed out or signed in, depending on what can be restored. */ +async function signInIfAsked(page: Page, email: string): Promise { + const login = page.locator('#username'); + await expect(login.or(accountSwitcher(page))).toBeVisible({ timeout: 60_000 }); + if (await login.isVisible()) await signIn(page, email); +} + +async function launchContext(browser: Browser, opts: Parameters[0] = {}): Promise { + const context = await browser.newContext({ ...contextOptions(), ...opts }); + await context.addInitScript(catchLaunchConsumer); + return context; +} + +async function signedIn(browser: Browser, opts: Parameters[0] = {}): Promise { + const page = await (await launchContext(browser, opts)).newPage(); + await neutralizeDevOverlay(page); + await page.goto('/'); + await signIn(page, ENT.alice); + return page; +} + +/** A real form POST to the share target, as the OS's share sheet sends it. */ +async function postShare(page: Page, fields: Record, files: LaunchFile[]): Promise { + await page.evaluate(({ fields: values, files: list }) => { + const form = document.createElement('form'); + form.method = 'POST'; + form.enctype = 'multipart/form-data'; + form.action = '/protocol/share'; + for (const [name, value] of Object.entries(values)) { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = name; + input.value = value; + form.append(input); + } + const input = document.createElement('input'); + input.type = 'file'; + input.name = 'files'; + input.multiple = true; + const data = new DataTransfer(); + for (const f of list) data.items.add(new File([f.body], f.name, { type: f.type })); + input.files = data.files; + form.append(input); + document.body.append(form); + form.submit(); + }, { fields, files }); +} + +async function workerReady(page: Page): Promise { + const active = await page.evaluate(() => navigator.serviceWorker.ready.then((r) => !!r.active)); + expect(active).toBe(true); +} + +const storedShareEntries = (page: Page) => page.evaluate(async (name) => { + if (!(await caches.has(name))) return 0; + return (await (await caches.open(name)).keys()).length; +}, SHARE_CACHE); + +test.beforeAll(() => requireEntEnv()); + +test.describe('manifest', () => { + test('offers shortcuts, file handlers and the share target, in the browser language', async ({ browser }) => { + const context = await browser.newContext(contextOptions()); + const res = await context.request.get('/manifest.webmanifest', { headers: { 'Accept-Language': 'de-DE,de;q=0.9' } }); + expect(res.ok()).toBe(true); + const manifest = await res.json(); + expect(manifest.shortcuts.map((s: { name: string; url: string }) => [s.name, s.url])).toEqual([ + ['Neue Nachricht', '/?compose=1'], + ['Kalender', '/calendar'], + ['Kontakte', '/contacts'], + ]); + expect(manifest.file_handlers.map((h: { action: string }) => h.action)).toEqual(['/mail', '/calendar', '/contacts']); + expect(manifest.share_target).toMatchObject({ action: '/protocol/share', method: 'POST', enctype: 'multipart/form-data' }); + expect(manifest.protocol_handlers).toHaveLength(2); + await context.close(); + }); + + test('every address in it answers', async ({ browser }) => { + const context = await browser.newContext(contextOptions()); + const manifest = await (await context.request.get('/manifest.webmanifest')).json(); + for (const shortcut of manifest.shortcuts) { + expect((await context.request.get(shortcut.url)).ok(), shortcut.url).toBe(true); + expect((await context.request.get(shortcut.icons[0].src)).ok(), shortcut.icons[0].src).toBe(true); + } + for (const handler of manifest.file_handlers) { + expect((await context.request.get(handler.action)).ok(), handler.action).toBe(true); + } + await context.close(); + }); +}); + +test.describe('share target', () => { + test('files and a link go through the service worker into a new draft', async ({ browser }) => { + const page = await signedIn(browser); + await workerReady(page); + + const subject = `Shared ${RUN}`; + await postShare(page, { title: subject, text: 'Have a look', url: 'https://example.org/page' }, [ + { name: `shared-${RUN}.txt`, type: 'text/plain', body: 'shared bytes' }, + ]); + await page.waitForURL((url) => url.pathname.endsWith('/') || !url.pathname.includes('protocol')); + await signInIfAsked(page, ENT.alice); + + await expect(composer(page)).toBeVisible({ timeout: 30_000 }); + await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue(subject); + await expect(composer(page).locator('[contenteditable="true"]').first()).toContainText('https://example.org/page'); + await expect(composer(page).getByText(`shared-${RUN}.txt`)).toBeVisible(); + // It reached the app through the worker's store, not the server's fallback. + await expect(page.getByText("The shared files couldn't be kept")).toHaveCount(0); + await expect.poll(() => new URL(page.url()).search).toBe(''); + await expect.poll(() => storedShareEntries(page)).toBe(0); + await shot(page, 'pwa-launch-share'); + await page.context().close(); + }); + + test('survives a sign-in that reloads the page', async ({ browser }) => { + const page = await (await launchContext(browser)).newPage(); + await page.goto('/'); + await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 }); + await workerReady(page); + + await postShare(page, { url: 'https://example.org/waited' }, [{ name: `waited-${RUN}.txt`, type: 'text/plain', body: 'x' }]); + await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 }); + await expect.poll(() => storedShareEntries(page)).toBe(2); + // What SSO does: away and back, in the same tab. + await page.reload(); + await signIn(page, ENT.alice); + + await expect(composer(page)).toBeVisible({ timeout: 30_000 }); + await expect(composer(page).getByText(`waited-${RUN}.txt`)).toBeVisible(); + await expect.poll(() => storedShareEntries(page)).toBe(0); + await page.context().close(); + }); + + test('without a service worker the link still arrives and the app says the files did not', async ({ browser }) => { + const page = await signedIn(browser, { serviceWorkers: 'block' }); + await postShare(page, { url: 'https://example.org/no-worker' }, [{ name: 'lost.txt', type: 'text/plain', body: 'x' }]); + await page.waitForLoadState('domcontentloaded'); + await signInIfAsked(page, ENT.alice); + + await expect(composer(page)).toBeVisible({ timeout: 30_000 }); + await expect(composer(page).locator('[contenteditable="true"]').first()).toContainText('https://example.org/no-worker'); + await expect(composer(page).getByText('lost.txt')).toHaveCount(0); + await expect(page.getByText("The shared files couldn't be kept").first()).toBeVisible(); + await page.context().close(); + }); + + test('the server takes the POST from outside the origin, which /api/ would refuse', async ({ browser }) => { + const context = await browser.newContext(contextOptions()); + const res = await context.request.post('/protocol/share', { + headers: { Origin: 'https://other.example', 'Sec-Fetch-Site': 'cross-site' }, + multipart: { url: 'https://example.org/direct' }, + maxRedirects: 0, + }); + expect(res.status()).toBe(303); + const location = new URL(res.headers().location, 'http://x.invalid'); + expect(location.searchParams.get('share')).toBe('unavailable'); + expect(location.searchParams.get('share_url')).toBe('https://example.org/direct'); + await context.close(); + }); +}); + +test.describe('file launches', () => { + test('.eml opens in the viewer, over the page', async ({ browser }) => { + const page = await signedIn(browser); + const before = new URL(page.url()).pathname; + await launch(page, { target: '/mail', files: [EML] }); + await expect(page.getByRole('heading', { name: `Launched message ${RUN}` })).toBeVisible({ timeout: 20_000 }); + expect(new URL(page.url()).pathname).toBe(before); + await expect(page.getByRole('button', { name: 'Download', exact: true }).first()).toBeVisible(); + await shot(page, 'pwa-launch-eml'); + await page.context().close(); + }); + + test('.eml opens before signing in', async ({ browser }) => { + const page = await (await launchContext(browser)).newPage(); + await page.goto('/'); + await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 }); + await launch(page, { target: '/mail', files: [EML] }); + await expect(page.getByRole('heading', { name: `Launched message ${RUN}` })).toBeVisible({ timeout: 20_000 }); + await page.context().close(); + }); + + test('.eml with downloads blocked: the viewer opens, with nothing to save', async ({ browser }) => { + const admin = await adminContext(browser); + const saved = await getPolicy(admin); + const organisation = (saved.organisation ?? {}) as Record; + await putPolicy(admin, { + ...saved, + organisation: { + ...organisation, + devices: { ...((organisation.devices ?? {}) as object), noDownloads: true }, + }, + }); + try { + const page = await signedIn(browser); + await launch(page, { target: '/mail', files: [EML] }); + await expect(page.getByRole('heading', { name: `Launched message ${RUN}` })).toBeVisible({ timeout: 20_000 }); + await expect(page.getByRole('button', { name: 'Download', exact: true })).toHaveCount(0); + await page.context().close(); + } finally { + await putPolicy(admin, saved); + await admin.close(); + } + }); + + test('.eml without a connection or the viewer chunk: a message, not the error page', async ({ browser }) => { + const page = await signedIn(browser); + await page.context().setOffline(true); + await launch(page, { target: '/mail', files: [EML] }); + await expect(page.getByText("The file couldn't be opened").first()).toBeVisible({ timeout: 20_000 }); + await expect(accountSwitcher(page)).toBeVisible(); + + await page.context().setOffline(false); + await launch(page, { target: '/mail', files: [EML] }); + await expect(page.getByRole('heading', { name: `Launched message ${RUN}` })).toBeVisible({ timeout: 20_000 }); + await page.context().close(); + }); + + test('.ics goes to the calendar import, which the server parses', async ({ browser }) => { + const page = await signedIn(browser); + await launch(page, { target: '/calendar', files: [ICS] }); + await page.waitForURL(/\/calendar/); + await expect(page.getByText(`Launched event ${RUN}`).first()).toBeVisible({ timeout: 30_000 }); + await shot(page, 'pwa-launch-ics'); + await page.context().close(); + }); + + test('.ics while offline says the server is needed, and carries on once back', async ({ browser }) => { + const page = await signedIn(browser); + // Into the calendar first: its code isn't on the device without offline mode. + await launch(page, { target: '/calendar' }); + await page.waitForURL(/\/calendar/); + await page.context().setOffline(true); + await launch(page, { target: '/calendar', files: [ICS] }); + await expect(page.getByText('Importing a calendar file needs a connection to the server.')).toBeVisible({ timeout: 20_000 }); + await shot(page, 'pwa-launch-ics-offline'); + + await page.context().setOffline(false); + await expect(page.getByText(`Launched event ${RUN}`).first()).toBeVisible({ timeout: 30_000 }); + await page.context().close(); + }); + + test('.vcf goes to the contacts import, parsed in the browser', async ({ browser }) => { + const page = await signedIn(browser); + await launch(page, { target: '/contacts', files: [VCF] }); + await page.waitForURL(/\/contacts/); + await expect(page.getByText(`Launched Person ${RUN}`).first()).toBeVisible({ timeout: 30_000 }); + await shot(page, 'pwa-launch-vcf'); + await page.context().close(); + }); + + test('a file launched before signing in waits for it', async ({ browser }) => { + const page = await (await launchContext(browser)).newPage(); + await page.goto('/'); + await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 }); + await launch(page, { target: '/contacts', files: [VCF] }); + await expect(page.locator('#username')).toBeVisible(); + await signIn(page, ENT.alice); + await page.waitForURL(/\/contacts/); + await expect(page.getByText(`Launched Person ${RUN}`).first()).toBeVisible({ timeout: 30_000 }); + await page.context().close(); + }); + + test('a file launched into a signed-in page with a reload on the way still arrives', async ({ browser }) => { + const page = await (await launchContext(browser)).newPage(); + await page.goto('/'); + await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 }); + await launch(page, { target: '/calendar', files: [ICS] }); + await expect.poll(() => storedShareEntries(page)).toBe(2); + await page.reload(); + await signIn(page, ENT.alice); + await page.waitForURL(/\/calendar/); + await expect(page.getByText(`Launched event ${RUN}`).first()).toBeVisible({ timeout: 30_000 }); + await expect.poll(() => storedShareEntries(page)).toBe(0); + await page.context().close(); + }); +}); + +test.describe('shortcuts', () => { + test('New message opens a composer from the address, once, and clears the address', async ({ browser }) => { + const page = await (await launchContext(browser)).newPage(); + await page.goto('/?compose=1'); + await signIn(page, ENT.alice); + await expect(composer(page)).toBeVisible({ timeout: 30_000 }); + expect(new URL(page.url()).search).toBe(''); + await page.context().close(); + }); + + test('New message in an open window', async ({ browser }) => { + const page = await signedIn(browser); + await expect(composer(page)).toHaveCount(0); + await launch(page, { target: '/?compose=1' }); + await expect(composer(page)).toBeVisible({ timeout: 30_000 }); + await page.context().close(); + }); + + test('Calendar and Contacts in an open window', async ({ browser }) => { + const page = await signedIn(browser); + await launch(page, { target: '/calendar' }); + await page.waitForURL(/\/calendar/); + await launch(page, { target: '/contacts' }); + await page.waitForURL(/\/contacts/); + await page.context().close(); + }); + + test('the plain app icon leaves an open window where it is', async ({ browser }) => { + const page = await signedIn(browser); + await launch(page, { target: '/calendar' }); + await page.waitForURL(/\/calendar/); + await launch(page, { target: '/' }); + await page.waitForTimeout(1000); + expect(new URL(page.url()).pathname).toMatch(/\/calendar(\/|$)/); + await expect(composer(page)).toHaveCount(0); + await page.context().close(); + }); +}); + +test.describe('in the Pro shell', () => { + const proOn = () => { + const key = 'settings-storage'; + const parsed = JSON.parse(localStorage.getItem(key) ?? '{"state":{},"version":0}'); + parsed.state = { ...parsed.state, proInterface: true }; + localStorage.setItem(key, JSON.stringify(parsed)); + }; + + test('a shared file reaches the composer tab, and a .ics the calendar tab', async ({ browser }) => { + const context = await launchContext(browser); + await context.addInitScript(proOn); + const page = await context.newPage(); + await page.goto('/'); + await signIn(page, ENT.alice); + await page.waitForURL(/\/pro/); + await workerReady(page); + + await postShare(page, { url: 'https://example.org/pro' }, [{ name: `pro-${RUN}.txt`, type: 'text/plain', body: 'x' }]); + await signInIfAsked(page, ENT.alice); + await page.waitForURL(/\/pro/); + await expect(page.getByText(`pro-${RUN}.txt`).first()).toBeVisible({ timeout: 30_000 }); + + await launch(page, { target: '/calendar', files: [ICS] }); + await expect(page.getByText(`Launched event ${RUN}`).first()).toBeVisible({ timeout: 30_000 }); + await shot(page, 'pwa-launch-pro'); + await context.close(); + }); +}); From f716ea66eea94b504ded5aad059999141cbf2389 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:07:55 +0100 Subject: [PATCH 3/3] fix: ask before a share starts a message and save nothing until the first edit Any site could POST a form to the share target on load: with no clicks the composer opened with its subject and file, and autosave stored a real draft on the server 2 seconds later. The worker can't see where a POST came from, and Chromium's link capturing accepts form submissions, so an installed-app window proves nothing either. - every share landing now asks first, in every window and browser; a text landing address gets the same question. Nothing goes up before the answer. - handed-in text and files are the composer's starting state: no autosave, page-close save or sign-out flush before the first real edit. - the worker ignores frames, keeps at most 5 entries and 100 MB for 15 minutes, sweeps on activate, on each share and from a page timer, and seals what it keeps with a key passed in the landing fragment. Offline wipes clear it too. - the server fallback uses the fragment and caps by encoded length: 3000 Japanese characters made a 27 KB `Location` and a 431. - a second compose handoff waits until the composer closes, also one set aside by reading a message, where it used to replace the first. - a launch leaving the mail page saves what was just typed first. - an expired handoff or a missing stored file now says so; shares get the control-character stripping and limits of `parseMailto`. Existing tests changed because the landing moved from the query to the fragment, stored entries gained a sealed part, and a share now asks first. --- app/(main)/protocol/share/route.ts | 49 +- .../composer-attach-from-files.test.tsx | 62 +++ components/email/email-composer.tsx | 53 ++- components/mail/mail-app.tsx | 89 ++-- .../protocol-launch-handler-provider.test.tsx | 159 ++++++- .../protocol-launch-handler-provider.tsx | 104 ++++- integration/tests/23-do-pwa-launch.spec.ts | 227 ++++++++- lib/__tests__/launch-handoff.test.ts | 238 ++++++++-- lib/__tests__/manifest-launch.test.ts | 33 +- lib/__tests__/sw-harness.ts | 2 +- lib/__tests__/sw-share-target.test.ts | 187 ++++++-- lib/launch-handoff.ts | 436 +++++++++++++----- lib/offline/__tests__/handoff-wipes.test.ts | 64 +++ lib/offline/offline-mode.ts | 4 + lib/protocol-handlers/mailto.ts | 14 +- locales/ar/common.json | 13 +- locales/ca/common.json | 13 +- locales/cs/common.json | 13 +- locales/da/common.json | 13 +- locales/de/common.json | 13 +- locales/en/common.json | 13 +- locales/es/common.json | 13 +- locales/et/common.json | 13 +- locales/fa/common.json | 13 +- locales/fr/common.json | 13 +- locales/he/common.json | 13 +- locales/hu/common.json | 13 +- locales/it/common.json | 13 +- locales/ja/common.json | 13 +- locales/ko/common.json | 13 +- locales/lv/common.json | 13 +- locales/mn/common.json | 13 +- locales/nb/common.json | 13 +- locales/nl/common.json | 13 +- locales/pl/common.json | 13 +- locales/pt/common.json | 13 +- locales/ro/common.json | 13 +- locales/ru/common.json | 13 +- locales/sk/common.json | 13 +- locales/th/common.json | 13 +- locales/tr/common.json | 13 +- locales/uk/common.json | 13 +- locales/vi/common.json | 13 +- locales/zh-TW/common.json | 13 +- locales/zh/common.json | 13 +- public/sw.js | 115 ++++- 46 files changed, 1928 insertions(+), 298 deletions(-) create mode 100644 lib/offline/__tests__/handoff-wipes.test.ts diff --git a/app/(main)/protocol/share/route.ts b/app/(main)/protocol/share/route.ts index ea0ed58c5..851f7a1ba 100644 --- a/app/(main)/protocol/share/route.ts +++ b/app/(main)/protocol/share/route.ts @@ -1,8 +1,9 @@ /** * 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 + * from before this existed. Nothing keeps files here, so text and links go on + * in the address's fragment, which stays out of access logs, and the page asks + * the user and 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. @@ -12,11 +13,32 @@ 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; +// Encoded lengths: a proxy in front has to pass the Location header, and one +// Japanese character takes 9 bytes in it. +const BUDGET = { title: 400, url: 1000, text: 1800 }; export const dynamic = "force-dynamic"; -function land(query: string): Response { - return new Response(null, { status: 303, headers: { Location: `${BASE_PATH}/${query}` } }); +function land(fragment: string): Response { + return new Response(null, { status: 303, headers: { Location: `${BASE_PATH}/${fragment}` } }); +} + +/** As much of `value` as takes up to `budget` bytes once encoded, whole characters only. */ +function fitEncoded(value: string, budget: number): { value: string; cut: boolean } { + let used = 0; + let out = ""; + for (const character of value) { + let size: number; + try { + size = encodeURIComponent(character).length; + } catch { + continue; // A lone surrogate. + } + used += size; + if (used > budget) return { value: out, cut: true }; + out += character; + } + return { value: out, cut: false }; } export async function POST(request: Request): Promise { @@ -24,25 +46,26 @@ export async function POST(request: Request): Promise { const params = new URLSearchParams({ share: "unavailable" }); if (!Number.isFinite(length) || length <= 0 || length > MAX_READ_BYTES) { params.set("share_files", "1"); - return land(`?${params}`); + return land(`#${params}`); } try { const form = await request.formData(); - const field = (name: string, max: number) => { + let cut = false; + for (const name of ["title", "text", "url"] as const) { 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 (typeof value !== "string" || !value) continue; + const fitted = fitEncoded(value, BUDGET[name]); + params.set(`share_${name}`, fitted.value); + cut ||= fitted.cut; + } + if (cut) params.set("share_cut", "1"); 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}`); + return land(`#${params}`); } /** Nothing to share: the app. */ diff --git a/components/email/__tests__/composer-attach-from-files.test.tsx b/components/email/__tests__/composer-attach-from-files.test.tsx index 5135d298f..c8510b72c 100644 --- a/components/email/__tests__/composer-attach-from-files.test.tsx +++ b/components/email/__tests__/composer-attach-from-files.test.tsx @@ -241,6 +241,68 @@ describe('files handed to a new composer (a share, a launch)', () => { const uploading = () => filesClient({ uploadBlob: vi.fn(() => new Promise(() => {})), getMaxSizeUpload: () => 0 }); + const handedIn = (extra: Record = {}) => ({ + to: '', cc: '', bcc: '', subject: 'Shared subject', body: 'Shared text', showCc: false, showBcc: false, + selectedIdentityId: null, subAddressTag: '', mode: 'compose', draftId: null, unedited: true, ...extra, + }) as never; + const settled = (client: Record) => ({ + ...client, + uploadBlob: vi.fn(async () => ({ blobId: 'blob-shared', size: 1, type: 'text/plain' })), + getMaxSizeUpload: () => 0, + }); + + it('saves no draft from handed-in text and files until the first real edit', async () => { + const client = settled(filesClient()) as unknown as ReturnType; + useAuthStore.setState({ client: client as never }); + vi.useFakeTimers(); + render(); + await act(async () => { await vi.advanceTimersByTimeAsync(5000); }); + expect(screen.getByText('shared.pdf')).toBeInTheDocument(); + expect(client.createDraft).not.toHaveBeenCalled(); + + // Closing the page doesn't save it either. + await act(async () => { window.dispatchEvent(new Event('beforeunload')); await vi.advanceTimersByTimeAsync(100); }); + expect(client.createDraft).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByPlaceholderText(/subject/i), { target: { value: 'Shared subject, edited' } }); + await act(async () => { await vi.advanceTimersByTimeAsync(2500); }); + expect(client.createDraft).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + + it('closes without asking while nothing is the user\'s own', async () => { + const onClose = vi.fn(); + useAuthStore.setState({ client: settled(filesClient()) as never }); + const ref = { current: null as ((afterClose?: () => void) => void) | null }; + render(); + await screen.findByText('shared.pdf'); + await act(async () => { ref.current?.(); }); + expect(onClose).toHaveBeenCalled(); + }); + + it('counts a removed attachment as an edit', async () => { + const client = settled(filesClient()) as unknown as ReturnType; + useAuthStore.setState({ client: client as never }); + vi.useFakeTimers(); + render(); + await act(async () => { await vi.advanceTimersByTimeAsync(100); }); + const chip = screen.getByText('shared.pdf').closest('[data-testid], div')!.parentElement!; + const remove = chip.querySelector('button[aria-label]') ?? chip.querySelector('button'); + fireEvent.click(remove!); + await act(async () => { await vi.advanceTimersByTimeAsync(2500); }); + expect(client.createDraft).toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('hands its unedited state back to a remount, without asking to save', async () => { + useAuthStore.setState({ client: settled(filesClient()) as never }); + const onSaveState = vi.fn(); + const view = render(); + await screen.findByText('shared.pdf'); + view.unmount(); + expect(onSaveState).toHaveBeenCalledWith(expect.objectContaining({ unedited: true, unsaved: false, subject: 'Shared subject' })); + }); + it('attaches them when it opens', async () => { useAuthStore.setState({ client: uploading() as never }); render(); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 93932e246..13418b806 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -217,6 +217,12 @@ type SuggestionItem = { name: string; email: string; group?: { id: string; membe const attachedInitialFiles = new WeakSet(); export interface ComposerDraftData { + /** + * Handed in from outside (a share, the desktop app) and not touched yet. + * It is only where the composer starts: no draft on the server until the + * user's first real edit. + */ + unedited?: boolean; to: string; cc: string; bcc: string; @@ -1770,6 +1776,16 @@ export function EmailComposer({ } changeVersionRef.current += 1; }, [toStr, ccStr, bccStr, subject, body, attachments.length]); + // Handed-in text and files aren't the user's until they touch them. Nothing + // counts as dirty until then, so autosave, the page-close save and the flush + // before a sign-out all leave them alone. + const uneditedRef = useRef(initialData?.unedited === true); + const [firstEditSeen, setFirstEditSeen] = useState(false); + const markEdited = useCallback(() => { + if (!uneditedRef.current) return; + uneditedRef.current = false; + setFirstEditSeen(true); + }, []); const isDirtyRef = useRef(false); isDirtyRef.current = toStr !== initialValuesRef.current.to || ccStr !== initialValuesRef.current.cc || bccStr !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject || @@ -1781,6 +1797,19 @@ export function EmailComposer({ if (priority !== initialPriorityRef.current) isDirtyRef.current = true; const initialLabelChoiceRef = useRef(classification.choice); if (classification.choice !== initialLabelChoiceRef.current) isDirtyRef.current = true; + if (uneditedRef.current) isDirtyRef.current = false; + // Changes only the user makes, also without typing: a picked recipient, a + // removed attachment, a priority. Handed-in files move the count they're + // compared with (addFiles). The body isn't here, a signature can change it; + // typing in it comes through the composer root's input events. + useEffect(() => { + if (!uneditedRef.current) return; + const start = initialValuesRef.current; + if (toStr !== start.to || ccStr !== start.cc || bccStr !== start.bcc || subject !== start.subject + || attachments.length !== start.attachmentCount || priority !== initialPriorityRef.current) { + markEdited(); + } + }, [toStr, ccStr, bccStr, subject, attachments.length, priority, markEdited]); const firstLabelRunRef = useRef(true); useEffect(() => { if (firstLabelRunRef.current) { @@ -1837,7 +1866,9 @@ export function EmailComposer({ useEffect(() => { return () => { if (explicitCloseRef.current) return; - if (onSaveState && isDirtyRef.current) { + // Kept in the host's memory across a remount, handed-in or not. Only + // the server stays out of it. + if (onSaveState && (isDirtyRef.current || uneditedRef.current)) { const s = stateRef.current; onSaveState({ ...s, @@ -1848,7 +1879,8 @@ export function EmailComposer({ classification: classificationRef.current.draftChoice, // Edits the last save didn't cover; a minimised composer with // none needs no save before an automatic sign-out. - unsaved: changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current, + unsaved: !uneditedRef.current && (changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current), + ...(uneditedRef.current ? { unedited: true } : {}), }); } }; @@ -2304,7 +2336,7 @@ export function EmailComposer({ return () => window.removeEventListener('keydown', handleTemplateKey); }, [templatesEnabled]); - const addFiles = useCallback(async (files: File[]) => { + const addFiles = useCallback(async (files: File[], opts?: { handedIn?: boolean }) => { if (!client || files.length === 0) return; // Let plugins veto each upload before it's queued. @@ -2358,6 +2390,8 @@ export function EmailComposer({ abortController: controller, }; }); + // Files handed in are where the composer starts, not an edit. + if (opts?.handedIn) initialValuesRef.current.attachmentCount += newAttachments.length; setAttachments(prev => [...prev, ...newAttachments]); for (let i = 0; i < files.length; i++) { @@ -2474,7 +2508,7 @@ export function EmailComposer({ useEffect(() => { if (!client || !initialFiles || initialFiles.length === 0 || attachedInitialFiles.has(initialFiles)) return; attachedInitialFiles.add(initialFiles); - void addFiles(initialFiles); + void addFiles(initialFiles, { handedIn: true }); }, [client, initialFiles, addFiles]); const handleFileSelect = async (event: React.ChangeEvent) => { @@ -2930,7 +2964,7 @@ export function EmailComposer({ // login page can say a draft wasn't saved. useEffect(() => registerComposeFlush(async () => { while (isSendingRef.current) await new Promise((resolve) => setTimeout(resolve, SEND_WAIT_POLL_MS)); - if (sentRef.current) return; + if (sentRef.current || uneditedRef.current) return; const unsaved = changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current; if (!unsaved) return; if (saveTimeoutRef.current) { @@ -3283,7 +3317,7 @@ export function EmailComposer({ } }; // eslint-disable-next-line react-hooks/exhaustive-deps -- saveDraft reads current state when called, not when effect is set up - }, [toStr, ccStr, bccStr, subject, body, attachments, priority]); + }, [toStr, ccStr, bccStr, subject, body, attachments, priority, firstEditSeen]); useEffect(() => { return () => { @@ -4290,6 +4324,13 @@ export function EmailComposer({ data-testid="email-composer" className={cn("flex h-full bg-background relative", className)} onKeyDownCapture={handleComposerKeyDown} + // Bubble phase, in the same batch as the field's own onChange. A state + // change in the capture phase renders first and puts the old value back. + onInput={markEdited} + onChange={markEdited} + onPaste={markEdited} + onCut={markEdited} + onDrop={markEdited} >
{liveMessage && {liveMessage.text}} diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 52f90a2f6..4e49fb555 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -142,7 +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 { pendingHandoffs, subscribeHandoff, takeHandoff, type Handoff } 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"; @@ -232,7 +232,7 @@ 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[] }; +type DraftRequest = ParsedMailto & { initialFiles?: File[]; unedited?: boolean }; export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Static Lite build: the route params are empty, read the link from the URL. @@ -1479,7 +1479,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { suppressComposerStateSaveSessionRef.current = composerSessionId; } setComposerSessionId((id) => id + 1); - setPendingDraft(mailtoComposerDraft(pending, useSettingsStore.getState().plainTextMode)); + setPendingDraft({ + ...mailtoComposerDraft(pending, useSettingsStore.getState().plainTextMode), + ...(pending.unedited ? { unedited: true } : {}), + }); setComposerInitialFiles(pending.initialFiles ?? null); setComposerMode("compose"); setShowComposer(true); @@ -1530,7 +1533,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }, []); // A draft that comes from outside: which account it goes out from. - const routeOutsideDraft = useCallback((pending: DraftRequest) => { + const routeOutsideDraft = useCallback(async (pending: DraftRequest): Promise => { const protocolAccounts = getMailtoProtocolAccounts(); if (protocolAccounts.length > 1) { setPendingMailtoAccountChoice(pending); @@ -1539,11 +1542,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const accountId = protocolAccounts[0]?.id ?? activeAccountId; if (accountId) { - void openMailtoForAccount(pending, accountId); + await openMailtoForAccount(pending, accountId); return; } - openMailtoDraft(pending); + await openMailtoDraft(pending); }, [activeAccountId, getMailtoProtocolAccounts, openMailtoDraft, openMailtoForAccount]); const handleMailtoProtocolRequest = useCallback(async (pending: ParsedMailto) => { @@ -1553,7 +1556,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { openMailtoDraft(pending); return; } - routeOutsideDraft(pending); + void routeOutsideDraft(pending); }, [mailtoAllowed, openMailtoDraft, routeOutsideDraft]); // A new draft from a shortcut, a share or the desktop app @@ -1561,35 +1564,65 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // 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; + // One at a time. A composer that is open holds the user's text, so what comes + // next stays queued until it closes, and a second one can't replace the + // first. So does one set aside by reading a message, while the server hasn't + // got what it holds. Under Pro a composer moves to its own tab, so nothing + // waits long. + const handoffOpeningRef = useRef(false); + const handoffToldWaitingRef = useRef(new Set()); + const composerInUseRef = useRef(false); + const composerInUse = showComposer || pendingMailtoAccountChoice !== null + || pendingDraft?.unsaved === true || pendingDraft?.unedited === true; + composerInUseRef.current = composerInUse; + const [handoffSettled, setHandoffSettled] = useState(0); 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, + const open = async (handoff: Handoff) => { + if (!handoff.subject && !handoff.body && handoff.files.length === 0) { + await withComposeGate('compose', {}, () => { + startFreshComposerSession(); + setComposerMode('compose'); + setShowComposer(true); + if (isMobile) setActiveView('viewer'); }); + return; } + await routeOutsideDraft({ + to: [], + cc: [], + bcc: [], + subject: handoff.subject ?? '', + body: handoff.body ?? '', + initialFiles: handoff.files, + unedited: true, + }); }; - openHandoff(); - return subscribeHandoff('compose', openHandoff); + const openNext = () => { + if (handoffOpeningRef.current) return; + if (composerInUseRef.current) { + const waiting = pendingHandoffs().find((handoff) => handoff.kind === 'compose'); + if (waiting && !handoffToldWaitingRef.current.has(waiting.id)) { + handoffToldWaitingRef.current.add(waiting.id); + toast.info(t('pwaLaunch.compose_waiting')); + } + return; + } + const handoff = takeHandoff('compose'); + if (!handoff) return; + handoffOpeningRef.current = true; + void open(handoff).finally(() => { + handoffOpeningRef.current = false; + setHandoffSettled((n) => n + 1); + }); + }; + + openNext(); + return subscribeHandoff('compose', openNext); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isAuthenticated, client, proTakingOver, routeOutsideDraft, withComposeGate, isMobile]); + }, [isAuthenticated, client, proTakingOver, routeOutsideDraft, withComposeGate, isMobile, composerInUse, handoffSettled]); useEffect(() => { if (!isAuthenticated || !client) return; diff --git a/components/protocol/__tests__/protocol-launch-handler-provider.test.tsx b/components/protocol/__tests__/protocol-launch-handler-provider.test.tsx index 49c451447..15ffa7cf2 100644 --- a/components/protocol/__tests__/protocol-launch-handler-provider.test.tsx +++ b/components/protocol/__tests__/protocol-launch-handler-provider.test.tsx @@ -1,4 +1,4 @@ -import { act, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, 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'; @@ -10,6 +10,11 @@ const hoisted = vi.hoisted(() => ({ push: vi.fn(), pathname: { current: '/' }, toastError: vi.fn(), + toastInfo: vi.fn(), + flushComposers: vi.fn(async () => ({ total: 0, settled: 0, failed: 0, timedOut: false })), + peek: vi.fn(), + adopt: vi.fn(), + discard: vi.fn(async () => {}), savePendingMailto: vi.fn(), protocolOpenMode: 'active-session', })); @@ -19,7 +24,14 @@ vi.mock('@/i18n/navigation', () => ({ 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/toast-store', () => ({ toast: { error: hoisted.toastError, info: hoisted.toastInfo, success: vi.fn() } })); +vi.mock('@/lib/compose-flush-registry', () => ({ flushComposers: hoisted.flushComposers })); +vi.mock('@/lib/launch-handoff', async (importOriginal) => ({ + ...(await importOriginal()), + peekStoredHandoff: hoisted.peek, + adoptStoredHandoff: hoisted.adopt, + discardStoredHandoff: hoisted.discard, +})); vi.mock('@/stores/settings-store', () => ({ useSettingsStore: Object.assign(() => undefined, { getState: () => ({ protocolOpenMode: hoisted.protocolOpenMode }) }), })); @@ -50,7 +62,7 @@ vi.mock('@/components/files/file-preview-modal', () => ({ }, })); -const { clearHandoffs, pendingHandoffs, takeHandoff } = await import('@/lib/launch-handoff'); +const { clearHandoffs, handOffFiles, pendingHandoffs, takeHandoff, HANDOFF_TTL_MS } = await import('@/lib/launch-handoff'); const { setDownloadGuard } = await import('@/lib/download-guard'); const { ProtocolLaunchHandlerProvider } = await import('../protocol-launch-handler-provider'); @@ -70,6 +82,12 @@ beforeEach(() => { setConsumer.mockClear(); hoisted.push.mockClear(); hoisted.toastError.mockClear(); + hoisted.toastInfo.mockClear(); + hoisted.flushComposers.mockReset(); + hoisted.flushComposers.mockResolvedValue({ total: 0, settled: 0, failed: 0, timedOut: false }); + hoisted.peek.mockReset(); + hoisted.adopt.mockReset(); + hoisted.discard.mockClear(); hoisted.savePendingMailto.mockClear(); hoisted.pathname.current = '/'; hoisted.protocolOpenMode = 'active-session'; @@ -212,6 +230,7 @@ describe('launches that are not files', () => { it('goes to the calendar from its shortcut in an open window', async () => { mount(); consumer!({ targetURL: `${window.location.origin}/calendar` }); + await flush(); expect(hoisted.push).toHaveBeenCalledWith('/calendar'); }); @@ -238,6 +257,51 @@ describe('launches that are not files', () => { }); }); +describe('leaving a composer for a launched file', () => { + it('saves what was typed first, then goes', async () => { + const order: string[] = []; + hoisted.flushComposers.mockImplementation(async () => { order.push('flush'); return { total: 1, settled: 1, failed: 0, timedOut: false }; }); + hoisted.push.mockImplementation(() => { order.push('push'); }); + mount(); + consumer!({ files: [handle('people.vcf')] }); + await flush(); + expect(order).toEqual(['flush', 'push']); + hoisted.push.mockReset(); + }); + + it.each([ + ['the server refused the save', { total: 1, settled: 1, failed: 1, timedOut: false }], + ['the save took too long', { total: 1, settled: 0, failed: 0, timedOut: true }], + ])('stays when %s, with the file still waiting', async (_name, result) => { + hoisted.flushComposers.mockResolvedValue(result); + mount(); + consumer!({ files: [handle('people.vcf')] }); + await flush(); + expect(hoisted.push).not.toHaveBeenCalled(); + expect(hoisted.toastError).toHaveBeenCalledWith('save_first_failed'); + expect(pendingHandoffs().map((h) => h.kind)).toEqual(['contacts']); + }); + + it('does not save for a .eml, which leaves nothing', async () => { + mount(); + consumer!({ files: [handle('note.eml')] }); + await screen.findByTestId('eml-viewer'); + expect(hoisted.flushComposers).not.toHaveBeenCalled(); + }); +}); + +describe('a handoff that goes without arriving', () => { + it('says so', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + mount(); + handOffFiles([new File(['x'], 'a.ics')], 'calendar'); + vi.advanceTimersByTime(HANDOFF_TTL_MS + 1); + takeHandoff('calendar'); + vi.useRealTimers(); + expect(hoisted.toastError).toHaveBeenCalledWith('handoff_expired'); + }); +}); + 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'); @@ -256,21 +320,100 @@ describe('the address a launch lands on', () => { 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'); +describe('a share from an address anyone can make', () => { + const dialogText = () => screen.getByTestId('share-confirm-details').textContent ?? ''; + const agree = () => fireEvent.click(screen.getByRole('button', { name: 'share_confirm_accept' })); + const refuse = () => fireEvent.click(screen.getByRole('button', { name: 'share_confirm_decline' })); + + it('asks about text in the address, and starts nothing before the answer', async () => { + window.history.replaceState(null, '', '/#share=unavailable&share_title=T&share_text=B&share_files=1&share_cut=1'); mount(); - expect(window.location.search).toBe(''); + expect(window.location.hash).toBe(''); + await screen.findByText('share_confirm_message'); + expect(dialogText()).toContain('T'); + expect(pendingHandoffs()).toEqual([]); + expect(hoisted.push).not.toHaveBeenCalled(); + + agree(); + await flush(); expect(takeHandoff('compose')).toMatchObject({ subject: 'T', body: 'B' }); expect(hoisted.toastError).toHaveBeenCalledWith('share_files_lost'); + expect(hoisted.toastInfo).toHaveBeenCalledWith('share_cut'); + }); + + it('starts nothing when refused', async () => { + window.history.replaceState(null, '', '/#share=unavailable&share_text=B'); + mount(); + await screen.findByText('share_confirm_message'); + refuse(); + await flush(); + expect(pendingHandoffs()).toEqual([]); + }); + + it('only reports lost files when there was no text to ask about', async () => { + window.history.replaceState(null, '', '/#share=unavailable&share_files=1'); + mount(); + await flush(); + expect(screen.queryByText('share_confirm_message')).toBeNull(); + expect(hoisted.toastError).toHaveBeenCalledWith('share_files_lost'); + expect(pendingHandoffs()).toEqual([]); + }); + + it('ignores it in the query, which reaches logs and any link', async () => { + window.history.replaceState(null, '', '/?share=unavailable&share_text=B'); + mount(); + await flush(); + expect(screen.queryByText('share_confirm_message')).toBeNull(); + expect(pendingHandoffs()).toEqual([]); + }); + + it('asks about a stored share, naming the files, before anything is taken in', async () => { + hoisted.peek.mockResolvedValue({ subject: 'S', body: '', files: ['invoice.exe'] }); + hoisted.adopt.mockResolvedValue(true); + window.history.replaceState(null, '', '/#share=abc&key=k1'); + mount(); + expect(window.location.hash).toBe(''); + await screen.findByText('share_confirm_message'); + expect(hoisted.peek).toHaveBeenCalledWith('abc', 'k1'); + expect(dialogText()).toContain('invoice.exe'); + expect(hoisted.adopt).not.toHaveBeenCalledWith('abc', 'k1'); + + agree(); + await waitFor(() => expect(hoisted.adopt).toHaveBeenCalledWith('abc', 'k1')); + expect(hoisted.discard).not.toHaveBeenCalled(); + }); + + it('asks in an installed-app window as well: link capturing can bring a POST there', async () => { + const original = window.matchMedia; + window.matchMedia = ((query: string) => ({ matches: query.includes('standalone'), media: query, addEventListener: () => {}, removeEventListener: () => {} })) as never; + hoisted.peek.mockResolvedValue({ subject: 'S', body: '', files: [] }); + window.history.replaceState(null, '', '/#share=abc&key=k1'); + mount(); + await screen.findByText('share_confirm_message'); + expect(hoisted.adopt).not.toHaveBeenCalledWith('abc', 'k1'); + window.matchMedia = original; + }); + + it('drops a stored share when refused', async () => { + hoisted.peek.mockResolvedValue({ subject: 'S', body: '', files: ['invoice.exe'] }); + window.history.replaceState(null, '', '/#share=abc&key=k1'); + mount(); + await screen.findByText('share_confirm_message'); + refuse(); + await waitFor(() => expect(hoisted.discard).toHaveBeenCalledWith('abc')); + expect(hoisted.adopt).not.toHaveBeenCalledWith('abc', 'k1'); }); it('says so when what was shared is gone', async () => { - window.history.replaceState(null, '', '/?share=abc'); + hoisted.peek.mockResolvedValue(null); + window.history.replaceState(null, '', '/#share=abc&key=k1'); mount(); - expect(window.location.search).toBe(''); + expect(window.location.hash).toBe(''); await flush(); expect(hoisted.toastError).toHaveBeenCalledWith('share_gone'); + expect(screen.queryByText('share_confirm_message')).toBeNull(); }); }); diff --git a/components/protocol/protocol-launch-handler-provider.tsx b/components/protocol/protocol-launch-handler-provider.tsx index 69dcf5eb3..f052600d5 100644 --- a/components/protocol/protocol-launch-handler-provider.tsx +++ b/components/protocol/protocol-launch-handler-provider.tsx @@ -1,23 +1,29 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; import { useTranslations } from "next-intl"; import { usePathname, useRouter } from "@/i18n/navigation"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useConfig } from "@/hooks/use-config"; +import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; +import { flushComposers } from "@/lib/compose-flush-registry"; import { getPathPrefix } from "@/lib/browser-navigation"; import { matchSurface, type AppSurface } from "@/lib/deep-links"; import { adoptStoredHandoff, + discardStoredHandoff, handOffDraft, handOffLaunchFiles, handOffNewMessage, parseLaunchLanding, + peekStoredHandoff, pendingHandoffs, restoreStoredHandoffs, shortcutFromTargetUrl, stripLandingParams, subscribeHandoff, + subscribeHandoffLost, type HandoffKind, } from "@/lib/launch-handoff"; import { productName } from "@/lib/product-name"; @@ -77,6 +83,14 @@ const HANDOFF_TARGET: Partial()); + const { dialogProps, confirm } = useConfirmDialog(); + const [offer, setOffer] = useState(null); + + // Anyone can POST to the share target or link to a landing address, and + // Chromium's link capturing can bring a cross-site form POST into an + // installed app's window, so nothing tells a real share from a planted one. + // Nothing goes into an account until the user says yes. + const askAboutShare = useCallback(async (shared: ShareOffer) => { + setOffer(shared); + const agreed = await confirm({ + title: tLaunch("share_confirm_title"), + message: tLaunch("share_confirm_message"), + confirmText: tLaunch("share_confirm_accept"), + cancelText: tLaunch("share_confirm_decline"), + }); + setOffer(null); + return agreed; + }, [confirm, tLaunch]); // 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); + const stopLossNotes = subscribeHandoffLost((loss) => { + toast.error(tLaunch(loss === "missing" ? "share_files_lost" : "handoff_expired")); + }); + const { search, hash, pathname: path } = window.location; + const landing = parseLaunchLanding(search, hash); if (landing) { - window.history.replaceState( - window.history.state, - "", - window.location.pathname + stripLandingParams(window.location.search) + window.location.hash, - ); + const left = stripLandingParams(search, hash); + window.history.replaceState(window.history.state, "", path + left.search + left.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")); + void (async () => { + const { subject, body } = landing; + if ((subject || body) && !(await askAboutShare({ subject, body, files: [] }))) return; + if (subject || body) handOffDraft({ subject, body }); + if (landing.filesLost) toast.error(tLaunch("share_files_lost")); + if (landing.cut) toast.info(tLaunch("share_cut")); + })(); } else if (landing?.type === "stored") { - void adoptStoredHandoff(landing.id).then((found) => { - if (!found) toast.error(tLaunch("share_gone")); - }); + void (async () => { + const shared = await peekStoredHandoff(landing.id, landing.key); + if (!shared) { + await discardStoredHandoff(landing.id); + toast.error(tLaunch("share_gone")); + return; + } + if (!(await askAboutShare(shared))) { + await discardStoredHandoff(landing.id); + return; + } + if (!(await adoptStoredHandoff(landing.id, landing.key))) toast.error(tLaunch("share_gone")); + })(); } void restoreStoredHandoffs(); + return stopLossNotes; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Leaving the mail page ends an open composer, and its autosave waits 2 + // seconds: what was just typed is saved first. If it can't be, stay. A file + // keeps waiting and opens once the user goes to that page. + const leaveFor = useCallback(async (path: "/" | "/calendar" | "/contacts") => { + const result = await flushComposers(FLUSH_BEFORE_LEAVING_MS); + if (result.failed > 0 || result.timedOut) { + toast.error(tLaunch("save_first_failed")); + return; + } + router.push(path); + }, [router, tLaunch]); + // 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(() => { @@ -136,13 +197,13 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle 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); + if (matchSurface(pathname)?.surface !== target.surface) void leaveFor(target.path as "/" | "/calendar" | "/contacts"); return; } }; goToTarget(); return subscribeHandoff("any", goToTarget); - }, [pathname, router]); + }, [pathname, leaveFor]); useEffect(() => { if (pathname.startsWith("/protocol/")) return; @@ -177,7 +238,7 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle 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}`); + else if (shortcut && matchSurface(pathname)?.surface !== shortcut) void leaveFor(`/${shortcut}`); return; } @@ -213,12 +274,23 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle notifyPendingWebcal(); if (pathname !== "/calendar") router.push("/calendar"); }); - }, [pathname, router, tLaunch]); + }, [pathname, router, tLaunch, leaveFor]); + const offeredFiles = offer?.files ?? []; return ( <> {children} + + {offer.subject &&
{tLaunch("share_confirm_subject")}
{offer.subject}
} + {offer.body &&
{tLaunch("share_confirm_text")}
{offer.body.slice(0, OFFER_TEXT_LENGTH)}
} + {offeredFiles.length > 0 &&
{tLaunch("share_confirm_files", { count: offeredFiles.length })}
{offeredFiles.slice(0, OFFER_FILE_COUNT).join(", ")}{offeredFiles.length > OFFER_FILE_COUNT ? ", …" : ""}
} + + )} + /> ); } diff --git a/integration/tests/23-do-pwa-launch.spec.ts b/integration/tests/23-do-pwa-launch.spec.ts index dd898c0e9..6d2330b72 100644 --- a/integration/tests/23-do-pwa-launch.spec.ts +++ b/integration/tests/23-do-pwa-launch.spec.ts @@ -1,6 +1,7 @@ import { test, expect, type Browser, type BrowserContext, type Page } from '@playwright/test'; import { accountSwitcher, neutralizeDevOverlay } from './helpers/app'; -import { ENT, ENT_PASSWORD, adminContext, contextOptions, getPolicy, putPolicy, requireEntEnv, shot } from './helpers/ent'; +import { WEBMAIL_URL } from './helpers/config'; +import { ENT, ENT_PASSWORD, adminContext, contextOptions, getPolicy, jmapAs, putPolicy, requireEntEnv, shot } from './helpers/ent'; /** * The installed app's launches through the real UI: shortcuts, files opened @@ -149,6 +150,45 @@ async function workerReady(page: Page): Promise { expect(active).toBe(true); } +/** The question a share has to pass, wherever it came from. */ +const startMessage = (page: Page) => page.getByRole('button', { name: 'Start message', exact: true }); +const dontStart = (page: Page) => page.getByRole('button', { name: "Don't start", exact: true }); + +const HOSTILE = 'http://hostile.example'; + +/** A page on another origin that POSTs to the share target as soon as it loads. Nobody clicks. */ +function hostilePage(opts: { subject: string; fileName?: string }): string { + return `hostile`; +} + +async function hostileTab(context: BrowserContext, opts: Parameters[0]): Promise { + await context.route(`${HOSTILE}/**`, (route) => route.fulfill({ contentType: 'text/html', body: hostilePage(opts) })); + const tab = await context.newPage(); + await tab.goto(`${HOSTILE}/`); + return tab; +} + +/** Every message on the server with this subject. */ +async function draftsWithSubject(subject: string): Promise { + const client = await jmapAs(ENT.alice); + const res = await client.request([['Email/query', { accountId: client.accountId, filter: { subject } }, 'q']]); + return res.methodResponses[0][1].ids as string[]; +} + +async function deleteDrafts(ids: string[]): Promise { + if (ids.length === 0) return; + const client = await jmapAs(ENT.alice); + await client.request([['Email/set', { accountId: client.accountId, destroy: ids }, 'd']]); +} + const storedShareEntries = (page: Page) => page.evaluate(async (name) => { if (!(await caches.has(name))) return 0; return (await (await caches.open(name)).keys()).length; @@ -197,6 +237,7 @@ test.describe('share target', () => { { name: `shared-${RUN}.txt`, type: 'text/plain', body: 'shared bytes' }, ]); await page.waitForURL((url) => url.pathname.endsWith('/') || !url.pathname.includes('protocol')); + await startMessage(page).click({ timeout: 60_000 }); await signInIfAsked(page, ENT.alice); await expect(composer(page)).toBeVisible({ timeout: 30_000 }); @@ -205,7 +246,7 @@ test.describe('share target', () => { await expect(composer(page).getByText(`shared-${RUN}.txt`)).toBeVisible(); // It reached the app through the worker's store, not the server's fallback. await expect(page.getByText("The shared files couldn't be kept")).toHaveCount(0); - await expect.poll(() => new URL(page.url()).search).toBe(''); + await expect.poll(() => new URL(page.url()).hash).toBe(''); await expect.poll(() => storedShareEntries(page)).toBe(0); await shot(page, 'pwa-launch-share'); await page.context().close(); @@ -218,8 +259,9 @@ test.describe('share target', () => { await workerReady(page); await postShare(page, { url: 'https://example.org/waited' }, [{ name: `waited-${RUN}.txt`, type: 'text/plain', body: 'x' }]); + await startMessage(page).click({ timeout: 60_000 }); await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 }); - await expect.poll(() => storedShareEntries(page)).toBe(2); + await expect.poll(() => storedShareEntries(page)).toBe(3); // What SSO does: away and back, in the same tab. await page.reload(); await signIn(page, ENT.alice); @@ -234,6 +276,7 @@ test.describe('share target', () => { const page = await signedIn(browser, { serviceWorkers: 'block' }); await postShare(page, { url: 'https://example.org/no-worker' }, [{ name: 'lost.txt', type: 'text/plain', body: 'x' }]); await page.waitForLoadState('domcontentloaded'); + await startMessage(page).click({ timeout: 60_000 }); await signInIfAsked(page, ENT.alice); await expect(composer(page)).toBeVisible({ timeout: 30_000 }); @@ -252,10 +295,181 @@ test.describe('share target', () => { }); expect(res.status()).toBe(303); const location = new URL(res.headers().location, 'http://x.invalid'); - expect(location.searchParams.get('share')).toBe('unavailable'); - expect(location.searchParams.get('share_url')).toBe('https://example.org/direct'); + // In the fragment: shared text stays out of access logs. + expect(location.search).toBe(''); + const fragment = new URLSearchParams(location.hash.slice(1)); + expect(fragment.get('share')).toBe('unavailable'); + expect(fragment.get('share_url')).toBe('https://example.org/direct'); await context.close(); }); + + test('long non-Latin text fits the address the fallback sends on', async ({ browser }) => { + const context = await browser.newContext(contextOptions()); + const res = await context.request.post('/protocol/share', { + multipart: { text: 'あ'.repeat(3000) }, + maxRedirects: 0, + }); + expect(res.status()).toBe(303); + const location = res.headers().location; + expect(location.length).toBeLessThan(4000); + expect((await context.request.get(location)).ok()).toBe(true); + await context.close(); + }); +}); + +test.describe('a share nobody asked for', () => { + test('a POST from another site leaves no draft on the server and asks first', async ({ browser }) => { + const signedInPage = await signedIn(browser); + await workerReady(signedInPage); + const subject = `hostile-${RUN}-a`; + const tab = await hostileTab(signedInPage.context(), { subject, fileName: `invoice-${RUN}.exe` }); + await tab.waitForURL((url) => url.href.startsWith(WEBMAIL_URL)); + + // The question comes before anything else, also before a sign-in. + await expect(startMessage(tab)).toBeVisible({ timeout: 30_000 }).catch(() => {}); + if (await startMessage(tab).isVisible()) { + await expect(tab.getByTestId('share-confirm-details')).toContainText(`invoice-${RUN}.exe`); + } else { + await signInIfAsked(tab, ENT.alice); + } + await tab.waitForTimeout(6000); + const drafts = await draftsWithSubject(subject); + await deleteDrafts(drafts); + expect(drafts).toEqual([]); + await expect(composer(tab)).toHaveCount(0); + await expect(startMessage(tab)).toBeVisible(); + await shot(tab, 'pwa-launch-share-confirm'); + + // Refused: what it stored goes too. + await dontStart(tab).click(); + await expect.poll(() => storedShareEntries(tab)).toBe(0); + await expect(composer(tab)).toHaveCount(0); + await signedInPage.context().close(); + }); + + test('agreed, it is only the starting state: no draft until the first real edit', async ({ browser }) => { + const signedInPage = await signedIn(browser); + await workerReady(signedInPage); + const subject = `hostile-${RUN}-b`; + const tab = await hostileTab(signedInPage.context(), { subject, fileName: `kept-${RUN}.txt` }); + await tab.waitForURL((url) => url.href.startsWith(WEBMAIL_URL)); + await startMessage(tab).click({ timeout: 60_000 }); + await signInIfAsked(tab, ENT.alice); + await expect(composer(tab).getByText(`kept-${RUN}.txt`)).toBeVisible({ timeout: 30_000 }); + await expect(composer(tab).getByPlaceholder(/subject/i)).toHaveValue(subject); + + await tab.waitForTimeout(6000); + expect(await draftsWithSubject(subject)).toEqual([]); + + // Leaving doesn't save it either. + await tab.evaluate(() => { window.dispatchEvent(new Event('beforeunload')); }); + await tab.waitForTimeout(3000); + expect(await draftsWithSubject(subject)).toEqual([]); + + // The user's own edit: from here it is a draft like any other. + await composer(tab).getByPlaceholder(/subject/i).fill(`${subject} edited`); + await expect(composer(tab).getByPlaceholder(/subject/i)).toHaveValue(`${subject} edited`); + await expect.poll(() => draftsWithSubject(subject), { timeout: 20_000 }).toHaveLength(1); + await deleteDrafts(await draftsWithSubject(subject)); + await signedInPage.context().close(); + }); + + test('a hidden frame stores nothing', async ({ browser }) => { + const page = await (await launchContext(browser)).newPage(); + await page.goto('/'); + await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 }); + await workerReady(page); + // From the app's own origin: another site's frame doesn't reach this worker + // at all in Chromium (partitioned storage), so only this shows the check. + await page.evaluate(() => { + const frame = document.createElement('iframe'); + frame.name = 'hidden-share'; + frame.style.display = 'none'; + document.body.append(frame); + const form = document.createElement('form'); + form.method = 'POST'; + form.enctype = 'multipart/form-data'; + form.action = '/protocol/share'; + form.target = 'hidden-share'; + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'title'; + input.value = 'frame'; + form.append(input); + document.body.append(form); + form.submit(); + }); + await page.waitForTimeout(3000); + expect(await storedShareEntries(page)).toBe(0); + await page.context().close(); + }); + + test('text in an address anyone can link to asks first too', async ({ browser }) => { + const page = await signedIn(browser); + await page.goto(`/#share=unavailable&share_title=${encodeURIComponent(`linked-${RUN}`)}&share_text=hello`); + await expect(startMessage(page)).toBeVisible({ timeout: 30_000 }); + await expect(composer(page)).toHaveCount(0); + await dontStart(page).click(); + await expect(composer(page)).toHaveCount(0); + await page.context().close(); + }); +}); + +test.describe('a handoff while a composer is open', () => { + test('one set aside by reading a message is not replaced by the next', async ({ browser }) => { + const page = await signedIn(browser); + await page.locator('[data-testid="email-list-item"]').first().waitFor({ timeout: 30_000 }); + const subject = `setaside${RUN}`; + await launch(page, { target: '/?compose=1' }); + await expect(composer(page)).toBeVisible({ timeout: 30_000 }); + await composer(page).getByPlaceholder(/subject/i).fill(subject); + await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue(subject); + // Reading a message suspends the composer before its autosave ran. + await page.locator('[data-testid="email-list-item"]').first().click(); + await expect(composer(page)).toHaveCount(0); + await page.waitForTimeout(2100); + await launch(page, { target: '/?compose=1' }); + await expect(page.getByText('Close this message to start the next one.').first()).toBeVisible({ timeout: 10_000 }); + await expect(composer(page)).toHaveCount(0); + await expect(page.getByText(subject).first()).toBeVisible(); + await page.context().close(); + }); + + test('two at once: the second waits until the first composer closes', async ({ browser }) => { + const page = await signedIn(browser); + // Let the page settle after the sign-in: the composer shouldn't race the first mail load. + await page.waitForTimeout(3000); + await launch(page, { target: '/?compose=1' }); + await expect(composer(page)).toBeVisible({ timeout: 30_000 }); + const subject = `kept-open-${RUN}`; + await composer(page).getByPlaceholder(/subject/i).fill(subject); + await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue(subject); + + await page.waitForTimeout(2100); + await launch(page, { target: '/?compose=1' }); + await expect(page.getByText('Close this message to start the next one.').first()).toBeVisible({ timeout: 10_000 }); + await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue(subject); + + await page.locator('[data-testid="composer-close"]').first().click(); + await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue('', { timeout: 15_000 }); + await deleteDrafts(await draftsWithSubject(subject)); + await page.context().close(); + }); + + test('a .vcf launch saves what was just typed before leaving the composer', async ({ browser }) => { + const page = await signedIn(browser); + await launch(page, { target: '/?compose=1' }); + await expect(composer(page)).toBeVisible({ timeout: 30_000 }); + const subject = `typedfirst${RUN}`; + await composer(page).getByPlaceholder(/subject/i).fill(subject); + // Well inside the autosave's 2 seconds. + await launch(page, { target: '/contacts', files: [VCF] }); + await page.waitForURL(/\/contacts/); + await expect(page.getByText(`Launched Person ${RUN}`).first()).toBeVisible({ timeout: 30_000 }); + await expect.poll(() => draftsWithSubject(subject), { timeout: 10_000 }).toHaveLength(1); + await deleteDrafts(await draftsWithSubject(subject)); + await page.context().close(); + }); }); test.describe('file launches', () => { @@ -365,7 +579,7 @@ test.describe('file launches', () => { await page.goto('/'); await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 }); await launch(page, { target: '/calendar', files: [ICS] }); - await expect.poll(() => storedShareEntries(page)).toBe(2); + await expect.poll(() => storedShareEntries(page)).toBe(3); await page.reload(); await signIn(page, ENT.alice); await page.waitForURL(/\/calendar/); @@ -432,6 +646,7 @@ test.describe('in the Pro shell', () => { await workerReady(page); await postShare(page, { url: 'https://example.org/pro' }, [{ name: `pro-${RUN}.txt`, type: 'text/plain', body: 'x' }]); + await startMessage(page).click({ timeout: 60_000 }); await signInIfAsked(page, ENT.alice); await page.waitForURL(/\/pro/); await expect(page.getByText(`pro-${RUN}.txt`).first()).toBeVisible({ timeout: 30_000 }); diff --git a/lib/__tests__/launch-handoff.test.ts b/lib/__tests__/launch-handoff.test.ts index 5b21f7abe..9dafa8f5d 100644 --- a/lib/__tests__/launch-handoff.test.ts +++ b/lib/__tests__/launch-handoff.test.ts @@ -3,21 +3,27 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FakeCaches, loadWorker } from './sw-harness'; import { HANDOFF_CACHE, + HANDOFF_MAX_BYTES, + HANDOFF_MAX_ENTRIES, HANDOFF_TTL_MS, adoptStoredHandoff, clearHandoffs, + discardStoredHandoff, + expireHandoffs, handOffDraft, handOffFiles, handOffLaunchFiles, handOffNewMessage, kindForFile, parseLaunchLanding, + peekStoredHandoff, pendingHandoffs, restoreStoredHandoffs, shareDraft, shortcutFromTargetUrl, stripLandingParams, subscribeHandoff, + subscribeHandoffLost, takeHandoff, } from '../launch-handoff'; @@ -25,7 +31,9 @@ import { 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)); +// Sealing runs on Web Crypto's thread pool. +const settle = () => new Promise((r) => setTimeout(r, 60)); +const whenStored = () => vi.waitFor(() => expect(sessionStorage.getItem('bulwark:launch-handoffs')).not.toBeNull(), { timeout: 5000 }); function memoryStorage() { const backing = new Map(); @@ -163,7 +171,7 @@ describe('across a page load', () => { 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 whenStored(); await newPage(); expect(takeHandoff('calendar')).toBeNull(); @@ -176,9 +184,9 @@ describe('across a page load', () => { it('removes the stored copy once it is taken', async () => { handOffFiles([file('a.ics')], 'calendar'); - await settle(); + await whenStored(); const store = await caches.open(HANDOFF_CACHE); - expect((await store.keys()).length).toBe(2); + expect((await store.keys()).length).toBe(3); takeHandoff('calendar'); await settle(); @@ -195,29 +203,104 @@ describe('across a page load', () => { it("leaves another window's handoff alone", async () => { handOffFiles([file('a.ics')], 'calendar'); - await settle(); + await whenStored(); 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); + expect((await (await caches.open(HANDOFF_CACHE)).keys()).length).toBe(3); + }); + + it('keeps no readable bytes, name or text at rest', async () => { + handOffDraft({ subject: 'Secret subject', body: 'Secret text', files: [file('secret-name.txt', 'text/plain', 'secret bytes')] }); + await whenStored(); + const store = await caches.open(HANDOFF_CACHE); + const keys = await store.keys(); + expect(keys.length).toBeGreaterThanOrEqual(3); + for (const key of keys) { + const stored = await (await store.match(key))!.text(); + for (const secret of ['Secret subject', 'Secret text', 'secret-name', 'secret bytes']) expect(stored).not.toContain(secret); + } + }); + + it('cannot read it once the tab with the key is gone', async () => { + handOffFiles([file('a.ics')], 'calendar'); + await whenStored(); + await newPage(); + vi.stubGlobal('sessionStorage', memoryStorage()); + const store = await caches.open(HANDOFF_CACHE); + const metaKey = (await store.keys()).find((key) => key.url.endsWith('/meta'))!.url; + const id = metaKey.split('/').slice(-2)[0]; + expect(await adoptStoredHandoff(id)).toBe(false); + expect(await adoptStoredHandoff(id, 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')).toBe(false); + }); + + it('says so when a stored handoff of this tab is gone after the reload', async () => { + const losses: string[] = []; + const off = subscribeHandoffLost((loss) => losses.push(loss)); + handOffFiles([file('a.ics')], 'calendar'); + await whenStored(); + await newPage(); + await caches.delete(HANDOFF_CACHE); + await restoreStoredHandoffs(); + expect(losses).toEqual(['expired']); + expect(sessionStorage.getItem('bulwark:launch-handoffs')).toBeNull(); + off(); + }); + + it('says so when a stored file is missing, and delivers the rest', async () => { + const losses: string[] = []; + const off = subscribeHandoffLost((loss) => losses.push(loss)); + handOffDraft({ subject: 'S', files: [file('one.txt'), file('two.txt')] }); + await whenStored(); + await newPage(); + const store = await caches.open(HANDOFF_CACHE); + const first = (await store.keys()).find((key) => key.url.endsWith('/0'))!; + await store.delete(first); + await restoreStoredHandoffs(); + expect(losses).toEqual(['missing']); + expect(takeHandoff('compose')?.files.map((f) => f.name)).toEqual(['two.txt']); + off(); + }); + + it('keeps no more than the limit, oldest out first', async () => { + const created: string[] = []; + for (let n = 0; n < HANDOFF_MAX_ENTRIES + 2; n += 1) { + const name = `f${n}.ics`; + handOffFiles([file(name)], 'calendar'); + created.push(name); + await settle(); + await settle(); + } + const store = await caches.open(HANDOFF_CACHE); + const metas = (await store.keys()).filter((key) => key.url.endsWith('/meta')); + expect(metas.length).toBeLessThanOrEqual(HANDOFF_MAX_ENTRIES); + }); + + it('does not store what is over the byte limit, and keeps it in memory', async () => { + const big = file('big.ics'); + Object.defineProperty(big, 'size', { value: HANDOFF_MAX_BYTES + 1 }); + handOffFiles([big], 'calendar'); + await settle(); + expect(await caches.has(HANDOFF_CACHE)).toBe(false); + expect(takeHandoff('calendar')?.files).toEqual([big]); }); it('sweeps what nobody took, and keeps what is recent', async () => { vi.useFakeTimers({ toFake: ['Date'] }); handOffFiles([file('old.ics')], 'calendar'); - await settle(); + await whenStored(); await newPage(); vi.stubGlobal('sessionStorage', memoryStorage()); vi.advanceTimersByTime(HANDOFF_TTL_MS + 1); handOffFiles([file('new.vcf')], 'contacts'); - await settle(); + await whenStored(); await restoreStoredHandoffs(); const store = await caches.open(HANDOFF_CACHE); const urls = (await store.keys()).map((key) => key.url); - expect(urls).toHaveLength(2); + expect(urls).toHaveLength(3); const meta = await store.match(urls.find((url) => url.endsWith('/meta'))!); expect(((await meta!.json()) as { kind: string }).kind).toBe('contacts'); }); @@ -245,10 +328,47 @@ describe('across a page load', () => { }); }); +describe('while the page stays open', () => { + it('drops what waited too long on a timer, and says so', async () => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }); + const losses: string[] = []; + const off = subscribeHandoffLost((loss) => losses.push(loss)); + handOffFiles([file('a.eml')], 'eml'); + await vi.advanceTimersByTimeAsync(HANDOFF_TTL_MS + 2000); + expect(pendingHandoffs()).toEqual([]); + expect(losses).toEqual(['expired']); + off(); + }); + + it('says so when a taker finds only an expired handoff', () => { + vi.useFakeTimers({ toFake: ['Date'] }); + const losses: string[] = []; + const off = subscribeHandoffLost((loss) => losses.push(loss)); + handOffFiles([file('a.eml')], 'eml'); + vi.advanceTimersByTime(HANDOFF_TTL_MS + 1); + expect(takeHandoff('eml')).toBeNull(); + expect(losses).toEqual(['expired']); + off(); + }); + + it('keeps what is still in time', async () => { + handOffFiles([file('a.eml')], 'eml'); + await expireHandoffs(); + expect(pendingHandoffs()).toHaveLength(1); + }); + + it('leaves the second of two drafts queued until it is taken', () => { + handOffDraft({ subject: 'first' }); + handOffDraft({ subject: 'second' }); + expect(takeHandoff('compose')?.subject).toBe('first'); + expect(pendingHandoffs().map((h) => h.subject)).toEqual(['second']); + }); +}); + describe('sign-out', () => { it('forgets what is waiting, in memory and stored', async () => { handOffFiles([file('a.ics')], 'calendar'); - await settle(); + await whenStored(); clearHandoffs(); await settle(); expect(pendingHandoffs()).toEqual([]); @@ -266,12 +386,18 @@ describe('what the service worker stored', () => { 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); + const answer = await worker.request({ url: 'https://mail.example.com/protocol/share', method: 'POST', mode: 'navigate', destination: 'document', formData: async () => form } as never); + const location = new URL(answer!.headers.get('location')!); + const landing = parseLaunchLanding(location.search, location.hash); expect(landing?.type).toBe('stored'); - const { id } = landing as { id: string }; + const { id, key } = landing as { id: string; key: string }; - expect(await adoptStoredHandoff(id)).toBe(true); + // Without the key from the address it can't be read; asking shows what it is. + expect(await peekStoredHandoff(id, '')).toBeNull(); + expect(await peekStoredHandoff(id, key)).toEqual({ subject: 'A link', body: 'Look at this\n\nhttps://example.org/page', files: ['photo.png'] }); + expect(pendingHandoffs()).toEqual([]); + + expect(await adoptStoredHandoff(id, key)).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'); @@ -283,13 +409,37 @@ describe('what the service worker stored', () => { }); it('is gone when the id names nothing', async () => { - expect(await adoptStoredHandoff('11111111-2222')).toBe(false); + expect(await adoptStoredHandoff('11111111-2222', 'AAAA')).toBe(false); + expect(await peekStoredHandoff('11111111-2222', 'AAAA')).toBeNull(); + }); + + it('goes when the user refuses it', async () => { + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + const form = new FormData(); + form.append('files', file('photo.png')); + const answer = await worker.request({ url: 'https://mail.example.com/protocol/share', method: 'POST', mode: 'navigate', destination: 'document', formData: async () => form } as never); + const location = new URL(answer!.headers.get('location')!); + const { id, key } = parseLaunchLanding(location.search, location.hash) as { id: string; key: string }; + await discardStoredHandoff(id); + expect((await (await caches.open(HANDOFF_CACHE)).keys()).length).toBe(0); + expect(await peekStoredHandoff(id, key)).toBeNull(); + }); + + it('cleans control characters out of names and text, as for a mailto: link', async () => { + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + const form = new FormData(); + form.set('title', 'Hi\r\nBcc: evil@example.org'); + form.append('files', file('a\u202Egnp.exe')); + const answer = await worker.request({ url: 'https://mail.example.com/protocol/share', method: 'POST', mode: 'navigate', destination: 'document', formData: async () => form } as never); + const location = new URL(answer!.headers.get('location')!); + const { id, key } = parseLaunchLanding(location.search, location.hash) as { id: string; key: string }; + expect(await peekStoredHandoff(id, key)).toMatchObject({ subject: 'HiBcc: evil@example.org', files: ['agnp.exe'] }); }); 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); + await store.put('/__bulwark/handoff/old/meta', new Response(JSON.stringify({ kind: 'compose', createdAt: Date.now() - HANDOFF_TTL_MS - 1, bytes: 0 }))); + expect(await adoptStoredHandoff('old', 'AAAA')).toBe(false); expect((await store.keys()).length).toBe(0); }); }); @@ -306,25 +456,55 @@ describe('text of a share', () => { it('copes with only a link', () => { expect(shareDraft({ url: 'https://a.example' })).toEqual({ subject: '', body: 'https://a.example' }); }); + + it('strips control characters from the subject, so no header can be planted', () => { + expect(shareDraft({ title: 'Hi\r\nBcc: evil@example.org' }).subject).toBe('HiBcc: evil@example.org'); + }); + + it('keeps line breaks in the body and drops other control characters', () => { + expect(shareDraft({ text: 'one\r\ntwo\u0000\u0007' }).body).toBe('one\ntwo'); + }); + + it('applies the limits of a mailto: link', () => { + const draft = shareDraft({ title: 'x'.repeat(2000), text: 'y'.repeat(100_000), url: 'https://a.example' }); + expect(draft.subject).toHaveLength(998); + expect(draft.body).toHaveLength(64 * 1024); + }); + + it('applies them to a draft handed in directly as well', () => { + handOffDraft({ subject: 'a\r\nb', body: 'y'.repeat(100_000) }); + const handoff = takeHandoff('compose'); + expect(handoff?.subject).toBe('ab'); + expect(handoff?.body).toHaveLength(64 * 1024); + }); }); 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); + ['?compose=1', '', { type: 'new-message' }], + ['', '#share=abc-123&key=k1', { type: 'stored', id: 'abc-123', key: 'k1' }], + ['', '#share=unavailable&share_title=T&share_text=B&share_files=1', { type: 'inline', subject: 'T', body: 'B', filesLost: true, cut: false }], + ['', '#share=unavailable&share_url=https%3A%2F%2Fa.example&share_cut=1', { type: 'inline', subject: '', body: 'https://a.example', filesLost: false, cut: true }], + ['', '#share=..%2F..%2Fetc', null], + // Shared text stays out of the query, which reaches logs. + ['?share=unavailable&share_text=x', '', null], + ['?compose=0', '', null], + ['?openLatestUnread=1', '#x', null], + ['', '', null], + ])('%s %s', (search, hash, expected) => { + expect(parseLaunchLanding(search, hash)).toEqual(expected); + }); + + it('cleans text that came in the address', () => { + const landing = parseLaunchLanding('', '#share=unavailable&share_title=Hi%0D%0ABcc%3A%20evil'); + expect(landing).toMatchObject({ subject: 'HiBcc: evil' }); }); 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'); + expect(stripLandingParams('?compose=1', '')).toEqual({ search: '', hash: '' }); + expect(stripLandingParams('?slot=2', '#share=unavailable&share_text=x')).toEqual({ search: '?slot=2', hash: '' }); + expect(stripLandingParams('?compose=1&slot=2', '#section')).toEqual({ search: '?slot=2', hash: '#section' }); + expect(stripLandingParams('', '#share=a&key=b')).toEqual({ search: '', hash: '' }); }); }); diff --git a/lib/__tests__/manifest-launch.test.ts b/lib/__tests__/manifest-launch.test.ts index 124f67b55..c4c2794f4 100644 --- a/lib/__tests__/manifest-launch.test.ts +++ b/lib/__tests__/manifest-launch.test.ts @@ -160,7 +160,9 @@ describe('share route without a service worker', () => { 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/' }); + // In the fragment: it never reaches an access log. + expect(landing.search).toBe(''); + expect(Object.fromEntries(new URLSearchParams(landing.hash.slice(1)))).toEqual({ share: 'unavailable', share_title: 'T', share_text: 'B', share_url: 'https://example.org/' }); }); it('says files were lost', async () => { @@ -169,7 +171,7 @@ describe('share route without a service worker', () => { 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'); + expect(res.headers.get('location')).toContain('share_files=1'); }); it('does not read a large upload into memory', async () => { @@ -180,15 +182,34 @@ describe('share route without a service worker', () => { 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'); + expect(res.headers.get('location')).toContain('share_files=1'); }); - it('keeps the address short', async () => { + it.each([ + ['Latin', 'x'], + ['Japanese, 9 encoded bytes each', 'あ'], + ['emoji, whole characters only', '😀'], + ])('keeps the address short and says it cut: %s', async (_name, character) => { const { POST } = await route(); const form = new FormData(); - form.set('text', 'x'.repeat(100_000)); + form.set('text', character.repeat(3000)); + form.set('title', character.repeat(3000)); + form.set('url', character.repeat(3000)); const res = await POST(post(form, '100000')); - expect(res.headers.get('location')!.length).toBeLessThan(4200); + const location = res.headers.get('location')!; + expect(location.length).toBeLessThan(4000); + const fragment = new URLSearchParams(new URL(location, 'https://x.example').hash.slice(1)); + expect(fragment.get('share_cut')).toBe('1'); + // Decodes cleanly: no half a character at the end. + expect(fragment.get('share_text')!.endsWith(character)).toBe(true); + }); + + it('does not say it cut what fits', async () => { + const { POST } = await route(); + const form = new FormData(); + form.set('text', 'あ'.repeat(100)); + const res = await POST(post(form, '1000')); + expect(res.headers.get('location')).not.toContain('share_cut'); }); it('honours the subpath', async () => { diff --git a/lib/__tests__/sw-harness.ts b/lib/__tests__/sw-harness.ts index 3f3479d40..f74964783 100644 --- a/lib/__tests__/sw-harness.ts +++ b/lib/__tests__/sw-harness.ts @@ -39,7 +39,7 @@ export function loadWorker(opts: { buildId: string; caches: FakeCaches; clients: 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, crypto: globalThis.crypto }); + 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, TextEncoder, btoa }); vm.runInContext(SW_SOURCE, context); async function dispatch(type: string, event: Record = {}) { const waits: Promise[] = []; diff --git a/lib/__tests__/sw-share-target.test.ts b/lib/__tests__/sw-share-target.test.ts index 799517e37..57509a55f 100644 --- a/lib/__tests__/sw-share-target.test.ts +++ b/lib/__tests__/sw-share-target.test.ts @@ -3,13 +3,17 @@ 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. +// launch while offline. Any site can POST here, signed in or not, so what the +// worker keeps is limited and sealed. Before: a POST went to the server when +// offline mode was off, and files sat readable in the cache with no limit. const ORIGIN = 'https://mail.example.com'; +const CACHE = 'bulwark-launch-handoff-v1'; +const TTL = 15 * 60 * 1000; const settle = () => new Promise((r) => setTimeout(r, 0)); function shareRequest(form: FormData, overrides: Record = {}) { - return { url: `${ORIGIN}/protocol/share`, method: 'POST', mode: 'navigate', formData: async () => form, ...overrides } as never; + return { url: `${ORIGIN}/protocol/share`, method: 'POST', mode: 'navigate', destination: 'document', formData: async () => form, ...overrides } as never; } function sharedForm(): FormData { @@ -17,15 +21,31 @@ function sharedForm(): 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' })); + form.append('files', new File(['secret bytes'], 'photo.png', { type: 'image/png' })); return form; } +const landingOf = (answer: Response | null) => { + const url = new URL(answer!.headers.get('location')!); + return { url, fragment: new URLSearchParams(url.hash.slice(1)) }; +}; + 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 }))); } +async function plantEntry(caches: FakeCaches, id: string, createdAt: number, bytes = 0) { + const store = await caches.open(CACHE); + await store.put(`/__bulwark/handoff/${id}/meta`, new Response(JSON.stringify({ kind: 'compose', createdAt, bytes }))); + await store.put(`/__bulwark/handoff/${id}/sealed`, new Response('x')); +} + +async function entryIds(caches: FakeCaches): Promise { + const store = await caches.open(CACHE); + return (await store.keys()).filter((key) => key.url.endsWith('/meta')).map((key) => key.url.split('/').slice(-2)[0]); +} + function htmlPage(body: string) { const res = new Response(body, { status: 200, headers: { 'Content-Type': 'text/html' } }); Object.defineProperty(res, 'type', { value: 'basic' }); @@ -33,7 +53,7 @@ function htmlPage(body: string) { } 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) => { + it.each([[false], [true]])('takes the POST with offline mode on=%s, keeps the files sealed and goes to the app', async (on) => { const caches = new FakeCaches(); await shellOn(caches, on); const fetchMock = vi.fn(); @@ -44,15 +64,23 @@ describe('share target', () => { 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')!; + const { url, fragment } = landingOf(answer); + expect(`${url.origin}${url.pathname}`).toBe(`${ORIGIN}/`); + // Id and key in the fragment: neither ever reaches the server. + expect(url.search).toBe(''); + const id = fragment.get('share')!; expect(id).toMatch(/^[\w-]+$/); + expect(fragment.get('key')).toMatch(/^[\w-]{43}$/); - const store = await caches.open('bulwark-launch-handoff-v1'); + const store = await caches.open(CACHE); const meta = (await (await store.match(`/__bulwark/handoff/${id}/meta`))!.json()) as Record; - 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'); + expect(meta).toMatchObject({ kind: 'compose', bytes: 12 }); + // Only what a sweep needs is readable. + expect(Object.keys(meta).sort()).toEqual(['bytes', 'createdAt', 'kind']); + for (const part of ['meta', 'sealed', '0']) { + const text = await (await store.match(`/__bulwark/handoff/${id}/${part}`))!.text(); + for (const secret of ['secret bytes', 'photo.png', 'A link', 'example.org']) expect(text).not.toContain(secret); + } }); it('answers while the worker has only just started and has not read its state', async () => { @@ -65,7 +93,7 @@ describe('share target', () => { 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=`); + expect(answer!.headers.get('location')).toContain(`${ORIGIN}/webmail/#share=`); }); it('skips empty file fields', async () => { @@ -75,37 +103,54 @@ describe('share target', () => { 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([]); + const id = landingOf(answer).fragment.get('share')!; + const store = await caches.open(CACHE); + const meta = (await (await store.match(`/__bulwark/handoff/${id}/meta`))!.json()) as { bytes: number }; + expect(meta.bytes).toBe(0); + expect(await store.match(`/__bulwark/handoff/${id}/0`)).toBeUndefined(); }); 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' + const store = await original(CACHE); + vi.spyOn(caches, 'open').mockImplementation(async (name: string) => (name === CACHE ? { ...store, put: async () => { throw new Error('QuotaExceededError'); } } : original(name))); const worker = loadWorker({ buildId: 'a', caches, clients: [] }); - const answer = await worker.request(shareRequest(sharedForm())); + const { fragment } = landingOf(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(fragment.get('share')).toBe('unavailable'); + expect(fragment.get('share_files')).toBe('1'); + expect(fragment.get('share_title')).toBe('A link'); + expect(fragment.get('share_url')).toBe('https://example.org/'); expect((await store.keys()).length).toBe(0); }); + it('keeps that address short too, and says it cut', async () => { + const caches = new FakeCaches(); + const original = caches.open.bind(caches); + const store = await original(CACHE); + vi.spyOn(caches, 'open').mockImplementation(async (name: string) => (name === CACHE + ? { ...store, put: async () => { throw new Error('QuotaExceededError'); } } + : original(name))); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + const form = new FormData(); + form.set('text', 'あ'.repeat(3000)); + + const answer = await worker.request(shareRequest(form)); + + expect(answer!.headers.get('location')!.length).toBeLessThan(4000); + expect(landingOf(answer).fragment.get('share_cut')).toBe('1'); + }); + 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'); + const { fragment } = landingOf(answer); + expect(fragment.get('share')).toBe('unavailable'); + expect(fragment.get('share_files')).toBe('1'); }); it('leaves other requests alone', async () => { @@ -117,8 +162,94 @@ describe('share target', () => { 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); + expect(await worker.request({ url: `${ORIGIN}/protocol/share`, mode: 'navigate', destination: 'document' } as never)).toBeNull(); + expect(await caches.has(CACHE)).toBe(false); + }); + + it('leaves a POST into a hidden frame to the network: it stores nothing', async () => { + const caches = new FakeCaches(); + await shellOn(caches, false); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + await settle(); + expect(await worker.request(shareRequest(sharedForm(), { destination: 'iframe' }))).toBeNull(); + expect(await worker.request(shareRequest(sharedForm(), { destination: 'embed' }))).toBeNull(); + expect(await caches.has(CACHE)).toBe(false); + }); +}); + +describe('limits on what the worker keeps', () => { + it('drops the oldest entries over the entry limit, on every share', async () => { + const caches = new FakeCaches(); + const now = Date.now(); + for (let n = 0; n < 5; n += 1) await plantEntry(caches, `old-${n}`, now - 1000 * (5 - n)); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + + const answer = await worker.request(shareRequest(sharedForm())); + + const ids = await entryIds(caches); + expect(ids).toHaveLength(5); + expect(ids).not.toContain('old-0'); + expect(ids).toContain('old-1'); + expect(ids).toContain(landingOf(answer).fragment.get('share')); + // Everything of the dropped one goes, not only its list. + expect(await (await caches.open(CACHE)).match('/__bulwark/handoff/old-0/sealed')).toBeUndefined(); + }); + + it('drops the oldest entries over the byte limit', async () => { + const caches = new FakeCaches(); + const now = Date.now(); + await plantEntry(caches, 'big', now - 2000, 100 * 1024 * 1024 - 5); + await plantEntry(caches, 'small', now - 1000, 2); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + + await worker.request(shareRequest(sharedForm())); + + const ids = await entryIds(caches); + expect(ids).not.toContain('big'); + expect(ids).toContain('small'); + }); + + it('stores nothing from a share over the byte limit, says the files are lost, and keeps the rest', async () => { + const caches = new FakeCaches(); + await plantEntry(caches, 'kept', Date.now() - 1000, 10); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + const big = new File(['x'], 'huge.bin'); + Object.defineProperty(big, 'size', { value: 100 * 1024 * 1024 + 1 }); + const fields = new Map([['title', 'T']]); + const form = { get: (name: string) => fields.get(name) ?? null, getAll: () => [big] } as unknown as FormData; + + const { fragment } = landingOf(await worker.request(shareRequest(form))); + + expect(fragment.get('share')).toBe('unavailable'); + expect(fragment.get('share_files')).toBe('1'); + expect(await entryIds(caches)).toEqual(['kept']); + }); + + it('sweeps what is too old on every share', async () => { + const caches = new FakeCaches(); + await plantEntry(caches, 'stale', Date.now() - TTL - 1); + await plantEntry(caches, 'fresh', Date.now() - 1000); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + await worker.request(shareRequest(sharedForm())); + const ids = await entryIds(caches); + expect(ids).not.toContain('stale'); + expect(ids).toContain('fresh'); + }); + + it('sweeps what is too old when it activates', async () => { + const caches = new FakeCaches(); + await plantEntry(caches, 'stale', Date.now() - TTL - 1); + await plantEntry(caches, 'fresh', Date.now() - 1000); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + await worker.dispatch('activate'); + expect(await entryIds(caches)).toEqual(['fresh']); + }); + + it('creates no cache when it activates with nothing kept', async () => { + const caches = new FakeCaches(); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + await worker.dispatch('activate'); + expect(await caches.has(CACHE)).toBe(false); }); }); diff --git a/lib/launch-handoff.ts b/lib/launch-handoff.ts index a072cf0ac..8f5be39ec 100644 --- a/lib/launch-handoff.ts +++ b/lib/launch-handoff.ts @@ -14,12 +14,24 @@ * 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. + * still be there afterwards. + * + * What is stored is sealed with a key of its own, and the key stays in this + * tab's sessionStorage, so a second window can't take it. The worker passes + * its key in the landing address's fragment. What is stored goes when it is + * taken, after HANDOFF_TTL_MS, when newer handoffs need the room, at sign-out + * and when offline mode wipes. + * + * Anyone can POST to the share target, or link to a landing address, so what + * arrives that way is untrusted: the provider asks the user before it gets + * here (peekStoredHandoff, then adoptStoredHandoff or discardStoredHandoff). + * Chromium's link capturing can bring a cross-site form POST into an installed + * app's window, so that goes for app windows too. Only a bridge skips it. */ +import { stripBidiControls } from '@/lib/bidi'; import { matchSurface } from '@/lib/deep-links'; +import { cleanMailtoBody, cleanMailtoSubject } from '@/lib/protocol-handlers/mailto'; export type HandoffKind = 'compose' | 'eml' | 'calendar' | 'contacts'; @@ -33,21 +45,28 @@ export interface Handoff { createdAt: number; } -/** The service worker writes this cache too, in the same layout. */ +/** The service worker writes this cache too, in the same layout and with the same limits. */ 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; +export const HANDOFF_MAX_ENTRIES = 5; +export const HANDOFF_MAX_BYTES = 100 * 1024 * 1024; +const STORE_PREFIX = '/__bulwark/handoff/'; +const TAB_KEYS_KEY = 'bulwark:launch-handoffs'; /** 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; +export type HandoffLoss = 'expired' | 'missing'; const queue: Handoff[] = []; const stored = new Map>(); const kindListeners = new Map>(); const anyListeners = new Set(); +const lossListeners = new Set<(loss: HandoffLoss) => void>(); let lastNewMessageAt = 0; +let expiryTimer: ReturnType | null = null; +/** Bumped by clearHandoffs: a store still under way when it ran takes itself back. */ +let clearCount = 0; function newId(): string { return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' @@ -59,41 +78,137 @@ function cacheApi(): CacheStorage | null { return typeof caches !== 'undefined' && caches && typeof caches.open === 'function' ? caches : null; } -function readTabIds(): string[] { +function readTabKeys(): Record { try { - const parsed: unknown = JSON.parse(sessionStorage.getItem(TAB_IDS_KEY) ?? '[]'); - return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []; + const parsed: unknown = JSON.parse(sessionStorage.getItem(TAB_KEYS_KEY) ?? '{}'); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}; } catch { - return []; + return {}; } } -function writeTabIds(ids: string[]): void { +function writeTabKeys(keys: Record): void { try { - if (ids.length > 0) sessionStorage.setItem(TAB_IDS_KEY, JSON.stringify(ids)); - else sessionStorage.removeItem(TAB_IDS_KEY); + if (Object.keys(keys).length > 0) sessionStorage.setItem(TAB_KEYS_KEY, JSON.stringify(keys)); + else sessionStorage.removeItem(TAB_KEYS_KEY); } catch { // Hardened or private modes: the handoff stays in memory only. } } +function setTabKey(id: string, key: string | null): void { + const keys = readTabKeys(); + if (key) keys[id] = key; + else delete keys[id]; + writeTabKeys(keys); +} + +function toBase64Url(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function fromBase64Url(text: string): Uint8Array { + const binary = atob(text.replace(/-/g, '+').replace(/_/g, '/')); + const bytes = new Uint8Array(new ArrayBuffer(binary.length)); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return bytes; +} + +/** A 12-byte IV, then the AES-GCM ciphertext. */ +async function seal(key: CryptoKey, data: BufferSource): Promise> { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const body = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data)); + const sealed = new Uint8Array(new ArrayBuffer(iv.length + body.length)); + sealed.set(iv); + sealed.set(body, iv.length); + return sealed; +} + +async function unseal(key: CryptoKey, sealed: ArrayBuffer): Promise { + return crypto.subtle.decrypt({ name: 'AES-GCM', iv: sealed.slice(0, 12) }, key, sealed.slice(12)); +} + +function canSeal(): boolean { + return typeof crypto !== 'undefined' && !!crypto.subtle; +} + +function entryPrefix(id: string): string { + return `${STORE_PREFIX}${id}/`; +} + +function pathOf(key: { url: string }): string { + return new URL(key.url, 'https://x.invalid').pathname; +} + +async function deleteEntry(cache: Cache, prefix: string): Promise { + const keys = await cache.keys(); + const own = keys.filter((key) => pathOf(key).startsWith(prefix)); + // The list last: the sweep goes by it. + await Promise.all(own.filter((key) => !pathOf(key).endsWith('/meta')).map((key) => cache.delete(key))); + await cache.delete(`${prefix}meta`); +} + +/** + * Drops what is too old and, when `incoming` wants in, the oldest entries that + * are in the way. Returns when the oldest remaining entry was stored. + */ +async function sweep(cache: Cache, incoming: { bytes: number } | null): Promise { + const entries: Array<{ prefix: string; createdAt: number; bytes: number }> = []; + for (const key of await cache.keys()) { + const path = pathOf(key); + if (!path.endsWith('/meta')) continue; + const response = await cache.match(key); + const meta = response ? ((await response.json().catch(() => null)) as { createdAt?: unknown; bytes?: unknown } | null) : null; + entries.push({ + prefix: path.slice(0, -'meta'.length), + createdAt: typeof meta?.createdAt === 'number' ? meta.createdAt : 0, + bytes: typeof meta?.bytes === 'number' ? meta.bytes : 0, + }); + } + entries.sort((a, b) => a.createdAt - b.createdAt); + const now = Date.now(); + let total = entries.reduce((sum, entry) => sum + entry.bytes, 0); + while (entries.length > 0) { + const tooOld = now - entries[0].createdAt > HANDOFF_TTL_MS; + const inTheWay = incoming !== null + && (entries.length >= HANDOFF_MAX_ENTRIES || total + incoming.bytes > HANDOFF_MAX_BYTES); + if (!tooOld && !inTheWay) break; + const [oldest] = entries.splice(0, 1); + total -= oldest.bytes; + await deleteEntry(cache, oldest.prefix); + } + return entries[0]?.createdAt ?? null; +} + async function writeStored(handoff: Handoff): Promise { const api = cacheApi(); - if (!api) return; - const prefix = STORE_PREFIX + handoff.id + '/'; + if (!api || !canSeal()) return; + const bytes = handoff.files.reduce((sum, file) => sum + file.size, 0); + if (bytes > HANDOFF_MAX_BYTES) return; + const prefix = entryPrefix(handoff.id); + const startedAt = clearCount; try { const cache = await api.open(HANDOFF_CACHE); + await sweep(cache, { bytes }); + const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); + const rawKey = new Uint8Array(await crypto.subtle.exportKey('raw', key)); // 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, + await cache.put(`${prefix}meta`, new Response(JSON.stringify({ kind: handoff.kind, createdAt: handoff.createdAt, bytes }))); + const contents = JSON.stringify({ 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]); + }); + await cache.put(`${prefix}sealed`, new Response(await seal(key, new TextEncoder().encode(contents)))); + for (const [index, file] of handoff.files.entries()) { + await cache.put(`${prefix}${index}`, new Response(await seal(key, await file.arrayBuffer()))); + } + if (startedAt !== clearCount) { + await deleteEntry(cache, prefix); + return; + } + setTabKey(handoff.id, toBase64Url(rawKey)); } catch { // Quota, or no Cache API here: the handoff stays in memory only. await deleteStored(handoff.id); @@ -101,18 +216,12 @@ async function writeStored(handoff: Handoff): Promise { } async function deleteStored(id: string): Promise { - writeTabIds(readTabIds().filter((other) => other !== id)); + setTabKey(id, null); 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'); + await deleteEntry(await api.open(HANDOFF_CACHE), entryPrefix(id)); } catch { // The sweep gets it. } @@ -125,8 +234,53 @@ function notify(kind: HandoffKind): void { for (const listener of [...(kindListeners.get(kind) ?? [])]) listener(kind); } +function reportLoss(loss: HandoffLoss): void { + for (const listener of [...lossListeners]) listener(loss); +} + +function dropFromQueue(index: number): Handoff { + const [handoff] = queue.splice(index, 1); + void (stored.get(handoff.id) ?? Promise.resolve()).then(() => deleteStored(handoff.id)); + stored.delete(handoff.id); + return handoff; +} + +function armExpiry(oldestStored: number | null): void { + if (expiryTimer !== null) clearTimeout(expiryTimer); + expiryTimer = null; + const times = [...queue.map((handoff) => handoff.createdAt), ...(oldestStored === null ? [] : [oldestStored])]; + if (times.length === 0) return; + const delay = Math.max(0, Math.min(...times) + HANDOFF_TTL_MS - Date.now()) + 1000; + expiryTimer = setTimeout(() => { void expireHandoffs(); }, delay); +} + +/** What waited too long goes, in memory and stored. Also runs on a timer while this page is open. */ +export async function expireHandoffs(): Promise { + const now = Date.now(); + let lost = false; + for (let index = queue.length - 1; index >= 0; index -= 1) { + if (now - queue[index].createdAt <= HANDOFF_TTL_MS) continue; + dropFromQueue(index); + lost = true; + } + if (lost) reportLoss('expired'); + await sweepStoredHandoffs(); +} + +async function sweepStoredHandoffs(): Promise { + let oldest: number | null = null; + const api = cacheApi(); + try { + if (api && (await api.has(HANDOFF_CACHE))) oldest = await sweep(await api.open(HANDOFF_CACHE), null); + } catch { + // Tried again at the next page load. + } + armExpiry(oldest); +} + function enqueue(handoff: Handoff, persist: boolean): void { queue.push(handoff); + armExpiry(null); if (persist) stored.set(handoff.id, writeStored(handoff)); notify(handoff.kind); } @@ -152,14 +306,16 @@ export function handOffFiles(files: File[], kind: HandoffKind): void { enqueue({ id: newId(), kind, files, createdAt: Date.now() }, kind !== 'eml'); } -/** Opens a new draft with this text and these files. */ +/** Opens a new draft with this text and these files. Trusted: what came from outside was asked about first. */ export function handOffDraft(draft: { subject?: string; body?: string; files?: File[] }): void { + const subject = cleanMailtoSubject(draft.subject ?? ''); + const body = cleanMailtoBody(draft.body ?? ''); enqueue({ id: newId(), kind: 'compose', files: draft.files ?? [], - ...(draft.subject ? { subject: draft.subject } : {}), - ...(draft.body ? { body: draft.body } : {}), + ...(subject ? { subject } : {}), + ...(body ? { body } : {}), createdAt: Date.now(), }, true); } @@ -203,15 +359,17 @@ export async function handOffLaunchFiles( /** 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) { + let lost = false; + let found: Handoff | null = null; + for (let index = 0; index < queue.length && !found; index += 1) { if (queue[index].kind !== kind) continue; - const [handoff] = queue.splice(index, 1); + const handoff = dropFromQueue(index); 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; + if (now - handoff.createdAt <= HANDOFF_TTL_MS) found = handoff; + else lost = true; } - return null; + if (lost) reportLoss('expired'); + return found; } /** What is waiting, oldest first. Nothing is taken. */ @@ -231,88 +389,134 @@ export function subscribeHandoff(kind: HandoffKind | 'any', listener: 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 }; +/** Hears about a handoff that went without arriving, so the user can be told. */ +export function subscribeHandoffLost(listener: (loss: HandoffLoss) => void): () => void { + lossListeners.add(listener); + return () => { lossListeners.delete(listener); }; } -/** Brings a stored handoff into this tab. False when it is gone. */ -export async function adoptStoredHandoff(id: string): Promise { - if (queue.some((handoff) => handoff.id === id)) return true; +/** Subject and body of a draft from what the OS shared, under the same rules as a mailto: link. */ +export function shareDraft(shared: { title?: string; text?: string; url?: string }): { subject: string; body: string } { + const text = cleanMailtoBody(shared.text ?? '').trim(); + const url = cleanMailtoSubject(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: cleanMailtoSubject(shared.title ?? '').trim(), body: cleanMailtoBody(body) }; +} + +/** A name from outside, as it may be shown and attached. */ +function cleanFileName(name: string): string { + // eslint-disable-next-line no-control-regex + return stripBidiControls(name.replace(/[\u0000-\u001F\u007F]/g, '')).slice(0, 255) || 'file'; +} + +interface StoredContents { + meta: { kind: HandoffKind; createdAt: number }; + subject: string; + body: string; + files: Array<{ name: string; type: string }>; + key: CryptoKey; + cache: Cache; +} + +async function openStored(id: string, encodedKey: string): Promise { const api = cacheApi(); - if (!api) return false; - const prefix = STORE_PREFIX + id + '/'; + if (!api || !canSeal() || !encodedKey) return null; + const prefix = entryPrefix(id); try { - if (!(await api.has(HANDOFF_CACHE))) return false; + if (!(await api.has(HANDOFF_CACHE))) return null; 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 }>; - }; + const metaResponse = await cache.match(`${prefix}meta`); + if (!metaResponse) return null; + const meta = (await metaResponse.json()) as StoredContents['meta']; if (Date.now() - meta.createdAt > HANDOFF_TTL_MS) { await deleteStored(id); - return false; + return null; } - 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; + const sealedResponse = await cache.match(`${prefix}sealed`); + if (!sealedResponse) return null; + const key = await crypto.subtle.importKey('raw', fromBase64Url(encodedKey), 'AES-GCM', false, ['decrypt']); + const contents = JSON.parse(new TextDecoder().decode(await unseal(key, await sealedResponse.arrayBuffer()))) as { + title?: string; text?: string; url?: string; files?: Array<{ name: string; type?: string }>; + }; + return { + meta, + ...shareDraft(contents), + files: (contents.files ?? []).map((file) => ({ name: cleanFileName(file.name), type: file.type ?? '' })), + key, + cache, + }; } catch { - return false; + return null; } } +/** What a stored handoff holds, to ask the user about. Null when it is gone or can't be read. */ +export async function peekStoredHandoff(id: string, encodedKey: string): Promise<{ subject: string; body: string; files: string[] } | null> { + const contents = await openStored(id, encodedKey); + return contents && { subject: contents.subject, body: contents.body, files: contents.files.map((file) => file.name) }; +} + +/** The user refused it. */ +export function discardStoredHandoff(id: string): Promise { + return deleteStored(id); +} + +/** Brings a stored handoff into this tab. False when it is gone. Without `encodedKey`: this tab's own key. */ +export async function adoptStoredHandoff(id: string, encodedKey?: string): Promise { + if (queue.some((handoff) => handoff.id === id)) return true; + const key = encodedKey ?? readTabKeys()[id] ?? ''; + const contents = await openStored(id, key); + if (!contents) return false; + const prefix = entryPrefix(id); + const files: File[] = []; + let missing = false; + for (const [index, entry] of contents.files.entries()) { + try { + const response = await contents.cache.match(`${prefix}${index}`); + if (!response) throw new Error('gone'); + const bytes = await unseal(contents.key, await response.arrayBuffer()); + files.push(new File([bytes], entry.name, { type: entry.type })); + } catch { + missing = true; + } + } + // The awaits above: another caller may have got here first. + if (queue.some((handoff) => handoff.id === id)) return true; + setTabKey(id, key); + enqueue({ + id, + kind: contents.meta.kind, + files, + ...(contents.subject ? { subject: contents.subject } : {}), + ...(contents.body ? { body: contents.body } : {}), + createdAt: contents.meta.createdAt, + }, false); + if (missing) reportLoss('missing'); + return true; +} + /** After a page load: this tab's stored handoffs come back, what is too old goes. */ export async function restoreStoredHandoffs(): Promise { - await Promise.all(readTabIds().map(async (id) => { - if (!(await adoptStoredHandoff(id))) writeTabIds(readTabIds().filter((other) => other !== id)); + let lost = false; + await Promise.all(Object.keys(readTabKeys()).map(async (id) => { + if (await adoptStoredHandoff(id)) return; + setTabKey(id, null); + lost = true; })); - 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. - } + if (lost) reportLoss('expired'); + await sweepStoredHandoffs(); } -/** Sign-out: nothing that was on its way into an account stays. */ +/** Sign-out and offline wipes: nothing that was on its way into an account stays. */ export function clearHandoffs(): void { + clearCount += 1; queue.length = 0; stored.clear(); - writeTabIds([]); + writeTabKeys({}); lastNewMessageAt = 0; + if (expiryTimer !== null) clearTimeout(expiryTimer); + expiryTimer = null; const api = cacheApi(); if (api) void api.delete(HANDOFF_CACHE).catch(() => {}); } @@ -320,16 +524,16 @@ export function clearHandoffs(): void { export type LaunchLanding = | { type: 'new-message' } /** The service worker stored what was shared. */ - | { type: 'stored'; id: string } + | { type: 'stored'; id: string; key: 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 }; + | { type: 'inline'; subject: string; body: string; filesLost: boolean; cut: boolean }; -const LANDING_PARAMS = ['compose', 'share', 'share_title', 'share_text', 'share_url', 'share_files']; +const LANDING_HASH_PARAMS = ['share', 'key', 'share_title', 'share_text', 'share_url', 'share_files', 'share_cut']; /** 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' }; +export function parseLaunchLanding(search: string, hash: string): LaunchLanding | null { + if (new URLSearchParams(search).get('compose') === '1') return { type: 'new-message' }; + const params = new URLSearchParams(hash.replace(/^#/, '')); const share = params.get('share'); if (!share) return null; if (share === 'unavailable') { @@ -341,17 +545,25 @@ export function parseLaunchLanding(search: string): LaunchLanding | null { url: params.get('share_url') ?? '', }), filesLost: params.get('share_files') === '1', + cut: params.get('share_cut') === '1', }; } - return /^[\w-]{1,64}$/.test(share) ? { type: 'stored', id: share } : null; + return /^[\w-]{1,64}$/.test(share) ? { type: 'stored', id: share, key: params.get('key') ?? '' } : 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}` : ''; +/** The address without what parseLaunchLanding read, so a reload doesn't do it again. */ +export function stripLandingParams(search: string, hash: string): { search: string; hash: string } { + const query = new URLSearchParams(search); + query.delete('compose'); + const rest = query.toString(); + let leftHash = hash; + const fragment = new URLSearchParams(hash.replace(/^#/, '')); + if (fragment.has('share')) { + for (const name of LANDING_HASH_PARAMS) fragment.delete(name); + const left = fragment.toString(); + leftHash = left ? `#${left}` : ''; + } + return { search: rest ? `?${rest}` : '', hash: leftHash }; } /** diff --git a/lib/offline/__tests__/handoff-wipes.test.ts b/lib/offline/__tests__/handoff-wipes.test.ts new file mode 100644 index 000000000..84c58ba08 --- /dev/null +++ b/lib/offline/__tests__/handoff-wipes.test.ts @@ -0,0 +1,64 @@ +import 'fake-indexeddb/auto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAccountStore } from '@/stores/account-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { handOffFiles, pendingHandoffs } from '@/lib/launch-handoff'; +import { disableOffline, enforcePolicy, wipeEverything } from '../offline-mode'; +import { forgetIndexes } from '../cache'; +import { forgetDeviceKey } from '../crypto'; +import { allowOfflineDbCreation, deleteOfflineDb } from '../db'; +import { useOfflineStore, writeEnabled } from '../state'; + +// Offline mode's wipes take what was on its way into an account along. + +function waiting() { + handOffFiles([new File(['x'], 'a.ics')], 'calendar'); + expect(pendingHandoffs()).toHaveLength(1); +} + +beforeEach(async () => { + forgetIndexes(); + forgetDeviceKey(); + await deleteOfflineDb(); + allowOfflineDbCreation(); + localStorage.clear(); + useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, browserOffline: false, downServers: [], startedOffline: false }); + usePolicyStore.setState({ loaded: true, subject: null, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } } as never); + useAccountStore.setState({ accounts: [{ id: 'acc', label: 'alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] }); +}); + +afterEach(async () => { + await wipeEverything(); +}); + +describe('offline wipes and handoffs', () => { + it('wipeEverything clears them', async () => { + waiting(); + await wipeEverything(); + expect(pendingHandoffs()).toHaveLength(0); + }); + + it('disableOffline clears them', async () => { + writeEnabled(['acc']); + waiting(); + await disableOffline('acc'); + expect(pendingHandoffs()).toHaveLength(0); + }); + + it('enforcePolicy clears them when the organisation forbids offline mode', async () => { + writeEnabled(['acc']); + waiting(); + usePolicyStore.setState({ policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, noOffline: true } } } }); + enforcePolicy(); + await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual([])); + expect(pendingHandoffs()).toHaveLength(0); + }); + + it('enforcePolicy leaves them while offline mode is allowed', () => { + writeEnabled(['acc']); + waiting(); + enforcePolicy(); + expect(pendingHandoffs()).toHaveLength(1); + }); +}); diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 0152db852..edc755b96 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -66,6 +66,7 @@ import { type OutboxSummary, type PolicyFingerprint, } from './queue'; +import { clearHandoffs } from '@/lib/launch-handoff'; import { isOfflineNow, OFFLINE_ACCOUNTS_KEY, readEnabledFrom, serverKey, setServerDown, useOfflineStore, writeEnabled, type OutboxRow } from './state'; import { flushQueue, settleStaleSends } from './flush'; import { syncAccount } from './sync'; @@ -696,6 +697,7 @@ export async function enableOffline(account: string): Promise { /** Switches offline mode off for an account (the user confirmed losing unsent mail) and removes everything it kept here. */ export async function disableOffline(account: string): Promise { confirmLoss(account); + clearHandoffs(); writeEnabled(useOfflineStore.getState().enabled.filter((id) => id !== account)); await wipeAccount(account); } @@ -779,6 +781,7 @@ export async function wipeEverything(): Promise { await deleteOfflineDb(); await tellServiceWorker(false); await deleteShellCaches(); + clearHandoffs(); confirmed.clear(); named.clear(); useOfflineStore.setState({ outbox: [], pendingChanges: 0, usageBytes: 0, syncing: [], lastSyncedAt: {} }); @@ -801,6 +804,7 @@ export function enforcePolicy(): void { for (const account of useOfflineStore.getState().enabled) { if (!policyDecided(account)) continue; if (!offlineAllowed(account)) { + clearHandoffs(); void wipeAccount(account); notify({ kind: 'forbidden' }); } diff --git a/lib/protocol-handlers/mailto.ts b/lib/protocol-handlers/mailto.ts index 8cb217dd8..e58385708 100644 --- a/lib/protocol-handlers/mailto.ts +++ b/lib/protocol-handlers/mailto.ts @@ -39,6 +39,16 @@ function stripBodyControlChars(value: string): string { .replace(CONTROL_CHARS_EXCEPT_LINE_BREAKS, ""); } +/** A subject from outside the app: no control characters, no longer than a mail header allows. */ +export function cleanMailtoSubject(value: string): string { + return stripControlChars(value).slice(0, MAX_SUBJECT_LENGTH); +} + +/** A body from outside the app: line breaks kept, other control characters gone, limited in length. */ +export function cleanMailtoBody(value: string): string { + return stripBodyControlChars(value).slice(0, MAX_BODY_LENGTH); +} + function splitRecipients(value: string): string[] { // Quote/angle-aware split so a `"Doe, John" ` display name with // an embedded comma stays a single recipient instead of being torn in two. @@ -125,8 +135,8 @@ export function parseMailto(raw: string): ParsedMailto | null { to, cc, bcc, - subject: stripControlChars(getQueryValue(searchParams, "subject")).slice(0, MAX_SUBJECT_LENGTH), - body: stripBodyControlChars(getQueryValue(searchParams, "body")).slice(0, MAX_BODY_LENGTH), + subject: cleanMailtoSubject(getQueryValue(searchParams, "subject")), + body: cleanMailtoBody(getQueryValue(searchParams, "body")), }; } diff --git a/locales/ar/common.json b/locales/ar/common.json index 8bc955352..7a9984275 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -258,7 +258,18 @@ "share_gone": "ما شاركته لم يعد موجودًا. يرجى مشاركته مرة أخرى.", "share_files_lost": "تعذر الاحتفاظ بالملفات المشتركة. أضفها إلى الرسالة مرة أخرى.", "ics_needs_server": "يحتاج استيراد ملف تقويم إلى اتصال بالخادم. يبدأ الاستيراد عند عودتك إلى الاتصال بالإنترنت.", - "contacts_need_server": "يحتاج استيراد جهات الاتصال إلى اتصال بالخادم. حاول مرة أخرى عند عودتك إلى الاتصال بالإنترنت." + "contacts_need_server": "يحتاج استيراد جهات الاتصال إلى اتصال بالخادم. حاول مرة أخرى عند عودتك إلى الاتصال بالإنترنت.", + "share_confirm_title": "هل تريد بدء رسالة جديدة؟", + "share_confirm_message": "يريد موقع أو تطبيق آخر بدء رسالة. لن يُرفع أو يُحفظ أي شيء إلا إذا وافقت.", + "share_confirm_subject": "الموضوع:", + "share_confirm_text": "النص:", + "share_confirm_files": "{count, plural, one {ملف:} other {ملفات:}}", + "share_confirm_accept": "بدء الرسالة", + "share_confirm_decline": "عدم البدء", + "share_cut": "كان النص المشترك طويلًا جدًا وتم اختصاره.", + "handoff_expired": "انتظر شيء فتحته أو شاركته مدة طويلة ولم يعد موجودًا. يرجى فتحه أو مشاركته مرة أخرى.", + "compose_waiting": "أغلق هذه الرسالة لبدء الرسالة التالية.", + "save_first_failed": "تعذر حفظ رسالتك، لذلك لم يُفتح الملف. احفظ الرسالة ثم افتح التقويم أو جهات الاتصال." }, "sidebar_apps": { "modal_title": "تطبيقات الشريط الجانبي", diff --git a/locales/ca/common.json b/locales/ca/common.json index 57dfafc11..8febad5bf 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -258,7 +258,18 @@ "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." + "contacts_need_server": "Per importar contactes cal connexió amb el servidor. Torneu-ho a provar quan torneu a estar en línia.", + "share_confirm_title": "Voleu començar un missatge nou?", + "share_confirm_message": "Un altre lloc o aplicació vol començar un missatge. No es puja ni es desa res si no hi esteu d'acord.", + "share_confirm_subject": "Assumpte:", + "share_confirm_text": "Text:", + "share_confirm_files": "{count, plural, one {Fitxer:} other {Fitxers:}}", + "share_confirm_accept": "Comença el missatge", + "share_confirm_decline": "No el comencis", + "share_cut": "El text compartit era massa llarg i s'ha escurçat.", + "handoff_expired": "Una cosa que heu obert o compartit ha esperat massa i ja no hi és. Torneu-la a obrir o compartir.", + "compose_waiting": "Tanqueu aquest missatge per començar el següent.", + "save_first_failed": "No s'ha pogut desar el missatge, per això no s'ha obert el fitxer. Deseu el missatge i obriu el calendari o els contactes." }, "sidebar_apps": { "modal_title": "Aplicacions de la barra lateral", diff --git a/locales/cs/common.json b/locales/cs/common.json index b564e4a67..f908c764d 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -258,7 +258,18 @@ "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." + "contacts_need_server": "Import kontaktů vyžaduje připojení k serveru. Zkuste to znovu, až budete online.", + "share_confirm_title": "Začít novou zprávu?", + "share_confirm_message": "Jiný web nebo aplikace chce začít zprávu. Bez vašeho souhlasu se nic nenahraje ani neuloží.", + "share_confirm_subject": "Předmět:", + "share_confirm_text": "Text:", + "share_confirm_files": "{count, plural, one {Soubor:} few {Soubory:} many {Souborů:} other {Soubory:}}", + "share_confirm_accept": "Začít zprávu", + "share_confirm_decline": "Nezačínat", + "share_cut": "Sdílený text byl příliš dlouhý a byl zkrácen.", + "handoff_expired": "To, co jste otevřeli nebo sdíleli, čekalo příliš dlouho a už tu není. Otevřete to nebo sdílejte znovu.", + "compose_waiting": "Zavřete tuto zprávu, abyste mohli začít další.", + "save_first_failed": "Zprávu se nepodařilo uložit, proto se soubor neotevřel. Uložte zprávu a pak otevřete kalendář nebo kontakty." }, "sidebar_apps": { "modal_title": "Aplikace postranního panelu", diff --git a/locales/da/common.json b/locales/da/common.json index 9935f289d..2bfa0300b 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -258,7 +258,18 @@ "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." + "contacts_need_server": "Import af kontakter kræver forbindelse til serveren. Prøv igen, når du er online igen.", + "share_confirm_title": "Vil du starte en ny besked?", + "share_confirm_message": "Et andet websted eller en anden app vil starte en besked. Intet uploades eller gemmes, medmindre du accepterer.", + "share_confirm_subject": "Emne:", + "share_confirm_text": "Tekst:", + "share_confirm_files": "{count, plural, one {Fil:} other {Filer:}}", + "share_confirm_accept": "Start besked", + "share_confirm_decline": "Start ikke", + "share_cut": "Den delte tekst var for lang og er blevet forkortet.", + "handoff_expired": "Noget, du åbnede eller delte, ventede for længe og er væk. Åbn eller del det igen.", + "compose_waiting": "Luk denne besked for at starte den næste.", + "save_first_failed": "Din besked kunne ikke gemmes, så filen er ikke åbnet. Gem beskeden, og åbn derefter kalenderen eller kontakterne." }, "sidebar_apps": { "modal_title": "Sidepanel-apps", diff --git a/locales/de/common.json b/locales/de/common.json index 0466fccf5..5ddd25b9f 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -258,7 +258,18 @@ "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." + "contacts_need_server": "Zum Importieren von Kontakten wird eine Verbindung zum Server gebraucht. Versuchen Sie es noch einmal, wenn Sie wieder online sind.", + "share_confirm_title": "Neue Nachricht beginnen?", + "share_confirm_message": "Eine andere Website oder App möchte eine Nachricht beginnen. Es wird nichts hochgeladen oder gespeichert, wenn Sie nicht zustimmen.", + "share_confirm_subject": "Betreff:", + "share_confirm_text": "Text:", + "share_confirm_files": "{count, plural, one {Datei:} other {Dateien:}}", + "share_confirm_accept": "Nachricht beginnen", + "share_confirm_decline": "Nicht beginnen", + "share_cut": "Der geteilte Text war zu lang und wurde gekürzt.", + "handoff_expired": "Etwas, das Sie geöffnet oder geteilt haben, hat zu lange gewartet und ist nicht mehr da. Bitte öffnen oder teilen Sie es noch einmal.", + "compose_waiting": "Schließen Sie diese Nachricht, um die nächste zu beginnen.", + "save_first_failed": "Ihre Nachricht konnte nicht gespeichert werden, deshalb wurde die Datei nicht geöffnet. Speichern Sie die Nachricht und öffnen Sie dann den Kalender oder die Kontakte." }, "sidebar_apps": { "modal_title": "Sidebar-Apps", diff --git a/locales/en/common.json b/locales/en/common.json index b87b0ec93..5f487c8de 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -258,7 +258,18 @@ "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." + "contacts_need_server": "Importing contacts needs a connection to the server. Try again when you're back online.", + "share_confirm_title": "Start a new message?", + "share_confirm_message": "Another site or app wants to start a message. Nothing is uploaded or saved unless you agree.", + "share_confirm_subject": "Subject:", + "share_confirm_text": "Text:", + "share_confirm_files": "{count, plural, one {File:} other {Files:}}", + "share_confirm_accept": "Start message", + "share_confirm_decline": "Don't start", + "share_cut": "The shared text was too long and has been shortened.", + "handoff_expired": "Something you opened or shared waited too long and is gone. Please open or share it again.", + "compose_waiting": "Close this message to start the next one.", + "save_first_failed": "Your message couldn't be saved, so the file hasn't been opened. Save the message, then open the calendar or contacts." }, "sidebar_apps": { "modal_title": "Sidebar apps", diff --git a/locales/es/common.json b/locales/es/common.json index 6a5f1f06f..3ace97f0b 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -258,7 +258,18 @@ "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." + "contacts_need_server": "Importar contactos requiere conexión con el servidor. Inténtalo de nuevo cuando vuelvas a estar en línea.", + "share_confirm_title": "¿Empezar un mensaje nuevo?", + "share_confirm_message": "Otro sitio o aplicación quiere empezar un mensaje. No se sube ni se guarda nada si no aceptas.", + "share_confirm_subject": "Asunto:", + "share_confirm_text": "Texto:", + "share_confirm_files": "{count, plural, one {Archivo:} other {Archivos:}}", + "share_confirm_accept": "Empezar mensaje", + "share_confirm_decline": "No empezar", + "share_cut": "El texto compartido era demasiado largo y se ha acortado.", + "handoff_expired": "Algo que abriste o compartiste esperó demasiado y ya no está. Ábrelo o compártelo de nuevo.", + "compose_waiting": "Cierra este mensaje para empezar el siguiente.", + "save_first_failed": "No se pudo guardar tu mensaje, así que el archivo no se ha abierto. Guarda el mensaje y luego abre el calendario o los contactos." }, "sidebar_apps": { "modal_title": "Aplicaciones de la barra lateral", diff --git a/locales/et/common.json b/locales/et/common.json index 4353289be..b08747e89 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -258,7 +258,18 @@ "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." + "contacts_need_server": "Kontaktide importimiseks on vaja ühendust serveriga. Proovi uuesti, kui oled jälle võrgus.", + "share_confirm_title": "Kas alustada uut kirja?", + "share_confirm_message": "Teine sait või rakendus soovib kirja alustada. Ilma sinu nõusolekuta ei laadita ega salvestata midagi.", + "share_confirm_subject": "Teema:", + "share_confirm_text": "Tekst:", + "share_confirm_files": "{count, plural, one {Fail:} other {Failid:}}", + "share_confirm_accept": "Alusta kirja", + "share_confirm_decline": "Ära alusta", + "share_cut": "Jagatud tekst oli liiga pikk ja seda lühendati.", + "handoff_expired": "Midagi, mille avasid või jagasid, ootas liiga kaua ja seda pole enam. Ava või jaga see uuesti.", + "compose_waiting": "Järgmise alustamiseks sulge see kiri.", + "save_first_failed": "Kirja ei saanud salvestada, seega faili ei avatud. Salvesta kiri ja ava siis kalender või kontaktid." }, "sidebar_apps": { "modal_title": "Külgriba rakendused", diff --git a/locales/fa/common.json b/locales/fa/common.json index 9b939d48b..e620575ee 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -258,7 +258,18 @@ "share_gone": "چیزی که هم‌رسانی کردید دیگر اینجا نیست. لطفاً دوباره آن را هم‌رسانی کنید.", "share_files_lost": "فایل‌های هم‌رسانی‌شده نگه داشته نشدند. دوباره آن‌ها را به پیام اضافه کنید.", "ics_needs_server": "وارد کردن فایل تقویم به اتصال به سرور نیاز دارد. وقتی دوباره آنلاین شوید، وارد کردن شروع می‌شود.", - "contacts_need_server": "وارد کردن مخاطبان به اتصال به سرور نیاز دارد. وقتی دوباره آنلاین شدید، دوباره امتحان کنید." + "contacts_need_server": "وارد کردن مخاطبان به اتصال به سرور نیاز دارد. وقتی دوباره آنلاین شدید، دوباره امتحان کنید.", + "share_confirm_title": "پیام جدیدی شروع شود؟", + "share_confirm_message": "سایت یا برنامه دیگری می‌خواهد پیامی را شروع کند. تا موافقت نکنید چیزی بارگذاری یا ذخیره نمی‌شود.", + "share_confirm_subject": "موضوع:", + "share_confirm_text": "متن:", + "share_confirm_files": "{count, plural, one {فایل:} other {فایل‌ها:}}", + "share_confirm_accept": "شروع پیام", + "share_confirm_decline": "شروع نشود", + "share_cut": "متن هم‌رسانی‌شده خیلی طولانی بود و کوتاه شد.", + "handoff_expired": "چیزی که باز یا هم‌رسانی کردید بیش از حد منتظر ماند و دیگر نیست. لطفاً دوباره آن را باز یا هم‌رسانی کنید.", + "compose_waiting": "برای شروع پیام بعدی این پیام را ببندید.", + "save_first_failed": "پیام شما ذخیره نشد، بنابراین فایل باز نشد. پیام را ذخیره کنید و سپس تقویم یا مخاطبان را باز کنید." }, "sidebar_apps": { "modal_title": "برنامه‌های نوار کناری", diff --git a/locales/fr/common.json b/locales/fr/common.json index 22b8087bf..b44c16906 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -258,7 +258,18 @@ "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." + "contacts_need_server": "L'importation de contacts nécessite une connexion au serveur. Réessayez quand vous serez de nouveau en ligne.", + "share_confirm_title": "Commencer un nouveau message ?", + "share_confirm_message": "Un autre site ou une autre application veut commencer un message. Rien n'est envoyé ni enregistré sans votre accord.", + "share_confirm_subject": "Objet :", + "share_confirm_text": "Texte :", + "share_confirm_files": "{count, plural, one {Fichier :} other {Fichiers :}}", + "share_confirm_accept": "Commencer le message", + "share_confirm_decline": "Ne pas commencer", + "share_cut": "Le texte partagé était trop long et a été raccourci.", + "handoff_expired": "Un élément que vous avez ouvert ou partagé a attendu trop longtemps et n'est plus disponible. Ouvrez-le ou partagez-le à nouveau.", + "compose_waiting": "Fermez ce message pour commencer le suivant.", + "save_first_failed": "Votre message n'a pas pu être enregistré, le fichier n'a donc pas été ouvert. Enregistrez le message, puis ouvrez le calendrier ou les contacts." }, "sidebar_apps": { "modal_title": "Applications de la barre latérale", diff --git a/locales/he/common.json b/locales/he/common.json index 95af29824..1d5253370 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5281,7 +5281,18 @@ "share_gone": "מה ששיתפתם כבר לא נמצא כאן. שתפו אותו שוב.", "share_files_lost": "לא ניתן היה לשמור את הקבצים ששותפו. הוסיפו אותם שוב להודעה.", "ics_needs_server": "ייבוא קובץ לוח שנה דורש חיבור לשרת. הייבוא יתחיל כשתחזרו להיות מחוברים.", - "contacts_need_server": "ייבוא אנשי קשר דורש חיבור לשרת. נסו שוב כשתחזרו להיות מחוברים." + "contacts_need_server": "ייבוא אנשי קשר דורש חיבור לשרת. נסו שוב כשתחזרו להיות מחוברים.", + "share_confirm_title": "להתחיל הודעה חדשה?", + "share_confirm_message": "אתר או אפליקציה אחרים רוצים להתחיל הודעה. שום דבר לא יועלה או יישמר בלי הסכמתכם.", + "share_confirm_subject": "נושא:", + "share_confirm_text": "טקסט:", + "share_confirm_files": "{count, plural, one {קובץ:} other {קבצים:}}", + "share_confirm_accept": "התחלת הודעה", + "share_confirm_decline": "לא להתחיל", + "share_cut": "הטקסט ששותף היה ארוך מדי וקוצר.", + "handoff_expired": "משהו שפתחתם או שיתפתם המתין יותר מדי זמן וכבר אינו כאן. פתחו או שתפו אותו שוב.", + "compose_waiting": "סגרו הודעה זו כדי להתחיל את הבאה.", + "save_first_failed": "לא ניתן היה לשמור את ההודעה, לכן הקובץ לא נפתח. שמרו את ההודעה ואז פתחו את לוח השנה או את אנשי הקשר." }, "mailbox_context_menu": { "mark_folder_read": "סמן תיקייה כקרויה", diff --git a/locales/hu/common.json b/locales/hu/common.json index 0564585b8..3dc525033 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -258,7 +258,18 @@ "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." + "contacts_need_server": "A névjegyek importálásához kapcsolat kell a szerverrel. Próbálja újra, amikor ismét online lesz.", + "share_confirm_title": "Új üzenetet kezd?", + "share_confirm_message": "Egy másik webhely vagy alkalmazás üzenetet szeretne kezdeni. Az Ön beleegyezése nélkül semmi sem kerül feltöltésre vagy mentésre.", + "share_confirm_subject": "Tárgy:", + "share_confirm_text": "Szöveg:", + "share_confirm_files": "{count, plural, one {Fájl:} other {Fájlok:}}", + "share_confirm_accept": "Üzenet kezdése", + "share_confirm_decline": "Ne kezdje el", + "share_cut": "A megosztott szöveg túl hosszú volt, ezért rövidítettük.", + "handoff_expired": "Valami, amit megnyitott vagy megosztott, túl sokáig várakozott, és már nincs meg. Kérjük, nyissa meg vagy ossza meg újra.", + "compose_waiting": "Zárja be ezt az üzenetet a következő kezdéséhez.", + "save_first_failed": "Az üzenetet nem sikerült menteni, ezért a fájl nem nyílt meg. Mentse az üzenetet, majd nyissa meg a naptárat vagy a névjegyeket." }, "sidebar_apps": { "modal_title": "Oldalsáv alkalmazások", diff --git a/locales/it/common.json b/locales/it/common.json index a82800289..fdbaa241f 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -258,7 +258,18 @@ "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." + "contacts_need_server": "Per importare i contatti serve una connessione al server. Riprova quando tornerai online.", + "share_confirm_title": "Iniziare un nuovo messaggio?", + "share_confirm_message": "Un altro sito o app vuole iniziare un messaggio. Non viene caricato o salvato nulla senza il tuo consenso.", + "share_confirm_subject": "Oggetto:", + "share_confirm_text": "Testo:", + "share_confirm_files": "{count, plural, one {File:} other {File:}}", + "share_confirm_accept": "Inizia messaggio", + "share_confirm_decline": "Non iniziare", + "share_cut": "Il testo condiviso era troppo lungo ed è stato accorciato.", + "handoff_expired": "Qualcosa che hai aperto o condiviso ha atteso troppo a lungo e non c'è più. Aprilo o condividilo di nuovo.", + "compose_waiting": "Chiudi questo messaggio per iniziare il successivo.", + "save_first_failed": "Non è stato possibile salvare il messaggio, quindi il file non è stato aperto. Salva il messaggio, poi apri il calendario o i contatti." }, "sidebar_apps": { "modal_title": "App della barra laterale", diff --git a/locales/ja/common.json b/locales/ja/common.json index 0e567d3ca..0ac0ff619 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -258,7 +258,18 @@ "share_gone": "共有した内容はもうありません。もう一度共有してください。", "share_files_lost": "共有したファイルを保持できませんでした。メッセージにもう一度追加してください。", "ics_needs_server": "カレンダーファイルのインポートにはサーバーへの接続が必要です。オンラインに戻るとインポートが始まります。", - "contacts_need_server": "連絡先のインポートにはサーバーへの接続が必要です。オンラインに戻ってからもう一度お試しください。" + "contacts_need_server": "連絡先のインポートにはサーバーへの接続が必要です。オンラインに戻ってからもう一度お試しください。", + "share_confirm_title": "新しいメッセージを開始しますか?", + "share_confirm_message": "別のサイトまたはアプリがメッセージを開始しようとしています。同意しない限り、何もアップロードも保存もされません。", + "share_confirm_subject": "件名:", + "share_confirm_text": "本文:", + "share_confirm_files": "{count, plural, other {ファイル:}}", + "share_confirm_accept": "メッセージを開始", + "share_confirm_decline": "開始しない", + "share_cut": "共有されたテキストが長すぎるため短縮されました。", + "handoff_expired": "開いた、または共有した内容は待機時間が長すぎたためなくなりました。もう一度開くか共有してください。", + "compose_waiting": "次のメッセージを開始するには、このメッセージを閉じてください。", + "save_first_failed": "メッセージを保存できなかったため、ファイルを開いていません。メッセージを保存してから、カレンダーまたは連絡先を開いてください。" }, "sidebar_apps": { "modal_title": "サイドバーアプリ", diff --git a/locales/ko/common.json b/locales/ko/common.json index 51cfc0fed..181b0b691 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -258,7 +258,18 @@ "share_gone": "공유한 내용이 더 이상 없습니다. 다시 공유해 주세요.", "share_files_lost": "공유한 파일을 보관할 수 없습니다. 메시지에 다시 추가해 주세요.", "ics_needs_server": "캘린더 파일을 가져오려면 서버에 연결되어 있어야 합니다. 다시 온라인 상태가 되면 가져오기가 시작됩니다.", - "contacts_need_server": "연락처를 가져오려면 서버에 연결되어 있어야 합니다. 다시 온라인 상태가 되면 다시 시도해 주세요." + "contacts_need_server": "연락처를 가져오려면 서버에 연결되어 있어야 합니다. 다시 온라인 상태가 되면 다시 시도해 주세요.", + "share_confirm_title": "새 메시지를 시작할까요?", + "share_confirm_message": "다른 사이트나 앱에서 메시지를 시작하려고 합니다. 동의하지 않으면 아무것도 업로드하거나 저장하지 않습니다.", + "share_confirm_subject": "제목:", + "share_confirm_text": "내용:", + "share_confirm_files": "{count, plural, other {파일:}}", + "share_confirm_accept": "메시지 시작", + "share_confirm_decline": "시작 안 함", + "share_cut": "공유한 텍스트가 너무 길어서 줄였습니다.", + "handoff_expired": "열었거나 공유한 항목이 너무 오래 대기하여 사라졌습니다. 다시 열거나 공유해 주세요.", + "compose_waiting": "다음 메시지를 시작하려면 이 메시지를 닫으세요.", + "save_first_failed": "메시지를 저장할 수 없어 파일을 열지 않았습니다. 메시지를 저장한 다음 캘린더나 연락처를 여세요." }, "sidebar_apps": { "modal_title": "사이드바 앱", diff --git a/locales/lv/common.json b/locales/lv/common.json index 60fbbb89b..ac37493b7 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -258,7 +258,18 @@ "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ē." + "contacts_need_server": "Kontaktu importēšanai vajadzīgs savienojums ar serveri. Mēģiniet vēlreiz, kad atkal būsiet tiešsaistē.", + "share_confirm_title": "Vai sākt jaunu ziņu?", + "share_confirm_message": "Cita vietne vai lietotne vēlas sākt ziņu. Bez jūsu piekrišanas nekas netiek augšupielādēts vai saglabāts.", + "share_confirm_subject": "Tēma:", + "share_confirm_text": "Teksts:", + "share_confirm_files": "{count, plural, zero {Faili:} one {Fails:} other {Faili:}}", + "share_confirm_accept": "Sākt ziņu", + "share_confirm_decline": "Nesākt", + "share_cut": "Kopīgotais teksts bija pārāk garš un tika saīsināts.", + "handoff_expired": "Tas, ko atvērāt vai kopīgojāt, gaidīja pārāk ilgi un vairs nav pieejams. Atveriet vai kopīgojiet to vēlreiz.", + "compose_waiting": "Aizveriet šo ziņu, lai sāktu nākamo.", + "save_first_failed": "Ziņu neizdevās saglabāt, tāpēc fails netika atvērts. Saglabājiet ziņu un pēc tam atveriet kalendāru vai kontaktus." }, "sidebar_apps": { "modal_title": "Sānu joslas lietotnes", diff --git a/locales/mn/common.json b/locales/mn/common.json index 649c9e7ac..767ccf332 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -258,7 +258,18 @@ "share_gone": "Таны хуваалцсан зүйл энд байхгүй болсон. Дахин хуваалцана уу.", "share_files_lost": "Хуваалцсан файлуудыг хадгалж чадсангүй. Зурваст дахин нэмнэ үү.", "ics_needs_server": "Хуанлийн файл импортлоход сервертэй холболт хэрэгтэй. Та дахин онлайн болоход импорт эхэлнэ.", - "contacts_need_server": "Харилцагчдыг импортлоход сервертэй холболт хэрэгтэй. Дахин онлайн болсон үедээ оролдоно уу." + "contacts_need_server": "Харилцагчдыг импортлоход сервертэй холболт хэрэгтэй. Дахин онлайн болсон үедээ оролдоно уу.", + "share_confirm_title": "Шинэ зурвас эхлүүлэх үү?", + "share_confirm_message": "Өөр сайт эсвэл апп зурвас эхлүүлэхийг хүсэж байна. Та зөвшөөрөхгүй бол юу ч байршуулахгүй, хадгалахгүй.", + "share_confirm_subject": "Гарчиг:", + "share_confirm_text": "Текст:", + "share_confirm_files": "{count, plural, one {Файл:} other {Файлууд:}}", + "share_confirm_accept": "Зурвас эхлүүлэх", + "share_confirm_decline": "Эхлүүлэхгүй", + "share_cut": "Хуваалцсан текст хэт урт байсан тул богиносгосон.", + "handoff_expired": "Таны нээсэн эсвэл хуваалцсан зүйл хэт удаан хүлээсэн тул байхгүй болсон. Дахин нээх эсвэл хуваалцана уу.", + "compose_waiting": "Дараагийнхыг эхлүүлэхийн тулд энэ зурвасыг хаана уу.", + "save_first_failed": "Зурвасыг хадгалж чадаагүй тул файлыг нээгээгүй. Зурвасаа хадгалаад хуанли эсвэл харилцагчдыг нээнэ үү." }, "sidebar_apps": { "modal_title": "Sidebar програмууд", diff --git a/locales/nb/common.json b/locales/nb/common.json index 0ddf770f4..df53a9495 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -258,7 +258,18 @@ "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." + "contacts_need_server": "Import av kontakter krever tilkobling til serveren. Prøv igjen når du er på nett igjen.", + "share_confirm_title": "Vil du starte en ny melding?", + "share_confirm_message": "Et annet nettsted eller en annen app vil starte en melding. Ingenting lastes opp eller lagres uten at du godtar det.", + "share_confirm_subject": "Emne:", + "share_confirm_text": "Tekst:", + "share_confirm_files": "{count, plural, one {Fil:} other {Filer:}}", + "share_confirm_accept": "Start melding", + "share_confirm_decline": "Ikke start", + "share_cut": "Den delte teksten var for lang og er forkortet.", + "handoff_expired": "Noe du åpnet eller delte, ventet for lenge og er borte. Åpne eller del det på nytt.", + "compose_waiting": "Lukk denne meldingen for å starte den neste.", + "save_first_failed": "Meldingen kunne ikke lagres, så filen er ikke åpnet. Lagre meldingen, og åpne deretter kalenderen eller kontaktene." }, "sidebar_apps": { "modal_title": "Apper i sidefeltet", diff --git a/locales/nl/common.json b/locales/nl/common.json index ab2c81582..3e3035b24 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -258,7 +258,18 @@ "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." + "contacts_need_server": "Voor het importeren van contacten is een verbinding met de server nodig. Probeer het opnieuw als je weer online bent.", + "share_confirm_title": "Een nieuw bericht beginnen?", + "share_confirm_message": "Een andere site of app wil een bericht beginnen. Er wordt niets geüpload of opgeslagen zonder je toestemming.", + "share_confirm_subject": "Onderwerp:", + "share_confirm_text": "Tekst:", + "share_confirm_files": "{count, plural, one {Bestand:} other {Bestanden:}}", + "share_confirm_accept": "Bericht beginnen", + "share_confirm_decline": "Niet beginnen", + "share_cut": "De gedeelde tekst was te lang en is ingekort.", + "handoff_expired": "Iets wat je hebt geopend of gedeeld, heeft te lang gewacht en is weg. Open of deel het opnieuw.", + "compose_waiting": "Sluit dit bericht om het volgende te beginnen.", + "save_first_failed": "Je bericht kon niet worden opgeslagen, dus het bestand is niet geopend. Sla het bericht op en open daarna de agenda of de contacten." }, "sidebar_apps": { "modal_title": "Zijbalk-apps", diff --git a/locales/pl/common.json b/locales/pl/common.json index 1fd914ac1..3a647b062 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -258,7 +258,18 @@ "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." + "contacts_need_server": "Import kontaktów wymaga połączenia z serwerem. Spróbuj ponownie, gdy znów będziesz online.", + "share_confirm_title": "Rozpocząć nową wiadomość?", + "share_confirm_message": "Inna witryna lub aplikacja chce rozpocząć wiadomość. Bez Twojej zgody nic nie zostanie wysłane ani zapisane.", + "share_confirm_subject": "Temat:", + "share_confirm_text": "Tekst:", + "share_confirm_files": "{count, plural, one {Plik:} few {Pliki:} many {Pliki:} other {Pliki:}}", + "share_confirm_accept": "Rozpocznij wiadomość", + "share_confirm_decline": "Nie rozpoczynaj", + "share_cut": "Udostępniony tekst był za długi i został skrócony.", + "handoff_expired": "To, co otwarto lub udostępniono, czekało zbyt długo i już go nie ma. Otwórz lub udostępnij to ponownie.", + "compose_waiting": "Zamknij tę wiadomość, aby rozpocząć następną.", + "save_first_failed": "Nie udało się zapisać wiadomości, więc plik nie został otwarty. Zapisz wiadomość, a potem otwórz kalendarz lub kontakty." }, "sidebar_apps": { "modal_title": "Aplikacje paska bocznego", diff --git a/locales/pt/common.json b/locales/pt/common.json index aca70eda7..9468bc5df 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -258,7 +258,18 @@ "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." + "contacts_need_server": "Importar contactos precisa de uma ligação ao servidor. Tente novamente quando voltar a estar online.", + "share_confirm_title": "Começar uma nova mensagem?", + "share_confirm_message": "Outro site ou aplicação quer começar uma mensagem. Nada é carregado ou guardado sem o seu acordo.", + "share_confirm_subject": "Assunto:", + "share_confirm_text": "Texto:", + "share_confirm_files": "{count, plural, one {Ficheiro:} other {Ficheiros:}}", + "share_confirm_accept": "Começar mensagem", + "share_confirm_decline": "Não começar", + "share_cut": "O texto partilhado era demasiado longo e foi encurtado.", + "handoff_expired": "Algo que abriu ou partilhou esperou demasiado tempo e já não está disponível. Abra ou partilhe novamente.", + "compose_waiting": "Feche esta mensagem para começar a seguinte.", + "save_first_failed": "Não foi possível guardar a mensagem, por isso o ficheiro não foi aberto. Guarde a mensagem e depois abra o calendário ou os contactos." }, "sidebar_apps": { "modal_title": "Apps da barra lateral", diff --git a/locales/ro/common.json b/locales/ro/common.json index 31a66a763..dd59bc843 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -258,7 +258,18 @@ "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." + "contacts_need_server": "Importul contactelor are nevoie de conexiune la server. Încercați din nou când reveniți online.", + "share_confirm_title": "Începeți un mesaj nou?", + "share_confirm_message": "Alt site sau altă aplicație vrea să înceapă un mesaj. Nu se încarcă și nu se salvează nimic fără acordul dvs.", + "share_confirm_subject": "Subiect:", + "share_confirm_text": "Text:", + "share_confirm_files": "{count, plural, one {Fișier:} few {Fișiere:} other {Fișiere:}}", + "share_confirm_accept": "Începeți mesajul", + "share_confirm_decline": "Nu începeți", + "share_cut": "Textul partajat era prea lung și a fost scurtat.", + "handoff_expired": "Ceva ce ați deschis sau partajat a așteptat prea mult și nu mai este disponibil. Deschideți sau partajați din nou.", + "compose_waiting": "Închideți acest mesaj pentru a-l începe pe următorul.", + "save_first_failed": "Mesajul nu a putut fi salvat, așa că fișierul nu a fost deschis. Salvați mesajul, apoi deschideți calendarul sau contactele." }, "sidebar_apps": { "modal_title": "Aplicații din bara laterală", diff --git a/locales/ru/common.json b/locales/ru/common.json index 8790a5cf5..4e4aa57a3 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -258,7 +258,18 @@ "share_gone": "То, чем вы поделились, больше недоступно. Поделитесь ещё раз.", "share_files_lost": "Не удалось сохранить полученные файлы. Добавьте их в письмо ещё раз.", "ics_needs_server": "Для импорта файла календаря нужно подключение к серверу. Импорт начнётся, когда вы снова будете в сети.", - "contacts_need_server": "Для импорта контактов нужно подключение к серверу. Попробуйте ещё раз, когда снова будете в сети." + "contacts_need_server": "Для импорта контактов нужно подключение к серверу. Попробуйте ещё раз, когда снова будете в сети.", + "share_confirm_title": "Начать новое письмо?", + "share_confirm_message": "Другой сайт или приложение хочет начать письмо. Без вашего согласия ничего не будет загружено или сохранено.", + "share_confirm_subject": "Тема:", + "share_confirm_text": "Текст:", + "share_confirm_files": "{count, plural, one {Файл:} few {Файлы:} many {Файлы:} other {Файлы:}}", + "share_confirm_accept": "Начать письмо", + "share_confirm_decline": "Не начинать", + "share_cut": "Полученный текст был слишком длинным и был сокращён.", + "handoff_expired": "То, что вы открыли или чем поделились, ждало слишком долго и больше недоступно. Откройте или поделитесь ещё раз.", + "compose_waiting": "Закройте это письмо, чтобы начать следующее.", + "save_first_failed": "Не удалось сохранить письмо, поэтому файл не открыт. Сохраните письмо, затем откройте календарь или контакты." }, "sidebar_apps": { "modal_title": "Приложения боковой панели", diff --git a/locales/sk/common.json b/locales/sk/common.json index c186d6754..80ce1012d 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -258,7 +258,18 @@ "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." + "contacts_need_server": "Import kontaktov vyžaduje pripojenie k serveru. Skúste to znova, keď budete online.", + "share_confirm_title": "Začať novú správu?", + "share_confirm_message": "Iný web alebo aplikácia chce začať správu. Bez vášho súhlasu sa nič nenahrá ani neuloží.", + "share_confirm_subject": "Predmet:", + "share_confirm_text": "Text:", + "share_confirm_files": "{count, plural, one {Súbor:} few {Súbory:} many {Súborov:} other {Súbory:}}", + "share_confirm_accept": "Začať správu", + "share_confirm_decline": "Nezačínať", + "share_cut": "Zdieľaný text bol príliš dlhý a bol skrátený.", + "handoff_expired": "To, čo ste otvorili alebo zdieľali, čakalo príliš dlho a už tu nie je. Otvorte to alebo zdieľajte znova.", + "compose_waiting": "Zatvorte túto správu, aby ste mohli začať ďalšiu.", + "save_first_failed": "Správu sa nepodarilo uložiť, preto sa súbor neotvoril. Uložte správu a potom otvorte kalendár alebo kontakty." }, "sidebar_apps": { "modal_title": "Aplikácie postranného panela", diff --git a/locales/th/common.json b/locales/th/common.json index fadbbffd1..8ccc9804c 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2391,7 +2391,18 @@ "share_gone": "สิ่งที่คุณแชร์ไม่อยู่ที่นี่แล้ว โปรดแชร์อีกครั้ง", "share_files_lost": "เก็บไฟล์ที่แชร์ไว้ไม่ได้ โปรดเพิ่มไฟล์ลงในข้อความอีกครั้ง", "ics_needs_server": "การนำเข้าไฟล์ปฏิทินต้องเชื่อมต่อกับเซิร์ฟเวอร์ การนำเข้าจะเริ่มเมื่อคุณกลับมาออนไลน์", - "contacts_need_server": "การนำเข้ารายชื่อติดต่อต้องเชื่อมต่อกับเซิร์ฟเวอร์ โปรดลองอีกครั้งเมื่อคุณกลับมาออนไลน์" + "contacts_need_server": "การนำเข้ารายชื่อติดต่อต้องเชื่อมต่อกับเซิร์ฟเวอร์ โปรดลองอีกครั้งเมื่อคุณกลับมาออนไลน์", + "share_confirm_title": "เริ่มข้อความใหม่หรือไม่", + "share_confirm_message": "เว็บไซต์หรือแอปอื่นต้องการเริ่มข้อความ จะไม่มีการอัปโหลดหรือบันทึกสิ่งใดหากคุณไม่ยินยอม", + "share_confirm_subject": "หัวเรื่อง:", + "share_confirm_text": "ข้อความ:", + "share_confirm_files": "{count, plural, other {ไฟล์:}}", + "share_confirm_accept": "เริ่มข้อความ", + "share_confirm_decline": "ไม่เริ่ม", + "share_cut": "ข้อความที่แชร์ยาวเกินไปจึงถูกตัดให้สั้นลง", + "handoff_expired": "สิ่งที่คุณเปิดหรือแชร์รอนานเกินไปและไม่อยู่แล้ว โปรดเปิดหรือแชร์อีกครั้ง", + "compose_waiting": "ปิดข้อความนี้เพื่อเริ่มข้อความถัดไป", + "save_first_failed": "บันทึกข้อความไม่ได้ จึงยังไม่เปิดไฟล์ โปรดบันทึกข้อความแล้วเปิดปฏิทินหรือรายชื่อติดต่อ" }, "sidebar_apps": { "modal_title": "แอปพลิเคชันแถบด้านข้าง", diff --git a/locales/tr/common.json b/locales/tr/common.json index ff89c598e..f9a47385b 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -258,7 +258,18 @@ "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." + "contacts_need_server": "Kişileri içe aktarmak için sunucu bağlantısı gerekir. Yeniden çevrimiçi olduğunuzda tekrar deneyin.", + "share_confirm_title": "Yeni bir ileti başlatılsın mı?", + "share_confirm_message": "Başka bir site veya uygulama ileti başlatmak istiyor. Siz kabul etmedikçe hiçbir şey yüklenmez veya kaydedilmez.", + "share_confirm_subject": "Konu:", + "share_confirm_text": "Metin:", + "share_confirm_files": "{count, plural, one {Dosya:} other {Dosyalar:}}", + "share_confirm_accept": "İletiyi başlat", + "share_confirm_decline": "Başlatma", + "share_cut": "Paylaşılan metin çok uzundu ve kısaltıldı.", + "handoff_expired": "Açtığınız veya paylaştığınız bir şey çok uzun süre bekledi ve artık yok. Lütfen yeniden açın veya paylaşın.", + "compose_waiting": "Sonrakini başlatmak için bu iletiyi kapatın.", + "save_first_failed": "İletiniz kaydedilemedi, bu yüzden dosya açılmadı. İletiyi kaydedin, ardından takvimi veya kişileri açın." }, "sidebar_apps": { "modal_title": "Kenar çubuğu uygulamaları", diff --git a/locales/uk/common.json b/locales/uk/common.json index 8d6f02fc1..02f7fd724 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -258,7 +258,18 @@ "share_gone": "Те, чим ви поділилися, більше недоступне. Поділіться ще раз.", "share_files_lost": "Не вдалося зберегти отримані файли. Додайте їх до листа ще раз.", "ics_needs_server": "Для імпорту файлу календаря потрібне підключення до сервера. Імпорт почнеться, коли ви знову будете в мережі.", - "contacts_need_server": "Для імпорту контактів потрібне підключення до сервера. Спробуйте ще раз, коли знову будете в мережі." + "contacts_need_server": "Для імпорту контактів потрібне підключення до сервера. Спробуйте ще раз, коли знову будете в мережі.", + "share_confirm_title": "Почати новий лист?", + "share_confirm_message": "Інший сайт або застосунок хоче почати лист. Без вашої згоди нічого не буде завантажено чи збережено.", + "share_confirm_subject": "Тема:", + "share_confirm_text": "Текст:", + "share_confirm_files": "{count, plural, one {Файл:} few {Файли:} many {Файли:} other {Файли:}}", + "share_confirm_accept": "Почати лист", + "share_confirm_decline": "Не починати", + "share_cut": "Отриманий текст був задовгий, тому його скорочено.", + "handoff_expired": "Те, що ви відкрили або чим поділилися, чекало надто довго і більше недоступне. Відкрийте або поділіться ще раз.", + "compose_waiting": "Закрийте цей лист, щоб почати наступний.", + "save_first_failed": "Не вдалося зберегти лист, тому файл не відкрито. Збережіть лист, а потім відкрийте календар або контакти." }, "sidebar_apps": { "modal_title": "Програми бічної панелі", diff --git a/locales/vi/common.json b/locales/vi/common.json index f8f43adae..c0fc52f4e 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -258,7 +258,18 @@ "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." + "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.", + "share_confirm_title": "Bắt đầu thư mới?", + "share_confirm_message": "Một trang web hoặc ứng dụng khác muốn bắt đầu một thư. Sẽ không có gì được tải lên hoặc lưu nếu bạn không đồng ý.", + "share_confirm_subject": "Chủ đề:", + "share_confirm_text": "Nội dung:", + "share_confirm_files": "{count, plural, other {Tệp:}}", + "share_confirm_accept": "Bắt đầu thư", + "share_confirm_decline": "Không bắt đầu", + "share_cut": "Văn bản được chia sẻ quá dài và đã được rút ngắn.", + "handoff_expired": "Nội dung bạn đã mở hoặc chia sẻ đã chờ quá lâu và không còn nữa. Vui lòng mở hoặc chia sẻ lại.", + "compose_waiting": "Đóng thư này để bắt đầu thư tiếp theo.", + "save_first_failed": "Không thể lưu thư của bạn nên tệp chưa được mở. Hãy lưu thư, rồi mở lịch hoặc danh bạ." }, "sidebar_apps": { "modal_title": "Ứng dụng thanh bên", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 391355f2e..c80f7d0cd 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -258,7 +258,18 @@ "share_gone": "您分享的內容已不在此處。請重新分享。", "share_files_lost": "無法保留分享的檔案。請將它們重新加入郵件。", "ics_needs_server": "匯入行事曆檔案需要連線到伺服器。您恢復連線後將開始匯入。", - "contacts_need_server": "匯入聯絡人需要連線到伺服器。請在恢復連線後再試一次。" + "contacts_need_server": "匯入聯絡人需要連線到伺服器。請在恢復連線後再試一次。", + "share_confirm_title": "要開始撰寫新郵件嗎?", + "share_confirm_message": "另一個網站或應用程式想要開始撰寫郵件。除非您同意,否則不會上傳或儲存任何內容。", + "share_confirm_subject": "主旨:", + "share_confirm_text": "內文:", + "share_confirm_files": "{count, plural, other {檔案:}}", + "share_confirm_accept": "開始撰寫郵件", + "share_confirm_decline": "不要開始", + "share_cut": "分享的文字太長,已被縮短。", + "handoff_expired": "您開啟或分享的內容等待太久,已不存在。請重新開啟或分享。", + "compose_waiting": "請關閉此郵件以開始下一封。", + "save_first_failed": "無法儲存您的郵件,因此未開啟檔案。請儲存郵件,然後開啟行事曆或聯絡人。" }, "sidebar_apps": { "modal_title": "側邊欄應用程式", diff --git a/locales/zh/common.json b/locales/zh/common.json index 2fabc2fe3..a2713c9e2 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -258,7 +258,18 @@ "share_gone": "您分享的内容已不在此处。请重新分享。", "share_files_lost": "无法保留分享的文件。请将它们重新添加到邮件中。", "ics_needs_server": "导入日历文件需要连接到服务器。您恢复联网后将开始导入。", - "contacts_need_server": "导入联系人需要连接到服务器。请在恢复联网后重试。" + "contacts_need_server": "导入联系人需要连接到服务器。请在恢复联网后重试。", + "share_confirm_title": "要开始写新邮件吗?", + "share_confirm_message": "另一个网站或应用想要开始写一封邮件。除非您同意,否则不会上传或保存任何内容。", + "share_confirm_subject": "主题:", + "share_confirm_text": "正文:", + "share_confirm_files": "{count, plural, other {文件:}}", + "share_confirm_accept": "开始写邮件", + "share_confirm_decline": "不开始", + "share_cut": "分享的文本太长,已被缩短。", + "handoff_expired": "您打开或分享的内容等待时间过长,已不存在。请重新打开或分享。", + "compose_waiting": "请关闭此邮件以开始下一封。", + "save_first_failed": "无法保存您的邮件,因此未打开文件。请保存邮件,然后打开日历或联系人。" }, "sidebar_apps": { "modal_title": "侧边栏应用", diff --git a/public/sw.js b/public/sw.js index 6a313d86e..84f0e41c1 100644 --- a/public/sw.js +++ b/public/sw.js @@ -62,7 +62,7 @@ self.addEventListener("install", (event) => { }); self.addEventListener("activate", (event) => { - event.waitUntil(Promise.all([self.clients.claim(), deleteStaleShellCaches()])); + event.waitUntil(Promise.all([self.clients.claim(), deleteStaleShellCaches(), sweepHandoffsOnActivate()])); }); // ---- Offline mode (#523): the app shell ---- @@ -315,18 +315,92 @@ async function deleteStaleShellCaches() { // // 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. +// layout and limits as lib/launch-handoff.ts, which also removes them). Any +// site can POST here, signed in or not, and the worker can't tell who did: the +// page asks the user. So what is kept is limited, and sealed with a key that +// only the landing address's fragment knows. A browser without share_target +// gets the same question. Without a worker the POST reaches +// app/(main)/protocol/share/route.ts. const HANDOFF_CACHE = "bulwark-launch-handoff-v1"; +const HANDOFF_TTL_MS = 15 * 60 * 1000; +const HANDOFF_MAX_ENTRIES = 5; +const HANDOFF_MAX_BYTES = 100 * 1024 * 1024; function isShareTarget(request) { - if (request.method !== "POST" || request.mode !== "navigate") return false; + // A document only: a hidden frame doesn't get to store anything. + if (request.method !== "POST" || request.mode !== "navigate" || request.destination !== "document") return false; const url = new URL(request.url); return url.origin === self.location.origin && url.pathname.replace(/\/+$/, "") === BASE_PATH + "/protocol/share"; } +/** Drops what is too old and, when `incomingBytes` wants in, the oldest entries in the way. */ +async function sweepHandoffs(cache, incomingBytes) { + const keys = await cache.keys(); + const pathOf = (key) => new URL(key.url, self.location.origin).pathname; + const entries = []; + for (const key of keys) { + const path = pathOf(key); + if (!path.endsWith("/meta")) continue; + const response = await cache.match(key); + const meta = response ? await response.json().catch(() => null) : null; + entries.push({ prefix: path.slice(0, -"meta".length), createdAt: (meta && meta.createdAt) || 0, bytes: (meta && meta.bytes) || 0 }); + } + entries.sort((a, b) => a.createdAt - b.createdAt); + let total = entries.reduce((sum, entry) => sum + entry.bytes, 0); + while (entries.length > 0) { + const tooOld = Date.now() - entries[0].createdAt > HANDOFF_TTL_MS; + const inTheWay = incomingBytes !== null && (entries.length >= HANDOFF_MAX_ENTRIES || total + incomingBytes > HANDOFF_MAX_BYTES); + if (!tooOld && !inTheWay) break; + const oldest = entries.shift(); + total -= oldest.bytes; + const own = keys.filter((key) => pathOf(key).startsWith(oldest.prefix)); + // The list last: this sweep goes by it. + await Promise.all(own.filter((key) => !pathOf(key).endsWith("/meta")).map((key) => cache.delete(key))); + await cache.delete(oldest.prefix + "meta"); + } +} + +async function sweepHandoffsOnActivate() { + try { + if (typeof caches === "undefined" || !(await caches.has(HANDOFF_CACHE))) return; + await sweepHandoffs(await caches.open(HANDOFF_CACHE), null); + } catch (_) { + // Tried again at the next share. + } +} + +/** A 12-byte IV, then the AES-GCM ciphertext. */ +async function sealHandoff(key, data) { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const body = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, data)); + const sealed = new Uint8Array(iv.length + body.length); + sealed.set(iv); + sealed.set(body, iv.length); + return sealed; +} + +/** As much of `value` as takes up to `budget` bytes once encoded, as in the server's fallback. */ +function fitEncoded(value, budget, flags) { + let used = 0; + let out = ""; + for (const character of value) { + try { + used += encodeURIComponent(character).length; + } catch (_) { + continue; + } + if (used > budget) { + flags.cut = true; + return out; + } + out += character; + } + return out; +} + async function receiveShare(request) { - const land = (params) => Response.redirect(self.location.origin + BASE_PATH + "/?" + new URLSearchParams(params), 303); + // In the fragment: it stays out of access logs, and the key out of the server's sight. + const land = (params) => Response.redirect(self.location.origin + BASE_PATH + "/#" + new URLSearchParams(params), 303); let form; try { form = await request.formData(); @@ -341,22 +415,37 @@ async function receiveShare(request) { const files = form.getAll("files").filter((file) => typeof file !== "string" && file.size > 0); const id = crypto.randomUUID(); const prefix = "/__bulwark/handoff/" + id + "/"; + const bytes = files.reduce((sum, file) => sum + file.size, 0); let cache = null; try { + if (bytes > HANDOFF_MAX_BYTES) throw new Error("too large"); 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 }); + await sweepHandoffs(cache, bytes); + const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt"]); + const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key)); + const contents = JSON.stringify({ ...shared, files: files.map((file) => ({ name: file.name, type: file.type })) }); + // The list first: the sweep goes by it. + await cache.put(prefix + "meta", new Response(JSON.stringify({ kind: "compose", createdAt: Date.now(), bytes }))); + await cache.put(prefix + "sealed", new Response(await sealHandoff(key, new TextEncoder().encode(contents)))); + for (let index = 0; index < files.length; index += 1) { + await cache.put(prefix + index, new Response(await sealHandoff(key, await files[index].arrayBuffer()))); + } + const encodedKey = btoa(String.fromCharCode(...rawKey)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + return land({ share: id, key: encodedKey }); } 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(() => {}); + if (cache) await Promise.all([...files.map((_file, index) => cache.delete(prefix + index)), cache.delete(prefix + "sealed"), cache.delete(prefix + "meta")]).catch(() => {}); + const flags = { cut: false }; + const title = fitEncoded(shared.title, 400, flags); + const text = fitEncoded(shared.text, 1800, flags); + const link = fitEncoded(shared.url, 1000, flags); 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), + share_title: title, + share_text: text, + share_url: link, ...(files.length > 0 ? { share_files: "1" } : {}), + ...(flags.cut ? { share_cut: "1" } : {}), }); } }