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;