mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
chore: merge opening files and sharing into the mail branch
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 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 text and links go on
|
||||
* in the address's fragment, which stays out of access logs, and the page asks
|
||||
* the user and says the files are gone
|
||||
* (components/protocol/protocol-launch-handler-provider.tsx).
|
||||
*
|
||||
* Open to any origin, like a mailto: link: it writes nothing and acts as nobody.
|
||||
*/
|
||||
|
||||
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
|
||||
// A form with text and links is small. Anything bigger carries files, and
|
||||
// isn't read into memory just to throw them away.
|
||||
const MAX_READ_BYTES = 256 * 1024;
|
||||
// Encoded lengths: a proxy in front has to pass the Location header, and one
|
||||
// Japanese character takes 9 bytes in it.
|
||||
const BUDGET = { title: 400, url: 1000, text: 1800 };
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function land(fragment: string): Response {
|
||||
return new Response(null, { status: 303, headers: { Location: `${BASE_PATH}/${fragment}` } });
|
||||
}
|
||||
|
||||
/** As much of `value` as takes up to `budget` bytes once encoded, whole characters only. */
|
||||
function fitEncoded(value: string, budget: number): { value: string; cut: boolean } {
|
||||
let used = 0;
|
||||
let out = "";
|
||||
for (const character of value) {
|
||||
let size: number;
|
||||
try {
|
||||
size = encodeURIComponent(character).length;
|
||||
} catch {
|
||||
continue; // A lone surrogate.
|
||||
}
|
||||
used += size;
|
||||
if (used > budget) return { value: out, cut: true };
|
||||
out += character;
|
||||
}
|
||||
return { value: out, cut: false };
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const length = Number(request.headers.get("content-length") ?? "");
|
||||
const params = new URLSearchParams({ share: "unavailable" });
|
||||
if (!Number.isFinite(length) || length <= 0 || length > MAX_READ_BYTES) {
|
||||
params.set("share_files", "1");
|
||||
return land(`#${params}`);
|
||||
}
|
||||
try {
|
||||
const form = await request.formData();
|
||||
let cut = false;
|
||||
for (const name of ["title", "text", "url"] as const) {
|
||||
const value = form.get(name);
|
||||
if (typeof value !== "string" || !value) continue;
|
||||
const fitted = fitEncoded(value, BUDGET[name]);
|
||||
params.set(`share_${name}`, fitted.value);
|
||||
cut ||= fitted.cut;
|
||||
}
|
||||
if (cut) params.set("share_cut", "1");
|
||||
if (form.getAll("files").some((file) => typeof file !== "string" && file.size > 0)) {
|
||||
params.set("share_files", "1");
|
||||
}
|
||||
} catch {
|
||||
params.set("share_files", "1");
|
||||
}
|
||||
return land(`#${params}`);
|
||||
}
|
||||
|
||||
/** Nothing to share: the app. */
|
||||
export function GET(): Response {
|
||||
return land("");
|
||||
}
|
||||
+60
-1
@@ -1,5 +1,8 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { localeFromAcceptLanguage } from "@/i18n/locale-matcher";
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
import {
|
||||
matchDomainBranding,
|
||||
@@ -15,8 +18,27 @@ type WebAppProtocolHandler = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
type WebAppFileHandler = {
|
||||
action: string;
|
||||
accept: Record<string, string[]>;
|
||||
};
|
||||
|
||||
type WebAppShareTarget = {
|
||||
action: string;
|
||||
method: "POST";
|
||||
enctype: "multipart/form-data";
|
||||
params: {
|
||||
title: string;
|
||||
text: string;
|
||||
url: string;
|
||||
files: Array<{ name: string; accept: string[] }>;
|
||||
};
|
||||
};
|
||||
|
||||
type ExtendedManifest = MetadataRoute.Manifest & {
|
||||
protocol_handlers?: WebAppProtocolHandler[];
|
||||
file_handlers?: WebAppFileHandler[];
|
||||
share_target?: WebAppShareTarget;
|
||||
launch_handler?: {
|
||||
client_mode?: "navigate-existing" | "auto" | "focus-existing" | "navigate-new"
|
||||
| Array<"navigate-existing" | "auto" | "focus-existing" | "navigate-new">;
|
||||
@@ -32,7 +54,8 @@ const withBase = (p: string) => `${BASE_PATH}${p}`;
|
||||
export default async function manifest(): Promise<ExtendedManifest> {
|
||||
await configManager.ensureLoaded();
|
||||
|
||||
const host = pickRequestHost(await headers());
|
||||
const requestHeaders = await headers();
|
||||
const host = pickRequestHost(requestHeaders);
|
||||
const domainOverrides = matchDomainBranding(
|
||||
host,
|
||||
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
|
||||
@@ -83,6 +106,17 @@ export default async function manifest(): Promise<ExtendedManifest> {
|
||||
{ src: withBase("/icon-maskable-dark-512x512.png"), sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||
];
|
||||
|
||||
// Shortcut names show in the OS menu, in the language the browser asks in.
|
||||
const t = await getTranslations({
|
||||
locale: localeFromAcceptLanguage(requestHeaders.get("accept-language"), routing.locales) ?? routing.defaultLocale,
|
||||
namespace: "pwaLaunch",
|
||||
});
|
||||
const shortcutIcons = [{
|
||||
src: withBase(hasCustomIcon ? "/api/pwa-icon/192" : "/icon-192x192.png"),
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
}];
|
||||
|
||||
return {
|
||||
name: appName,
|
||||
short_name: shortName,
|
||||
@@ -118,6 +152,31 @@ export default async function manifest(): Promise<ExtendedManifest> {
|
||||
{ protocol: "mailto", url: withBase("/protocol/mailto?url=%s") },
|
||||
{ protocol: "webcal", url: withBase("/protocol/webcal?url=%s") },
|
||||
],
|
||||
shortcuts: [
|
||||
{ name: t("shortcut_new_message"), url: withBase("/?compose=1"), icons: shortcutIcons },
|
||||
{ name: t("shortcut_calendar"), url: withBase("/calendar"), icons: shortcutIcons },
|
||||
{ name: t("shortcut_contacts"), url: withBase("/contacts"), icons: shortcutIcons },
|
||||
],
|
||||
// Where a file goes is decided by its type, in lib/launch-handoff.ts. The
|
||||
// actions are the pages a new window should start on.
|
||||
file_handlers: [
|
||||
{ action: withBase("/mail"), accept: { "message/rfc822": [".eml"] } },
|
||||
{ action: withBase("/calendar"), accept: { "text/calendar": [".ics", ".ical"] } },
|
||||
{ action: withBase("/contacts"), accept: { "text/vcard": [".vcf", ".vcard"], "text/x-vcard": [".vcf", ".vcard"] } },
|
||||
],
|
||||
// Not under /api/: proxy.ts refuses a POST from outside there. The service
|
||||
// worker takes it (public/sw.js); without one, app/(main)/protocol/share.
|
||||
share_target: {
|
||||
action: withBase("/protocol/share"),
|
||||
method: "POST",
|
||||
enctype: "multipart/form-data",
|
||||
params: {
|
||||
title: "title",
|
||||
text: "text",
|
||||
url: "url",
|
||||
files: [{ name: "files", accept: ["*/*"] }],
|
||||
},
|
||||
},
|
||||
launch_handler: {
|
||||
client_mode: ["focus-existing", "navigate-new"],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useOfflineStore } from '@/lib/offline/state';
|
||||
import { ICalImportModal } from '../ical-import-modal';
|
||||
|
||||
// A .ics opened from the OS. The server reads it, so without a connection the
|
||||
// dialog has to say so instead of calling a good file invalid.
|
||||
|
||||
vi.mock('@/stores/calendar-store', () => ({
|
||||
useCalendarStore: (selector: (state: { importEvents: () => Promise<number> }) => unknown) => selector({ importEvents: async () => 1 }),
|
||||
}));
|
||||
vi.mock('@/stores/settings-store', () => ({
|
||||
useSettingsStore: (selector: (state: { timeFormat: string }) => unknown) => selector({ timeFormat: '24h' }),
|
||||
}));
|
||||
vi.mock('@/components/ui/dialog', () => ({
|
||||
Dialog: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
const calendars = [{ id: 'cal-1', name: 'Home', isDefault: true }] as never[];
|
||||
const ics = () => new File(['BEGIN:VCALENDAR'], 'invite.ics', { type: 'text/calendar' });
|
||||
|
||||
function client(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
getServerUrl: () => 'https://mail.example.org',
|
||||
getCalendarsAccountId: () => 'acc',
|
||||
uploadBlob: vi.fn(async () => ({ blobId: 'blob-1' })),
|
||||
parseCalendarEvents: vi.fn(async () => [{ title: 'Standup', start: '2026-11-03T09:00:00' }]),
|
||||
...overrides,
|
||||
} as never as { uploadBlob: ReturnType<typeof vi.fn>; parseCalendarEvents: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useOfflineStore.setState({ browserOffline: false, downServers: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useOfflineStore.setState({ browserOffline: false, downServers: [] });
|
||||
});
|
||||
|
||||
describe('ICalImportModal with an initial file', () => {
|
||||
it('reads the file at once and shows the preview', async () => {
|
||||
const c = client();
|
||||
render(<ICalImportModal calendars={calendars} client={c as never} onClose={vi.fn()} initialFile={ics()} />);
|
||||
await screen.findByText('Standup');
|
||||
expect(c.uploadBlob).toHaveBeenCalledTimes(1);
|
||||
expect(c.parseCalendarEvents).toHaveBeenCalledWith('acc', 'blob-1');
|
||||
});
|
||||
|
||||
it('reads nothing without one', async () => {
|
||||
const c = client();
|
||||
render(<ICalImportModal calendars={calendars} client={c as never} onClose={vi.fn()} />);
|
||||
await act(async () => {});
|
||||
expect(c.uploadBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('says the server is needed when offline, and starts by itself once back online', async () => {
|
||||
useOfflineStore.setState({ browserOffline: true });
|
||||
const c = client();
|
||||
render(<ICalImportModal calendars={calendars} client={c as never} onClose={vi.fn()} initialFile={ics()} />);
|
||||
expect(await screen.findByText('ics_needs_server')).toBeInTheDocument();
|
||||
expect(c.uploadBlob).not.toHaveBeenCalled();
|
||||
|
||||
useOfflineStore.setState({ browserOffline: false });
|
||||
await act(async () => { window.dispatchEvent(new Event('online')); });
|
||||
await screen.findByText('Standup');
|
||||
expect(c.uploadBlob).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('says the same when the connection fails while it reads', async () => {
|
||||
const c = client({
|
||||
uploadBlob: vi.fn(async () => {
|
||||
useOfflineStore.setState({ downServers: ['https://mail.example.org'] });
|
||||
throw new TypeError('Failed to fetch');
|
||||
}),
|
||||
});
|
||||
render(<ICalImportModal calendars={calendars} client={c as never} onClose={vi.fn()} initialFile={ics()} />);
|
||||
expect(await screen.findByText('ics_needs_server')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still calls a bad file invalid while online', async () => {
|
||||
const c = client({ parseCalendarEvents: vi.fn(async () => { throw new Error('parse'); }) });
|
||||
render(<ICalImportModal calendars={calendars} client={c as never} onClose={vi.fn()} initialFile={ics()} />);
|
||||
expect(await screen.findByText('invalid_format')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies the size limit to it', async () => {
|
||||
const big = new File([new Uint8Array(10 * 1024 * 1024 + 1)], 'big.ics');
|
||||
const c = client();
|
||||
render(<ICalImportModal calendars={calendars} client={c as never} onClose={vi.fn()} initialFile={big} />);
|
||||
expect(await screen.findByText('file_too_large')).toBeInTheDocument();
|
||||
expect(c.uploadBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('picks the default calendar when the calendars load after it opened', async () => {
|
||||
const importEvents = vi.fn();
|
||||
const c = client();
|
||||
const view = render(<ICalImportModal calendars={[]} client={c as never} onClose={vi.fn()} initialFile={ics()} />);
|
||||
await screen.findByText('Standup');
|
||||
view.rerender(
|
||||
<ICalImportModal
|
||||
calendars={[{ id: 'a', name: 'Work' }, { id: 'b', name: 'Home', isDefault: true }] as never[]}
|
||||
client={c as never}
|
||||
onClose={vi.fn()}
|
||||
initialFile={ics()}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe('b'));
|
||||
expect(importEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -179,6 +180,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,
|
||||
@@ -2579,6 +2584,16 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
/>
|
||||
)}
|
||||
|
||||
{launchedFile.file && client && (
|
||||
<ICalImportModal
|
||||
key={`${launchedFile.file.name}:${launchedFile.file.lastModified}`}
|
||||
calendars={displayCalendars}
|
||||
client={client}
|
||||
initialFile={launchedFile.file}
|
||||
onClose={launchedFile.done}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSubscriptionModal && client && (
|
||||
<ICalSubscriptionModal
|
||||
client={client}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useId } from "react";
|
||||
import { useState, useCallback, useEffect, useRef, useId } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { buildRecurrenceSummary } from "./recurrence-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -16,12 +16,15 @@ import { toast } from "@/stores/toast-store";
|
||||
import { apiFetch } from "@/lib/browser-navigation";
|
||||
import { explainServerAuthError } from "@/lib/server-auth-status";
|
||||
import { IS_LITE } from "@/lib/lite";
|
||||
import { isOfflineNow } from "@/lib/offline/state";
|
||||
|
||||
interface ICalImportModalProps {
|
||||
calendars: Calendar[];
|
||||
client: IJMAPClient;
|
||||
onClose: () => void;
|
||||
initialUrl?: string;
|
||||
/** A file opened from the OS: read at once, as if picked here. */
|
||||
initialFile?: File | null;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
@@ -30,8 +33,9 @@ const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
|
||||
type ImportStep = "select" | "preview" | "importing";
|
||||
type ImportMode = "file" | "url";
|
||||
|
||||
export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICalImportModalProps) {
|
||||
export function ICalImportModal({ calendars, client, onClose, initialUrl, initialFile }: ICalImportModalProps) {
|
||||
const t = useTranslations("calendar.import");
|
||||
const tLaunch = useTranslations("pwaLaunch");
|
||||
const tCal = useTranslations("calendar");
|
||||
const locale = useLocale();
|
||||
const tForm = useTranslations("calendar.form");
|
||||
@@ -56,6 +60,15 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
|
||||
const dropZoneRef = useRef<HTMLButtonElement>(null);
|
||||
const urlInputRef = useRef<HTMLInputElement>(null);
|
||||
const targetId = useId();
|
||||
// The file that couldn't be read for lack of a connection, read again when
|
||||
// it is back.
|
||||
const waitingForServer = useRef<File | null>(null);
|
||||
|
||||
// Opened before the calendars had loaded, from a launch: the default once they have.
|
||||
useEffect(() => {
|
||||
if (calendarId || calendars.length === 0) return;
|
||||
setCalendarId((calendars.find((c) => c.isDefault) ?? calendars[0]).id);
|
||||
}, [calendars, calendarId]);
|
||||
|
||||
const validateFile = useCallback((file: File): string | null => {
|
||||
if (file.size > MAX_FILE_SIZE) return t("file_too_large");
|
||||
@@ -71,6 +84,16 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
|
||||
return;
|
||||
}
|
||||
|
||||
// The server reads the file (uploadBlob, then CalendarEvent/parse). Say so
|
||||
// rather than call a good file invalid.
|
||||
const serverUrl = client.getServerUrl();
|
||||
if (isOfflineNow(serverUrl)) {
|
||||
waitingForServer.current = file;
|
||||
setError(tLaunch("ics_needs_server"));
|
||||
return;
|
||||
}
|
||||
waitingForServer.current = null;
|
||||
|
||||
setError(null);
|
||||
setIsParsing(true);
|
||||
setStep("select");
|
||||
@@ -91,11 +114,31 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
|
||||
setSelectedIndices(new Set(events.map((_, i) => i)));
|
||||
setStep("preview");
|
||||
} catch {
|
||||
setError(t("invalid_format"));
|
||||
if (isOfflineNow(serverUrl)) {
|
||||
waitingForServer.current = file;
|
||||
setError(tLaunch("ics_needs_server"));
|
||||
} else {
|
||||
setError(t("invalid_format"));
|
||||
}
|
||||
} finally {
|
||||
setIsParsing(false);
|
||||
}
|
||||
}, [client, validateFile, t]);
|
||||
}, [client, validateFile, t, tLaunch]);
|
||||
|
||||
const initialFileRead = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialFile || initialFileRead.current) return;
|
||||
initialFileRead.current = true;
|
||||
void handleFile(initialFile);
|
||||
}, [initialFile, handleFile]);
|
||||
|
||||
useEffect(() => {
|
||||
const retry = () => {
|
||||
if (waitingForServer.current) void handleFile(waitingForServer.current);
|
||||
};
|
||||
window.addEventListener("online", retry);
|
||||
return () => window.removeEventListener("online", retry);
|
||||
}, [handleFile]);
|
||||
|
||||
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useOfflineStore } from '@/lib/offline/state';
|
||||
import { ContactImportDialog } from '../contact-import-dialog';
|
||||
|
||||
// A .vcf opened from the OS: it parses in the browser, also without a connection.
|
||||
|
||||
const VCARD = ['BEGIN:VCARD', 'VERSION:3.0', 'FN:Ada Lovelace', 'EMAIL:ada@example.org', 'END:VCARD', ''].join('\r\n');
|
||||
const vcf = (body = VCARD) => {
|
||||
const file = new File([body], 'people.vcf', { type: 'text/vcard' });
|
||||
// jsdom's File has no text().
|
||||
if (typeof file.text !== 'function') Object.defineProperty(file, 'text', { value: async () => body });
|
||||
return file;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
useOfflineStore.setState({ browserOffline: false, downServers: [] });
|
||||
});
|
||||
|
||||
describe('ContactImportDialog with an initial file', () => {
|
||||
it('parses it at once and offers the cards', async () => {
|
||||
render(<ContactImportDialog existingContacts={[]} onImport={vi.fn()} onClose={vi.fn()} initialFile={vcf()} />);
|
||||
expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('parses it offline as well', async () => {
|
||||
useOfflineStore.setState({ browserOffline: true });
|
||||
render(<ContactImportDialog existingContacts={[]} onImport={vi.fn()} onClose={vi.fn()} initialFile={vcf()} />);
|
||||
expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies the size limit to it', async () => {
|
||||
const big = new File([new Uint8Array(5 * 1024 * 1024 + 1)], 'big.vcf');
|
||||
render(<ContactImportDialog existingContacts={[]} onImport={vi.fn()} onClose={vi.fn()} initialFile={big} />);
|
||||
expect(await screen.findByText('import.file_too_large')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the file input without one', async () => {
|
||||
const { container } = render(<ContactImportDialog existingContacts={[]} onImport={vi.fn()} onClose={vi.fn()} />);
|
||||
expect(container.ownerDocument.querySelector('input[type="file"]')).not.toBeNull();
|
||||
expect(screen.queryByText('Ada Lovelace')).toBeNull();
|
||||
});
|
||||
|
||||
it('says the server is needed when the import fails offline', async () => {
|
||||
const onImport = vi.fn(async () => {
|
||||
useOfflineStore.setState({ browserOffline: true });
|
||||
throw new TypeError('Failed to fetch');
|
||||
});
|
||||
render(<ContactImportDialog existingContacts={[]} onImport={onImport} onClose={vi.fn()} initialFile={vcf()} />);
|
||||
await screen.findByText('Ada Lovelace');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'import.import_button' }));
|
||||
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('contacts_need_server'));
|
||||
});
|
||||
|
||||
it('calls a failed import failed while online', async () => {
|
||||
const onImport = vi.fn(async () => { throw new Error('server said no'); });
|
||||
render(<ContactImportDialog existingContacts={[]} onImport={onImport} onClose={vi.fn()} initialFile={vcf()} />);
|
||||
await screen.findByText('Ada Lovelace');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'import.import_button' }));
|
||||
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('import.failed'));
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Upload, FileText, AlertTriangle, Check } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -9,19 +9,24 @@ import { cn } from "@/lib/utils";
|
||||
import { parseVCard, detectDuplicates } from "@/lib/vcard";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { isOfflineNow } from "@/lib/offline/state";
|
||||
|
||||
interface ContactImportDialogProps {
|
||||
existingContacts: ContactCard[];
|
||||
onImport: (contacts: ContactCard[]) => Promise<number>;
|
||||
onClose: () => void;
|
||||
/** A file opened from the OS: read at once, as if picked here. */
|
||||
initialFile?: File | null;
|
||||
}
|
||||
|
||||
export function ContactImportDialog({
|
||||
existingContacts,
|
||||
onImport,
|
||||
onClose,
|
||||
initialFile,
|
||||
}: ContactImportDialogProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const tLaunch = useTranslations("pwaLaunch");
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [parsed, setParsed] = useState<ContactCard[]>([]);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
@@ -30,10 +35,7 @@ export function ContactImportDialog({
|
||||
const [result, setResult] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const readFile = useCallback(async (file: File) => {
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
@@ -66,6 +68,18 @@ export function ContactImportDialog({
|
||||
}
|
||||
}, [existingContacts, t]);
|
||||
|
||||
const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) await readFile(file);
|
||||
}, [readFile]);
|
||||
|
||||
const initialFileRead = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialFile || initialFileRead.current) return;
|
||||
initialFileRead.current = true;
|
||||
void readFile(initialFile);
|
||||
}, [initialFile, readFile]);
|
||||
|
||||
const toggleSelect = (idx: number) => {
|
||||
const next = new Set(selected);
|
||||
if (next.has(idx)) {
|
||||
@@ -94,7 +108,7 @@ export function ContactImportDialog({
|
||||
setResult(count);
|
||||
} catch (error) {
|
||||
console.error('Failed to import contacts:', error);
|
||||
setError(t("import.failed"));
|
||||
setError(isOfflineNow() ? tLaunch("contacts_need_server") : t("import.failed"));
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/typ
|
||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||
import { appPath, buildContactsPath, parseContactsPath, type ContactDeepLink } from "@/lib/deep-links";
|
||||
import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
|
||||
import { useHandoffFiles } from "@/hooks/use-handoff-files";
|
||||
import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
|
||||
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
|
||||
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
|
||||
@@ -356,6 +357,14 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
// focused tab writes the address bar; the standard instance that renders
|
||||
// while Pro takes over a route stays silent.
|
||||
const proInterfaceActive = useProInterfaceActive();
|
||||
// A .vcf opened from the OS (lib/launch-handoff.ts). It waits for the
|
||||
// contacts, or everything would look new to the duplicate check. The
|
||||
// standard page that renders while the Pro shell takes over leaves it for
|
||||
// the Pro one.
|
||||
const launchedFile = useHandoffFiles(
|
||||
"contacts",
|
||||
isAuthenticated && !!client && (contactsLoaded || !supportsSync) && (isEmbedded || !proInterfaceActive),
|
||||
);
|
||||
const isFocusedProTab = useIsFocusedProTab();
|
||||
const contactsLinkPath = appPath(buildContactsPath({
|
||||
contactId: view === 'detail' || view === 'edit' ? selectedContactId : null,
|
||||
@@ -1572,6 +1581,15 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
if (movingContactIds) void handleDropContacts(movingContactIds, book);
|
||||
}}
|
||||
/>
|
||||
{launchedFile.file && (
|
||||
<ContactImportDialog
|
||||
key={`${launchedFile.file.name}:${launchedFile.file.lastModified}`}
|
||||
existingContacts={contacts}
|
||||
onImport={handleImportContacts}
|
||||
initialFile={launchedFile.file}
|
||||
onClose={launchedFile.done}
|
||||
/>
|
||||
)}
|
||||
{showImportDialog && (
|
||||
<ContactImportDialog
|
||||
existingContacts={contacts}
|
||||
|
||||
@@ -232,6 +232,124 @@ function pickFromFiles(nodes: FileNode[]) {
|
||||
fireEvent.click(screen.getByTestId('fake-file-picker'));
|
||||
}
|
||||
|
||||
describe('files handed to a new composer (a share, a launch)', () => {
|
||||
afterEach(() => {
|
||||
useAuthStore.setState({ client: null });
|
||||
vi.clearAllMocks();
|
||||
hoisted.beforeAttachmentUpload.mockImplementation(async () => true);
|
||||
});
|
||||
|
||||
const uploading = () => filesClient({ uploadBlob: vi.fn(() => new Promise(() => {})), getMaxSizeUpload: () => 0 });
|
||||
|
||||
const handedIn = (extra: Record<string, unknown> = {}) => ({
|
||||
to: '', cc: '', bcc: '', subject: 'Shared subject', body: 'Shared text', showCc: false, showBcc: false,
|
||||
selectedIdentityId: null, subAddressTag: '', mode: 'compose', draftId: null, unedited: true, ...extra,
|
||||
}) as never;
|
||||
const settled = (client: Record<string, unknown>) => ({
|
||||
...client,
|
||||
uploadBlob: vi.fn(async () => ({ blobId: 'blob-shared', size: 1, type: 'text/plain' })),
|
||||
getMaxSizeUpload: () => 0,
|
||||
});
|
||||
|
||||
it('saves no draft from handed-in text and files until the first real edit', async () => {
|
||||
const client = settled(filesClient()) as unknown as ReturnType<typeof filesClient>;
|
||||
useAuthStore.setState({ client: client as never });
|
||||
vi.useFakeTimers();
|
||||
render(<EmailComposer onClose={vi.fn()} initialData={handedIn()} initialFiles={[new File(['x'], 'shared.pdf')]} />);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5000); });
|
||||
expect(screen.getByText('shared.pdf')).toBeInTheDocument();
|
||||
expect(client.createDraft).not.toHaveBeenCalled();
|
||||
|
||||
// Closing the page doesn't save it either.
|
||||
await act(async () => { window.dispatchEvent(new Event('beforeunload')); await vi.advanceTimersByTimeAsync(100); });
|
||||
expect(client.createDraft).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/subject/i), { target: { value: 'Shared subject, edited' } });
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(2500); });
|
||||
expect(client.createDraft).toHaveBeenCalledTimes(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('closes without asking while nothing is the user\'s own', async () => {
|
||||
const onClose = vi.fn();
|
||||
useAuthStore.setState({ client: settled(filesClient()) as never });
|
||||
const ref = { current: null as ((afterClose?: () => void) => void) | null };
|
||||
render(<EmailComposer onClose={onClose} requestCloseRef={ref} initialData={handedIn()} initialFiles={[new File(['x'], 'shared.pdf')]} />);
|
||||
await screen.findByText('shared.pdf');
|
||||
await act(async () => { ref.current?.(); });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('counts a removed attachment as an edit', async () => {
|
||||
const client = settled(filesClient()) as unknown as ReturnType<typeof filesClient>;
|
||||
useAuthStore.setState({ client: client as never });
|
||||
vi.useFakeTimers();
|
||||
render(<EmailComposer onClose={vi.fn()} initialData={handedIn()} initialFiles={[new File(['x'], 'shared.pdf')]} />);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(100); });
|
||||
const chip = screen.getByText('shared.pdf').closest('[data-testid], div')!.parentElement!;
|
||||
const remove = chip.querySelector('button[aria-label]') ?? chip.querySelector('button');
|
||||
fireEvent.click(remove!);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(2500); });
|
||||
expect(client.createDraft).toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('hands its unedited state back to a remount, without asking to save', async () => {
|
||||
useAuthStore.setState({ client: settled(filesClient()) as never });
|
||||
const onSaveState = vi.fn();
|
||||
const view = render(<EmailComposer onClose={vi.fn()} onSaveState={onSaveState} initialData={handedIn()} initialFiles={[new File(['x'], 'shared.pdf')]} />);
|
||||
await screen.findByText('shared.pdf');
|
||||
view.unmount();
|
||||
expect(onSaveState).toHaveBeenCalledWith(expect.objectContaining({ unedited: true, unsaved: false, subject: 'Shared subject' }));
|
||||
});
|
||||
|
||||
it('attaches them when it opens', async () => {
|
||||
useAuthStore.setState({ client: uploading() as never });
|
||||
render(<EmailComposer onClose={vi.fn()} initialFiles={[new File(['x'], 'shared.pdf', { type: 'application/pdf' })]} />);
|
||||
expect(await screen.findByText('shared.pdf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('waits for the client', async () => {
|
||||
const files = [new File(['x'], 'shared.pdf')];
|
||||
const view = render(<EmailComposer onClose={vi.fn()} initialFiles={files} />);
|
||||
await act(async () => {});
|
||||
expect(hoisted.beforeAttachmentUpload).not.toHaveBeenCalled();
|
||||
|
||||
useAuthStore.setState({ client: uploading() as never });
|
||||
view.rerender(<EmailComposer key="with-client" onClose={vi.fn()} initialFiles={files} />);
|
||||
expect(await screen.findByText('shared.pdf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('attaches an array once, also when the composer remounts with it', async () => {
|
||||
useAuthStore.setState({ client: uploading() as never });
|
||||
const files = [new File(['x'], 'shared.pdf')];
|
||||
const first = render(<EmailComposer onClose={vi.fn()} initialFiles={files} />);
|
||||
await screen.findByText('shared.pdf');
|
||||
first.unmount();
|
||||
|
||||
render(<EmailComposer onClose={vi.fn()} initialFiles={files} />);
|
||||
await act(async () => {});
|
||||
expect(hoisted.beforeAttachmentUpload).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText('shared.pdf')).toBeNull();
|
||||
});
|
||||
|
||||
it('runs the same checks as a picked file: a plugin may refuse it', async () => {
|
||||
hoisted.beforeAttachmentUpload.mockImplementation(async (info) => info.name !== 'setup.exe');
|
||||
useAuthStore.setState({ client: uploading() as never });
|
||||
render(<EmailComposer onClose={vi.fn()} initialFiles={[new File(['x'], 'setup.exe'), new File(['x'], 'notes.pdf')]} />);
|
||||
expect(await screen.findByText('notes.pdf')).toBeInTheDocument();
|
||||
expect(screen.queryByText('setup.exe')).toBeNull();
|
||||
});
|
||||
|
||||
it('runs the same checks as a picked file: the size limit', async () => {
|
||||
useAuthStore.setState({ client: filesClient({ getMaxSizeUpload: () => 1 }) as never });
|
||||
render(<EmailComposer onClose={vi.fn()} initialFiles={[new File(['too big'], 'big.pdf')]} />);
|
||||
await act(async () => {});
|
||||
expect(hoisted.toastError).toHaveBeenCalledWith('attachment_too_large');
|
||||
expect(screen.queryByText('big.pdf')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('attach from Files', () => {
|
||||
afterEach(() => {
|
||||
useAuthStore.setState({ client: null });
|
||||
|
||||
@@ -213,7 +213,16 @@ function createChipDragPreview(label: string): HTMLElement {
|
||||
// inserts as a single chip and expands into its members on send.
|
||||
type SuggestionItem = { name: string; email: string; group?: { id: string; memberCount: number } };
|
||||
|
||||
// Which `initialFiles` arrays are attached: a remount gets the same array.
|
||||
const attachedInitialFiles = new WeakSet<File[]>();
|
||||
|
||||
export interface ComposerDraftData {
|
||||
/**
|
||||
* Handed in from outside (a share, the desktop app) and not touched yet.
|
||||
* It is only where the composer starts: no draft on the server until the
|
||||
* user's first real edit.
|
||||
*/
|
||||
unedited?: boolean;
|
||||
to: string;
|
||||
cc: string;
|
||||
bcc: string;
|
||||
@@ -360,6 +369,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 +507,7 @@ export function EmailComposer({
|
||||
className,
|
||||
initialDraftText: initialDraftTextProp,
|
||||
initialData: initialDataProp,
|
||||
initialFiles,
|
||||
prefill,
|
||||
sessionKey,
|
||||
sourceEmailId,
|
||||
@@ -1760,6 +1776,16 @@ export function EmailComposer({
|
||||
}
|
||||
changeVersionRef.current += 1;
|
||||
}, [toStr, ccStr, bccStr, subject, body, attachments.length]);
|
||||
// Handed-in text and files aren't the user's until they touch them. Nothing
|
||||
// counts as dirty until then, so autosave, the page-close save and the flush
|
||||
// before a sign-out all leave them alone.
|
||||
const uneditedRef = useRef(initialData?.unedited === true);
|
||||
const [firstEditSeen, setFirstEditSeen] = useState(false);
|
||||
const markEdited = useCallback(() => {
|
||||
if (!uneditedRef.current) return;
|
||||
uneditedRef.current = false;
|
||||
setFirstEditSeen(true);
|
||||
}, []);
|
||||
const isDirtyRef = useRef(false);
|
||||
isDirtyRef.current = toStr !== initialValuesRef.current.to || ccStr !== initialValuesRef.current.cc ||
|
||||
bccStr !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject ||
|
||||
@@ -1771,6 +1797,19 @@ export function EmailComposer({
|
||||
if (priority !== initialPriorityRef.current) isDirtyRef.current = true;
|
||||
const initialLabelChoiceRef = useRef(classification.choice);
|
||||
if (classification.choice !== initialLabelChoiceRef.current) isDirtyRef.current = true;
|
||||
if (uneditedRef.current) isDirtyRef.current = false;
|
||||
// Changes only the user makes, also without typing: a picked recipient, a
|
||||
// removed attachment, a priority. Handed-in files move the count they're
|
||||
// compared with (addFiles). The body isn't here, a signature can change it;
|
||||
// typing in it comes through the composer root's input events.
|
||||
useEffect(() => {
|
||||
if (!uneditedRef.current) return;
|
||||
const start = initialValuesRef.current;
|
||||
if (toStr !== start.to || ccStr !== start.cc || bccStr !== start.bcc || subject !== start.subject
|
||||
|| attachments.length !== start.attachmentCount || priority !== initialPriorityRef.current) {
|
||||
markEdited();
|
||||
}
|
||||
}, [toStr, ccStr, bccStr, subject, attachments.length, priority, markEdited]);
|
||||
const firstLabelRunRef = useRef(true);
|
||||
useEffect(() => {
|
||||
if (firstLabelRunRef.current) {
|
||||
@@ -1827,7 +1866,9 @@ export function EmailComposer({
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (explicitCloseRef.current) return;
|
||||
if (onSaveState && isDirtyRef.current) {
|
||||
// Kept in the host's memory across a remount, handed-in or not. Only
|
||||
// the server stays out of it.
|
||||
if (onSaveState && (isDirtyRef.current || uneditedRef.current)) {
|
||||
const s = stateRef.current;
|
||||
onSaveState({
|
||||
...s,
|
||||
@@ -1838,7 +1879,8 @@ export function EmailComposer({
|
||||
classification: classificationRef.current.draftChoice,
|
||||
// Edits the last save didn't cover; a minimised composer with
|
||||
// none needs no save before an automatic sign-out.
|
||||
unsaved: changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current,
|
||||
unsaved: !uneditedRef.current && (changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current),
|
||||
...(uneditedRef.current ? { unedited: true } : {}),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -2294,7 +2336,7 @@ export function EmailComposer({
|
||||
return () => window.removeEventListener('keydown', handleTemplateKey);
|
||||
}, [templatesEnabled]);
|
||||
|
||||
const addFiles = useCallback(async (files: File[]) => {
|
||||
const addFiles = useCallback(async (files: File[], opts?: { handedIn?: boolean }) => {
|
||||
if (!client || files.length === 0) return;
|
||||
|
||||
// Let plugins veto each upload before it's queued.
|
||||
@@ -2348,6 +2390,8 @@ export function EmailComposer({
|
||||
abortController: controller,
|
||||
};
|
||||
});
|
||||
// Files handed in are where the composer starts, not an edit.
|
||||
if (opts?.handedIn) initialValuesRef.current.attachmentCount += newAttachments.length;
|
||||
setAttachments(prev => [...prev, ...newAttachments]);
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
@@ -2461,6 +2505,12 @@ export function EmailComposer({
|
||||
}
|
||||
}, [client, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !initialFiles || initialFiles.length === 0 || attachedInitialFiles.has(initialFiles)) return;
|
||||
attachedInitialFiles.add(initialFiles);
|
||||
void addFiles(initialFiles, { handedIn: true });
|
||||
}, [client, initialFiles, addFiles]);
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!event.target.files) return;
|
||||
await addFiles(Array.from(event.target.files));
|
||||
@@ -2914,7 +2964,7 @@ export function EmailComposer({
|
||||
// login page can say a draft wasn't saved.
|
||||
useEffect(() => registerComposeFlush(async () => {
|
||||
while (isSendingRef.current) await new Promise((resolve) => setTimeout(resolve, SEND_WAIT_POLL_MS));
|
||||
if (sentRef.current) return;
|
||||
if (sentRef.current || uneditedRef.current) return;
|
||||
const unsaved = changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current;
|
||||
if (!unsaved) return;
|
||||
if (saveTimeoutRef.current) {
|
||||
@@ -3267,7 +3317,7 @@ export function EmailComposer({
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- saveDraft reads current state when called, not when effect is set up
|
||||
}, [toStr, ccStr, bccStr, subject, body, attachments, priority]);
|
||||
}, [toStr, ccStr, bccStr, subject, body, attachments, priority, firstEditSeen]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -4274,6 +4324,13 @@ export function EmailComposer({
|
||||
data-testid="email-composer"
|
||||
className={cn("flex h-full bg-background relative", className)}
|
||||
onKeyDownCapture={handleComposerKeyDown}
|
||||
// Bubble phase, in the same batch as the field's own onChange. A state
|
||||
// change in the capture phase renders first and puts the old value back.
|
||||
onInput={markEdited}
|
||||
onChange={markEdited}
|
||||
onPaste={markEdited}
|
||||
onCut={markEdited}
|
||||
onDrop={markEdited}
|
||||
>
|
||||
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only" data-testid="composer-live-region">
|
||||
{liveMessage && <span key={liveMessage.n}>{liveMessage.text}</span>}
|
||||
|
||||
@@ -145,6 +145,7 @@ import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-h
|
||||
import { setNotificationReplyOpener } from "@/lib/pwa-notify/page-actions";
|
||||
import { requestTabReply } from "@/lib/pwa-notify/tab-reply";
|
||||
import { consumePendingReplyText } from "@/lib/pwa-notify/worker-state";
|
||||
import { pendingHandoffs, subscribeHandoff, takeHandoff, type Handoff } from "@/lib/launch-handoff";
|
||||
import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { PLUGIN_REPLY_EVENT, type PluginReplyRequest } from "@/lib/plugin-sandbox/host-api-compose";
|
||||
import { onOpenEmail, type OpenEmailHandler, type OpenEmailOutcome } from "@/lib/plugin-sandbox/open-email";
|
||||
@@ -233,6 +234,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[]; unedited?: boolean };
|
||||
|
||||
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);
|
||||
@@ -319,7 +323,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// Office attachments open read-only in the configured WOPI editor (#1047).
|
||||
const [officeAttachment, setOfficeAttachment] = useState<{ blobId: string; name: string; type?: string; size?: number; accountId: string; slot: number | null } | null>(null);
|
||||
const wopiStatus = useWopiStatus(true);
|
||||
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
|
||||
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<DraftRequest | null>(null);
|
||||
// Files from a share or a launch, for the next composer. It attaches an
|
||||
// array once, so a stale one does nothing.
|
||||
const [composerInitialFiles, setComposerInitialFiles] = useState<File[] | null>(null);
|
||||
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
|
||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastUndoToastSubmissionRef = useRef<string | null>(null);
|
||||
@@ -1381,6 +1388,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,
|
||||
@@ -1391,6 +1399,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
|
||||
@@ -1470,18 +1479,22 @@ 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));
|
||||
setPendingDraft({
|
||||
...mailtoComposerDraft(pending, useSettingsStore.getState().plainTextMode),
|
||||
...(pending.unedited ? { unedited: true } : {}),
|
||||
});
|
||||
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
|
||||
@@ -1500,7 +1513,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) {
|
||||
@@ -1524,13 +1537,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(async (pending: DraftRequest): Promise<void> => {
|
||||
const protocolAccounts = getMailtoProtocolAccounts();
|
||||
if (protocolAccounts.length > 1) {
|
||||
setPendingMailtoAccountChoice(pending);
|
||||
@@ -1539,12 +1547,87 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
|
||||
const accountId = protocolAccounts[0]?.id ?? activeAccountId;
|
||||
if (accountId) {
|
||||
void openMailtoForAccount(pending, accountId);
|
||||
await openMailtoForAccount(pending, accountId);
|
||||
return;
|
||||
}
|
||||
|
||||
openMailtoDraft(pending);
|
||||
}, [activeAccountId, getMailtoProtocolAccounts, mailtoAllowed, openMailtoDraft, openMailtoForAccount]);
|
||||
await openMailtoDraft(pending);
|
||||
}, [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;
|
||||
}
|
||||
void 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;
|
||||
// One at a time. A composer that is open holds the user's text, so what comes
|
||||
// next stays queued until it closes, and a second one can't replace the
|
||||
// first. So does one set aside by reading a message, while the server hasn't
|
||||
// got what it holds. Under Pro a composer moves to its own tab, so nothing
|
||||
// waits long.
|
||||
const handoffOpeningRef = useRef(false);
|
||||
const handoffToldWaitingRef = useRef(new Set<string>());
|
||||
const composerInUseRef = useRef(false);
|
||||
const composerInUse = showComposer || pendingMailtoAccountChoice !== null
|
||||
|| pendingDraft?.unsaved === true || pendingDraft?.unedited === true;
|
||||
composerInUseRef.current = composerInUse;
|
||||
const [handoffSettled, setHandoffSettled] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !client || proTakingOver) return;
|
||||
|
||||
const open = async (handoff: Handoff) => {
|
||||
if (!handoff.subject && !handoff.body && handoff.files.length === 0) {
|
||||
await withComposeGate('compose', {}, () => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
});
|
||||
return;
|
||||
}
|
||||
await routeOutsideDraft({
|
||||
to: [],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: handoff.subject ?? '',
|
||||
body: handoff.body ?? '',
|
||||
initialFiles: handoff.files,
|
||||
unedited: true,
|
||||
});
|
||||
};
|
||||
|
||||
const openNext = () => {
|
||||
if (handoffOpeningRef.current) return;
|
||||
if (composerInUseRef.current) {
|
||||
const waiting = pendingHandoffs().find((handoff) => handoff.kind === 'compose');
|
||||
if (waiting && !handoffToldWaitingRef.current.has(waiting.id)) {
|
||||
handoffToldWaitingRef.current.add(waiting.id);
|
||||
toast.info(t('pwaLaunch.compose_waiting'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const handoff = takeHandoff('compose');
|
||||
if (!handoff) return;
|
||||
handoffOpeningRef.current = true;
|
||||
void open(handoff).finally(() => {
|
||||
handoffOpeningRef.current = false;
|
||||
setHandoffSettled((n) => n + 1);
|
||||
});
|
||||
};
|
||||
|
||||
openNext();
|
||||
return subscribeHandoff('compose', openNext);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAuthenticated, client, proTakingOver, routeOutsideDraft, withComposeGate, isMobile, composerInUse, handoffSettled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !client) return;
|
||||
@@ -4887,6 +4970,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
initialDraftText={composerDraftText}
|
||||
initialData={pendingDraft}
|
||||
prefill={composerPrefill}
|
||||
initialFiles={composerInitialFiles}
|
||||
sessionKey={`inline-${composerSessionId}`}
|
||||
sourceEmailId={composerSourceEmail?.id}
|
||||
onSaveState={(data) => {
|
||||
|
||||
@@ -244,6 +244,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||
initialDraftText={data.initialDraftText}
|
||||
initialData={data.initialData}
|
||||
prefill={data.prefill}
|
||||
initialFiles={data.initialFiles}
|
||||
sessionKey={`pro-${tabId}-${data.sessionId}`}
|
||||
sourceEmailId={data.sourceEmailId ?? undefined}
|
||||
composeFromAccountEmail={composeFromAccountEmail}
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FakeCaches } from '@/lib/__tests__/sw-harness';
|
||||
|
||||
// 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(),
|
||||
toastInfo: vi.fn(),
|
||||
flushComposers: vi.fn(async () => ({ total: 0, settled: 0, failed: 0, timedOut: false })),
|
||||
peek: vi.fn(),
|
||||
adopt: vi.fn(),
|
||||
discard: vi.fn(async () => {}),
|
||||
savePendingMailto: vi.fn(),
|
||||
protocolOpenMode: 'active-session',
|
||||
}));
|
||||
|
||||
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: hoisted.toastInfo, success: vi.fn() } }));
|
||||
vi.mock('@/lib/compose-flush-registry', () => ({ flushComposers: hoisted.flushComposers }));
|
||||
vi.mock('@/lib/launch-handoff', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/launch-handoff')>()),
|
||||
peekStoredHandoff: hoisted.peek,
|
||||
adoptStoredHandoff: hoisted.adopt,
|
||||
discardStoredHandoff: hoisted.discard,
|
||||
}));
|
||||
vi.mock('@/stores/settings-store', () => ({
|
||||
useSettingsStore: Object.assign(() => undefined, { getState: () => ({ protocolOpenMode: hoisted.protocolOpenMode }) }),
|
||||
}));
|
||||
vi.mock('@/lib/protocol-handlers/session', () => ({
|
||||
listenForMailtoRequests: () => () => {},
|
||||
notifyPendingMailto: vi.fn(),
|
||||
notifyPendingWebcal: vi.fn(),
|
||||
requestOpenMailtoInExistingClient: vi.fn(async () => false),
|
||||
savePendingMailto: hoisted.savePendingMailto,
|
||||
savePendingWebcal: vi.fn(),
|
||||
}));
|
||||
// The real modal has its own tests; here: does the launch reach it, and is
|
||||
// its download gated by the same function.
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({
|
||||
FilePreviewModal: function PreviewStub({ name, onClose, onDownload, getFileContent }: {
|
||||
name: string; onClose: () => void; onDownload: () => void;
|
||||
getFileContent: () => Promise<{ blob: Blob; contentType: string }>;
|
||||
}) {
|
||||
const [type, setType] = React.useState('');
|
||||
React.useEffect(() => { void getFileContent().then((c) => setType(c.contentType)); }, [getFileContent]);
|
||||
return (
|
||||
<div data-testid="eml-viewer" data-type={type}>
|
||||
{name}
|
||||
<button onClick={onClose}>close</button>
|
||||
<button onClick={onDownload}>save</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
const { clearHandoffs, handOffFiles, pendingHandoffs, takeHandoff, HANDOFF_TTL_MS } = await import('@/lib/launch-handoff');
|
||||
const { setDownloadGuard } = await import('@/lib/download-guard');
|
||||
const { ProtocolLaunchHandlerProvider } = await import('../protocol-launch-handler-provider');
|
||||
|
||||
type Consumer = (params: { targetURL?: string; files?: Array<{ kind: string; getFile: () => Promise<File> }> }) => void;
|
||||
|
||||
let consumer: Consumer | null = null;
|
||||
const setConsumer = vi.fn((fn: Consumer) => { consumer = fn; });
|
||||
const handle = (name: string, type = '') => ({ kind: 'file', getFile: async () => new File(['x'], name, { type }) });
|
||||
const flush = () => act(async () => { await new Promise((r) => setTimeout(r, 0)); });
|
||||
|
||||
function mount() {
|
||||
return render(<ProtocolLaunchHandlerProvider><div>app</div></ProtocolLaunchHandlerProvider>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
consumer = null;
|
||||
setConsumer.mockClear();
|
||||
hoisted.push.mockClear();
|
||||
hoisted.toastError.mockClear();
|
||||
hoisted.toastInfo.mockClear();
|
||||
hoisted.flushComposers.mockReset();
|
||||
hoisted.flushComposers.mockResolvedValue({ total: 0, settled: 0, failed: 0, timedOut: false });
|
||||
hoisted.peek.mockReset();
|
||||
hoisted.adopt.mockReset();
|
||||
hoisted.discard.mockClear();
|
||||
hoisted.savePendingMailto.mockClear();
|
||||
hoisted.pathname.current = '/';
|
||||
hoisted.protocolOpenMode = 'active-session';
|
||||
sessionStorage.clear();
|
||||
vi.stubGlobal('caches', new FakeCaches());
|
||||
window.launchQueue = { setConsumer } as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearHandoffs();
|
||||
delete window.launchQueue;
|
||||
vi.unstubAllGlobals();
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
describe('file launches', () => {
|
||||
it('opens a .eml in the viewer without leaving the page', async () => {
|
||||
mount();
|
||||
consumer!({ targetURL: `${window.location.origin}/mail`, files: [handle('note.eml')] });
|
||||
const viewer = await screen.findByTestId('eml-viewer');
|
||||
expect(viewer).toHaveTextContent('note.eml');
|
||||
await waitFor(() => expect(viewer).toHaveAttribute('data-type', 'message/rfc822'));
|
||||
expect(hoisted.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows several .eml files one after the other', async () => {
|
||||
mount();
|
||||
consumer!({ files: [handle('one.eml'), handle('two.eml')] });
|
||||
expect(await screen.findByTestId('eml-viewer')).toHaveTextContent('one.eml');
|
||||
await act(async () => { screen.getByText('close').click(); });
|
||||
expect(await screen.findByTestId('eml-viewer')).toHaveTextContent('two.eml');
|
||||
await act(async () => { screen.getByText('close').click(); });
|
||||
expect(screen.queryByTestId('eml-viewer')).toBeNull();
|
||||
});
|
||||
|
||||
it("saves a .eml only if the device policy allows downloads", async () => {
|
||||
const created = vi.fn(() => 'blob:x');
|
||||
vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: created, revokeObjectURL: vi.fn() }));
|
||||
setDownloadGuard(() => false);
|
||||
mount();
|
||||
consumer!({ files: [handle('note.eml')] });
|
||||
await screen.findByTestId('eml-viewer');
|
||||
await act(async () => { screen.getByText('save').click(); });
|
||||
expect(created).not.toHaveBeenCalled();
|
||||
setDownloadGuard(null);
|
||||
});
|
||||
|
||||
it('takes a .ics to the calendar with the file waiting', async () => {
|
||||
mount();
|
||||
consumer!({ targetURL: `${window.location.origin}/calendar`, files: [handle('invite.ics')] });
|
||||
await flush();
|
||||
expect(hoisted.push).toHaveBeenCalledWith('/calendar');
|
||||
expect(takeHandoff('calendar')?.files[0].name).toBe('invite.ics');
|
||||
});
|
||||
|
||||
it('takes a .vcf to the contacts with the file waiting', async () => {
|
||||
mount();
|
||||
consumer!({ files: [handle('people.vcf')] });
|
||||
await flush();
|
||||
expect(hoisted.push).toHaveBeenCalledWith('/contacts');
|
||||
expect(takeHandoff('contacts')?.files[0].name).toBe('people.vcf');
|
||||
});
|
||||
|
||||
it('stays when already on the right page', async () => {
|
||||
hoisted.pathname.current = '/calendar';
|
||||
mount();
|
||||
consumer!({ files: [handle('invite.ics')] });
|
||||
await flush();
|
||||
expect(hoisted.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('goes to the page in the Pro shell too, which has one route', async () => {
|
||||
hoisted.pathname.current = '/pro';
|
||||
mount();
|
||||
consumer!({ files: [handle('invite.ics')] });
|
||||
await flush();
|
||||
expect(hoisted.push).toHaveBeenCalledWith('/calendar');
|
||||
});
|
||||
|
||||
it('says so for a file it cannot handle', async () => {
|
||||
mount();
|
||||
consumer!({ files: [handle('photo.png', 'image/png')] });
|
||||
await flush();
|
||||
expect(hoisted.toastError).toHaveBeenCalledWith('unsupported_file');
|
||||
expect(pendingHandoffs()).toEqual([]);
|
||||
});
|
||||
|
||||
it('says so for a file it cannot read', async () => {
|
||||
mount();
|
||||
consumer!({ files: [{ kind: 'file', getFile: async () => { throw new Error('denied'); } }] });
|
||||
await flush();
|
||||
expect(hoisted.toastError).toHaveBeenCalledWith('open_failed');
|
||||
});
|
||||
|
||||
it('waits on the sign-in page, and goes on after it', async () => {
|
||||
hoisted.pathname.current = '/login';
|
||||
const view = mount();
|
||||
consumer!({ files: [handle('invite.ics')] });
|
||||
await flush();
|
||||
expect(hoisted.push).not.toHaveBeenCalled();
|
||||
|
||||
hoisted.pathname.current = '/';
|
||||
view.rerender(<ProtocolLaunchHandlerProvider><div>app</div></ProtocolLaunchHandlerProvider>);
|
||||
await flush();
|
||||
expect(hoisted.push).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.push).toHaveBeenCalledWith('/calendar');
|
||||
});
|
||||
|
||||
it('goes to the page only once for a handoff, so it does not trap a later visit elsewhere', async () => {
|
||||
hoisted.pathname.current = '/settings';
|
||||
const view = mount();
|
||||
consumer!({ files: [handle('invite.ics')] });
|
||||
await flush();
|
||||
expect(hoisted.push).toHaveBeenCalledTimes(1);
|
||||
|
||||
hoisted.pathname.current = '/mail';
|
||||
view.rerender(<ProtocolLaunchHandlerProvider><div>app</div></ProtocolLaunchHandlerProvider>);
|
||||
await flush();
|
||||
expect(hoisted.push).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('launches that are not files', () => {
|
||||
it('still handles mailto:', async () => {
|
||||
mount();
|
||||
consumer!({ targetURL: `${window.location.origin}/protocol/mailto?url=${encodeURIComponent('mailto:a@example.org')}` });
|
||||
await waitFor(() => expect(hoisted.savePendingMailto).toHaveBeenCalled());
|
||||
expect(hoisted.savePendingMailto.mock.calls[0][0].to).toEqual(['a@example.org']);
|
||||
});
|
||||
|
||||
it('opens a new message from the shortcut in an open window', async () => {
|
||||
hoisted.pathname.current = '/settings';
|
||||
mount();
|
||||
consumer!({ targetURL: `${window.location.origin}/?compose=1` });
|
||||
await flush();
|
||||
expect(hoisted.push).toHaveBeenCalledWith('/');
|
||||
expect(takeHandoff('compose')).toMatchObject({ files: [] });
|
||||
});
|
||||
|
||||
it('goes to the calendar from its shortcut in an open window', async () => {
|
||||
mount();
|
||||
consumer!({ targetURL: `${window.location.origin}/calendar` });
|
||||
await flush();
|
||||
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('leaving a composer for a launched file', () => {
|
||||
it('saves what was typed first, then goes', async () => {
|
||||
const order: string[] = [];
|
||||
hoisted.flushComposers.mockImplementation(async () => { order.push('flush'); return { total: 1, settled: 1, failed: 0, timedOut: false }; });
|
||||
hoisted.push.mockImplementation(() => { order.push('push'); });
|
||||
mount();
|
||||
consumer!({ files: [handle('people.vcf')] });
|
||||
await flush();
|
||||
expect(order).toEqual(['flush', 'push']);
|
||||
hoisted.push.mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the server refused the save', { total: 1, settled: 1, failed: 1, timedOut: false }],
|
||||
['the save took too long', { total: 1, settled: 0, failed: 0, timedOut: true }],
|
||||
])('stays when %s, with the file still waiting', async (_name, result) => {
|
||||
hoisted.flushComposers.mockResolvedValue(result);
|
||||
mount();
|
||||
consumer!({ files: [handle('people.vcf')] });
|
||||
await flush();
|
||||
expect(hoisted.push).not.toHaveBeenCalled();
|
||||
expect(hoisted.toastError).toHaveBeenCalledWith('save_first_failed');
|
||||
expect(pendingHandoffs().map((h) => h.kind)).toEqual(['contacts']);
|
||||
});
|
||||
|
||||
it('does not save for a .eml, which leaves nothing', async () => {
|
||||
mount();
|
||||
consumer!({ files: [handle('note.eml')] });
|
||||
await screen.findByTestId('eml-viewer');
|
||||
expect(hoisted.flushComposers).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a handoff that goes without arriving', () => {
|
||||
it('says so', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
mount();
|
||||
handOffFiles([new File(['x'], 'a.ics')], 'calendar');
|
||||
vi.advanceTimersByTime(HANDOFF_TTL_MS + 1);
|
||||
takeHandoff('calendar');
|
||||
vi.useRealTimers();
|
||||
expect(hoisted.toastError).toHaveBeenCalledWith('handoff_expired');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the address a launch lands on', () => {
|
||||
it('opens a new message once and removes the parameter', async () => {
|
||||
window.history.replaceState(null, '', '/?compose=1&slot=2#x');
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a share from an address anyone can make', () => {
|
||||
const dialogText = () => screen.getByTestId('share-confirm-details').textContent ?? '';
|
||||
const agree = () => fireEvent.click(screen.getByRole('button', { name: 'share_confirm_accept' }));
|
||||
const refuse = () => fireEvent.click(screen.getByRole('button', { name: 'share_confirm_decline' }));
|
||||
|
||||
it('asks about text in the address, and starts nothing before the answer', async () => {
|
||||
window.history.replaceState(null, '', '/#share=unavailable&share_title=T&share_text=B&share_files=1&share_cut=1');
|
||||
mount();
|
||||
expect(window.location.hash).toBe('');
|
||||
await screen.findByText('share_confirm_message');
|
||||
expect(dialogText()).toContain('T');
|
||||
expect(pendingHandoffs()).toEqual([]);
|
||||
expect(hoisted.push).not.toHaveBeenCalled();
|
||||
|
||||
agree();
|
||||
await flush();
|
||||
expect(takeHandoff('compose')).toMatchObject({ subject: 'T', body: 'B' });
|
||||
expect(hoisted.toastError).toHaveBeenCalledWith('share_files_lost');
|
||||
expect(hoisted.toastInfo).toHaveBeenCalledWith('share_cut');
|
||||
});
|
||||
|
||||
it('starts nothing when refused', async () => {
|
||||
window.history.replaceState(null, '', '/#share=unavailable&share_text=B');
|
||||
mount();
|
||||
await screen.findByText('share_confirm_message');
|
||||
refuse();
|
||||
await flush();
|
||||
expect(pendingHandoffs()).toEqual([]);
|
||||
});
|
||||
|
||||
it('only reports lost files when there was no text to ask about', async () => {
|
||||
window.history.replaceState(null, '', '/#share=unavailable&share_files=1');
|
||||
mount();
|
||||
await flush();
|
||||
expect(screen.queryByText('share_confirm_message')).toBeNull();
|
||||
expect(hoisted.toastError).toHaveBeenCalledWith('share_files_lost');
|
||||
expect(pendingHandoffs()).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores it in the query, which reaches logs and any link', async () => {
|
||||
window.history.replaceState(null, '', '/?share=unavailable&share_text=B');
|
||||
mount();
|
||||
await flush();
|
||||
expect(screen.queryByText('share_confirm_message')).toBeNull();
|
||||
expect(pendingHandoffs()).toEqual([]);
|
||||
});
|
||||
|
||||
it('asks about a stored share, naming the files, before anything is taken in', async () => {
|
||||
hoisted.peek.mockResolvedValue({ subject: 'S', body: '', files: ['invoice.exe'] });
|
||||
hoisted.adopt.mockResolvedValue(true);
|
||||
window.history.replaceState(null, '', '/#share=abc&key=k1');
|
||||
mount();
|
||||
expect(window.location.hash).toBe('');
|
||||
await screen.findByText('share_confirm_message');
|
||||
expect(hoisted.peek).toHaveBeenCalledWith('abc', 'k1');
|
||||
expect(dialogText()).toContain('invoice.exe');
|
||||
expect(hoisted.adopt).not.toHaveBeenCalledWith('abc', 'k1');
|
||||
|
||||
agree();
|
||||
await waitFor(() => expect(hoisted.adopt).toHaveBeenCalledWith('abc', 'k1'));
|
||||
expect(hoisted.discard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks in an installed-app window as well: link capturing can bring a POST there', async () => {
|
||||
const original = window.matchMedia;
|
||||
window.matchMedia = ((query: string) => ({ matches: query.includes('standalone'), media: query, addEventListener: () => {}, removeEventListener: () => {} })) as never;
|
||||
hoisted.peek.mockResolvedValue({ subject: 'S', body: '', files: [] });
|
||||
window.history.replaceState(null, '', '/#share=abc&key=k1');
|
||||
mount();
|
||||
await screen.findByText('share_confirm_message');
|
||||
expect(hoisted.adopt).not.toHaveBeenCalledWith('abc', 'k1');
|
||||
window.matchMedia = original;
|
||||
});
|
||||
|
||||
it('drops a stored share when refused', async () => {
|
||||
hoisted.peek.mockResolvedValue({ subject: 'S', body: '', files: ['invoice.exe'] });
|
||||
window.history.replaceState(null, '', '/#share=abc&key=k1');
|
||||
mount();
|
||||
await screen.findByText('share_confirm_message');
|
||||
refuse();
|
||||
await waitFor(() => expect(hoisted.discard).toHaveBeenCalledWith('abc'));
|
||||
expect(hoisted.adopt).not.toHaveBeenCalledWith('abc', 'k1');
|
||||
});
|
||||
|
||||
it('says so when what was shared is gone', async () => {
|
||||
hoisted.peek.mockResolvedValue(null);
|
||||
window.history.replaceState(null, '', '/#share=abc&key=k1');
|
||||
mount();
|
||||
expect(window.location.hash).toBe('');
|
||||
await flush();
|
||||
expect(hoisted.toastError).toHaveBeenCalledWith('share_gone');
|
||||
expect(screen.queryByText('share_confirm_message')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState, type ComponentType, type ComponentProps } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { FilePreviewModal as FilePreviewModalType } from "@/components/files/file-preview-modal";
|
||||
import { useHandoffFiles } from "@/hooks/use-handoff-files";
|
||||
import { saveBlobAs } from "@/lib/download-guard";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
type PreviewComponent = ComponentType<ComponentProps<typeof FilePreviewModalType>>;
|
||||
|
||||
/**
|
||||
* Shows a .eml opened from the OS. It parses in the browser, so it works
|
||||
* offline and before sign-in. `policyAccount` stays out: the strictest device
|
||||
* policy of the accounts signed in here decides about saving (FilePreviewModal
|
||||
* and saveBlobAs check it).
|
||||
*
|
||||
* The preview is loaded when a file arrives, to keep it off the layout's
|
||||
* bundle, and by hand rather than with next/dynamic: without a connection and
|
||||
* without the offline shell its chunk can't load, which has to end in a
|
||||
* message, not in the error page, and work again later.
|
||||
*/
|
||||
export function LaunchFileHost() {
|
||||
const t = useTranslations("pwaLaunch");
|
||||
const { file, done } = useHandoffFiles("eml", true);
|
||||
const [Preview, setPreview] = useState<PreviewComponent | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!file || Preview) return;
|
||||
let stale = false;
|
||||
import("@/components/files/file-preview-modal")
|
||||
.then((module) => {
|
||||
if (!stale) setPreview(() => module.FilePreviewModal);
|
||||
})
|
||||
.catch(() => {
|
||||
if (stale) return;
|
||||
toast.error(t("open_failed"));
|
||||
done();
|
||||
});
|
||||
return () => { stale = true; };
|
||||
}, [file, Preview, done, t]);
|
||||
|
||||
const getFileContent = useCallback(async () => {
|
||||
if (!file) throw new Error("no file");
|
||||
return { blob: file, contentType: "message/rfc822" };
|
||||
}, [file]);
|
||||
|
||||
const onDownload = useCallback(() => {
|
||||
if (file) saveBlobAs(file, file.name, { kind: "attachment" });
|
||||
}, [file]);
|
||||
|
||||
if (!file || !Preview) return null;
|
||||
return (
|
||||
<Preview
|
||||
key={`${file.name}:${file.lastModified}`}
|
||||
name={file.name}
|
||||
onClose={done}
|
||||
onDownload={onDownload}
|
||||
getFileContent={getFileContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { usePathname, useRouter } from "@/i18n/navigation";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { flushComposers } from "@/lib/compose-flush-registry";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { matchSurface, type AppSurface } from "@/lib/deep-links";
|
||||
import {
|
||||
adoptStoredHandoff,
|
||||
discardStoredHandoff,
|
||||
handOffDraft,
|
||||
handOffLaunchFiles,
|
||||
handOffNewMessage,
|
||||
parseLaunchLanding,
|
||||
peekStoredHandoff,
|
||||
pendingHandoffs,
|
||||
restoreStoredHandoffs,
|
||||
shortcutFromTargetUrl,
|
||||
stripLandingParams,
|
||||
subscribeHandoff,
|
||||
subscribeHandoffLost,
|
||||
type HandoffKind,
|
||||
} from "@/lib/launch-handoff";
|
||||
import { productName } from "@/lib/product-name";
|
||||
import { parseMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { parseWebcal } from "@/lib/protocol-handlers/webcal";
|
||||
@@ -18,8 +38,13 @@ import {
|
||||
savePendingWebcal,
|
||||
} from "@/lib/protocol-handlers/session";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { LaunchFileHost } from "./launch-file-host";
|
||||
|
||||
type LaunchParams = { targetURL?: string };
|
||||
type LaunchParams = {
|
||||
targetURL?: string;
|
||||
files?: ReadonlyArray<{ kind?: string; getFile?: () => Promise<File> }>;
|
||||
};
|
||||
type StandaloneNavigator = Navigator & { standalone?: boolean };
|
||||
|
||||
declare global {
|
||||
@@ -51,6 +76,21 @@ function getProtocolLaunch(targetURL: string):
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The page a handoff waits for. A .eml has none: it opens over any page. */
|
||||
const HANDOFF_TARGET: Partial<Record<HandoffKind, { surface: AppSurface; path: string }>> = {
|
||||
compose: { surface: "mail", path: "/" },
|
||||
calendar: { surface: "calendar", path: "/calendar" },
|
||||
contacts: { surface: "contacts", path: "/contacts" },
|
||||
};
|
||||
|
||||
/** How long a launch waits for an open composer to save before it leaves the page. */
|
||||
const FLUSH_BEFORE_LEAVING_MS = 5000;
|
||||
/** How much of a share the question shows. */
|
||||
const OFFER_TEXT_LENGTH = 200;
|
||||
const OFFER_FILE_COUNT = 5;
|
||||
|
||||
type ShareOffer = { subject: string; body: string; files: string[] };
|
||||
|
||||
function isStandaloneDisplayMode() {
|
||||
return window.matchMedia?.("(display-mode: standalone)").matches
|
||||
|| (navigator as StandaloneNavigator).standalone === true;
|
||||
@@ -73,6 +113,97 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle
|
||||
const appName = productName(useConfig().appName);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const tLaunch = useTranslations("pwaLaunch");
|
||||
const sentToTarget = useRef(new Set<string>());
|
||||
const { dialogProps, confirm } = useConfirmDialog();
|
||||
const [offer, setOffer] = useState<ShareOffer | null>(null);
|
||||
|
||||
// Anyone can POST to the share target or link to a landing address, and
|
||||
// Chromium's link capturing can bring a cross-site form POST into an
|
||||
// installed app's window, so nothing tells a real share from a planted one.
|
||||
// Nothing goes into an account until the user says yes.
|
||||
const askAboutShare = useCallback(async (shared: ShareOffer) => {
|
||||
setOffer(shared);
|
||||
const agreed = await confirm({
|
||||
title: tLaunch("share_confirm_title"),
|
||||
message: tLaunch("share_confirm_message"),
|
||||
confirmText: tLaunch("share_confirm_accept"),
|
||||
cancelText: tLaunch("share_confirm_decline"),
|
||||
});
|
||||
setOffer(null);
|
||||
return agreed;
|
||||
}, [confirm, tLaunch]);
|
||||
|
||||
// What the page's own address asks for: a shortcut's new message, or what
|
||||
// was shared. Once: it leaves the address before anything else happens.
|
||||
useEffect(() => {
|
||||
const stopLossNotes = subscribeHandoffLost((loss) => {
|
||||
toast.error(tLaunch(loss === "missing" ? "share_files_lost" : "handoff_expired"));
|
||||
});
|
||||
const { search, hash, pathname: path } = window.location;
|
||||
const landing = parseLaunchLanding(search, hash);
|
||||
if (landing) {
|
||||
const left = stripLandingParams(search, hash);
|
||||
window.history.replaceState(window.history.state, "", path + left.search + left.hash);
|
||||
}
|
||||
if (landing?.type === "new-message") {
|
||||
handOffNewMessage();
|
||||
} else if (landing?.type === "inline") {
|
||||
void (async () => {
|
||||
const { subject, body } = landing;
|
||||
if ((subject || body) && !(await askAboutShare({ subject, body, files: [] }))) return;
|
||||
if (subject || body) handOffDraft({ subject, body });
|
||||
if (landing.filesLost) toast.error(tLaunch("share_files_lost"));
|
||||
if (landing.cut) toast.info(tLaunch("share_cut"));
|
||||
})();
|
||||
} else if (landing?.type === "stored") {
|
||||
void (async () => {
|
||||
const shared = await peekStoredHandoff(landing.id, landing.key);
|
||||
if (!shared) {
|
||||
await discardStoredHandoff(landing.id);
|
||||
toast.error(tLaunch("share_gone"));
|
||||
return;
|
||||
}
|
||||
if (!(await askAboutShare(shared))) {
|
||||
await discardStoredHandoff(landing.id);
|
||||
return;
|
||||
}
|
||||
if (!(await adoptStoredHandoff(landing.id, landing.key))) toast.error(tLaunch("share_gone"));
|
||||
})();
|
||||
}
|
||||
void restoreStoredHandoffs();
|
||||
return stopLossNotes;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Leaving the mail page ends an open composer, and its autosave waits 2
|
||||
// seconds: what was just typed is saved first. If it can't be, stay. A file
|
||||
// keeps waiting and opens once the user goes to that page.
|
||||
const leaveFor = useCallback(async (path: "/" | "/calendar" | "/contacts") => {
|
||||
const result = await flushComposers(FLUSH_BEFORE_LEAVING_MS);
|
||||
if (result.failed > 0 || result.timedOut) {
|
||||
toast.error(tLaunch("save_first_failed"));
|
||||
return;
|
||||
}
|
||||
router.push(path);
|
||||
}, [router, tLaunch]);
|
||||
|
||||
// Takes a handoff to the page that handles it, once each: on arrival, or
|
||||
// when the sign-in it had to wait for is done.
|
||||
useEffect(() => {
|
||||
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) void leaveFor(target.path as "/" | "/calendar" | "/contacts");
|
||||
return;
|
||||
}
|
||||
};
|
||||
goToTarget();
|
||||
return subscribeHandoff("any", goToTarget);
|
||||
}, [pathname, leaveFor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname.startsWith("/protocol/")) return;
|
||||
@@ -93,10 +224,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) void leaveFor(`/${shortcut}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (launch.kind === "mailto") {
|
||||
const parsed = parseMailto(launch.raw);
|
||||
@@ -130,7 +274,23 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle
|
||||
notifyPendingWebcal();
|
||||
if (pathname !== "/calendar") router.push("/calendar");
|
||||
});
|
||||
}, [pathname, router]);
|
||||
}, [pathname, router, tLaunch, leaveFor]);
|
||||
|
||||
return children;
|
||||
const offeredFiles = offer?.files ?? [];
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<LaunchFileHost />
|
||||
<ConfirmDialog
|
||||
{...dialogProps}
|
||||
details={offer && (
|
||||
<dl className="space-y-1 break-words text-sm" data-testid="share-confirm-details">
|
||||
{offer.subject && <div><dt className="inline font-medium">{tLaunch("share_confirm_subject")} </dt><dd className="inline">{offer.subject}</dd></div>}
|
||||
{offer.body && <div><dt className="inline font-medium">{tLaunch("share_confirm_text")} </dt><dd className="inline">{offer.body.slice(0, OFFER_TEXT_LENGTH)}</dd></div>}
|
||||
{offeredFiles.length > 0 && <div><dt className="inline font-medium">{tLaunch("share_confirm_files", { count: offeredFiles.length })} </dt><dd className="inline">{offeredFiles.slice(0, OFFER_FILE_COUNT).join(", ")}{offeredFiles.length > OFFER_FILE_COUNT ? ", …" : ""}</dd></div>}
|
||||
</dl>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { subscribeHandoff, takeHandoff, type HandoffKind } from "@/lib/launch-handoff";
|
||||
|
||||
/**
|
||||
* The files handed to this part of the app (lib/launch-handoff.ts), one at a
|
||||
* time. Nothing is taken while `active` is false, for a surface that isn't
|
||||
* ready for them yet. `done` moves on to the next file.
|
||||
*/
|
||||
export function useHandoffFiles(kind: HandoffKind, active: boolean): { file: File | null; done: () => void } {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const take = () => {
|
||||
for (let handoff = takeHandoff(kind); handoff; handoff = takeHandoff(kind)) {
|
||||
const received = handoff.files;
|
||||
if (received.length > 0) setFiles((waiting) => [...waiting, ...received]);
|
||||
}
|
||||
};
|
||||
take();
|
||||
return subscribeHandoff(kind, take);
|
||||
}, [kind, active]);
|
||||
|
||||
const done = useCallback(() => setFiles((waiting) => waiting.slice(1)), []);
|
||||
return { file: files[0] ?? null, done };
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
import { test, expect, type Browser, type BrowserContext, type Page } from '@playwright/test';
|
||||
import { accountSwitcher, neutralizeDevOverlay } from './helpers/app';
|
||||
import { WEBMAIL_URL } from './helpers/config';
|
||||
import { ENT, ENT_PASSWORD, adminContext, contextOptions, getPolicy, jmapAs, putPolicy, requireEntEnv, shot } from './helpers/ent';
|
||||
|
||||
/**
|
||||
* The installed app's launches through the real UI: shortcuts, files opened
|
||||
* from the OS, and shares. Chromium can't fire launchQueue from a test, so a
|
||||
* stand-in catches the consumer the app registers, and the spec calls that
|
||||
* consumer the way the browser would. Shares go through the real service
|
||||
* worker as a real form POST. Needs a production build, the only one that
|
||||
* registers the worker.
|
||||
*
|
||||
* BUILD=1 bash desktop-offline-rig/start-prod.sh <worktree> 3230 <jmap-url> pwa-launch-1.0
|
||||
* bash desktop-offline-rig/run-spec.sh <worktree> 3230 <jmap-url> 1.0 integration/tests/23-do-pwa-launch.spec.ts
|
||||
*/
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const RUN = Date.now().toString(36);
|
||||
const composer = (page: Page) => page.locator('[data-testid="email-composer"]').first();
|
||||
const SHARE_CACHE = 'bulwark-launch-handoff-v1';
|
||||
|
||||
type LaunchFile = { name: string; type: string; body: string };
|
||||
|
||||
const EML: LaunchFile = {
|
||||
name: 'note.eml',
|
||||
type: 'message/rfc822',
|
||||
body: [
|
||||
'From: Bob <bob@example.org>',
|
||||
'To: Alice <alice@example.org>',
|
||||
`Subject: Launched message ${RUN}`,
|
||||
'Date: Tue, 03 Nov 2026 10:00:00 +0000',
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'',
|
||||
'Opened from the file system.',
|
||||
'',
|
||||
].join('\r\n'),
|
||||
};
|
||||
|
||||
const ICS: LaunchFile = {
|
||||
name: 'invite.ics',
|
||||
type: 'text/calendar',
|
||||
body: [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//Bulwark//Test//EN',
|
||||
'BEGIN:VEVENT',
|
||||
`UID:launch-${RUN}@example.org`,
|
||||
'DTSTAMP:20261103T100000Z',
|
||||
'DTSTART:20261110T100000Z',
|
||||
'DTEND:20261110T110000Z',
|
||||
`SUMMARY:Launched event ${RUN}`,
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
'',
|
||||
].join('\r\n'),
|
||||
};
|
||||
|
||||
const VCF: LaunchFile = {
|
||||
name: 'people.vcf',
|
||||
type: 'text/vcard',
|
||||
body: ['BEGIN:VCARD', 'VERSION:3.0', `FN:Launched Person ${RUN}`, `EMAIL:launched-${RUN}@example.org`, 'END:VCARD', ''].join('\r\n'),
|
||||
};
|
||||
|
||||
/** Stands in for the browser's launchQueue, keeping the consumer the app sets. */
|
||||
function catchLaunchConsumer() {
|
||||
Object.defineProperty(window, 'launchQueue', {
|
||||
configurable: true,
|
||||
value: {
|
||||
setConsumer(fn: unknown) {
|
||||
(window as unknown as { __launch: unknown }).__launch = fn;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function launch(page: Page, opts: { target?: string; files?: LaunchFile[] }): Promise<void> {
|
||||
await page.waitForFunction(() => typeof (window as unknown as { __launch?: unknown }).__launch === 'function');
|
||||
await page.evaluate(({ target, files }) => {
|
||||
const consumer = (window as unknown as { __launch: (params: unknown) => void }).__launch;
|
||||
consumer({
|
||||
targetURL: new URL(target, window.location.origin).href,
|
||||
...(files.length > 0 ? {
|
||||
files: files.map((f) => ({ kind: 'file', getFile: async () => new File([f.body], f.name, { type: f.type }) })),
|
||||
} : {}),
|
||||
});
|
||||
}, { target: opts.target ?? '/', files: opts.files ?? [] });
|
||||
}
|
||||
|
||||
async function signIn(page: Page, email: string): Promise<void> {
|
||||
await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await page.fill('#username', email);
|
||||
await page.fill('#password', ENT_PASSWORD);
|
||||
await page.click('button[type="submit"]');
|
||||
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 });
|
||||
}
|
||||
|
||||
/** A new window lands signed out or signed in, depending on what can be restored. */
|
||||
async function signInIfAsked(page: Page, email: string): Promise<void> {
|
||||
const login = page.locator('#username');
|
||||
await expect(login.or(accountSwitcher(page))).toBeVisible({ timeout: 60_000 });
|
||||
if (await login.isVisible()) await signIn(page, email);
|
||||
}
|
||||
|
||||
async function launchContext(browser: Browser, opts: Parameters<Browser['newContext']>[0] = {}): Promise<BrowserContext> {
|
||||
const context = await browser.newContext({ ...contextOptions(), ...opts });
|
||||
await context.addInitScript(catchLaunchConsumer);
|
||||
return context;
|
||||
}
|
||||
|
||||
async function signedIn(browser: Browser, opts: Parameters<Browser['newContext']>[0] = {}): Promise<Page> {
|
||||
const page = await (await launchContext(browser, opts)).newPage();
|
||||
await neutralizeDevOverlay(page);
|
||||
await page.goto('/');
|
||||
await signIn(page, ENT.alice);
|
||||
return page;
|
||||
}
|
||||
|
||||
/** A real form POST to the share target, as the OS's share sheet sends it. */
|
||||
async function postShare(page: Page, fields: Record<string, string>, files: LaunchFile[]): Promise<void> {
|
||||
await page.evaluate(({ fields: values, files: list }) => {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.enctype = 'multipart/form-data';
|
||||
form.action = '/protocol/share';
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = name;
|
||||
input.value = value;
|
||||
form.append(input);
|
||||
}
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.name = 'files';
|
||||
input.multiple = true;
|
||||
const data = new DataTransfer();
|
||||
for (const f of list) data.items.add(new File([f.body], f.name, { type: f.type }));
|
||||
input.files = data.files;
|
||||
form.append(input);
|
||||
document.body.append(form);
|
||||
form.submit();
|
||||
}, { fields, files });
|
||||
}
|
||||
|
||||
async function workerReady(page: Page): Promise<void> {
|
||||
const active = await page.evaluate(() => navigator.serviceWorker.ready.then((r) => !!r.active));
|
||||
expect(active).toBe(true);
|
||||
}
|
||||
|
||||
/** The question a share has to pass, wherever it came from. */
|
||||
const startMessage = (page: Page) => page.getByRole('button', { name: 'Start message', exact: true });
|
||||
const dontStart = (page: Page) => page.getByRole('button', { name: "Don't start", exact: true });
|
||||
|
||||
const HOSTILE = 'http://hostile.example';
|
||||
|
||||
/** A page on another origin that POSTs to the share target as soon as it loads. Nobody clicks. */
|
||||
function hostilePage(opts: { subject: string; fileName?: string }): string {
|
||||
return `<!doctype html><title>hostile</title><body><script>
|
||||
const f = document.createElement('form');
|
||||
f.method = 'POST'; f.enctype = 'multipart/form-data'; f.action = ${JSON.stringify(`${WEBMAIL_URL}/protocol/share`)};
|
||||
const title = document.createElement('input'); title.type = 'hidden'; title.name = 'title'; title.value = ${JSON.stringify(opts.subject)}; f.append(title);
|
||||
const text = document.createElement('input'); text.type = 'hidden'; text.name = 'text'; text.value = 'body from another site'; f.append(text);
|
||||
const input = document.createElement('input'); input.type = 'file'; input.name = 'files'; input.multiple = true;
|
||||
${opts.fileName ? `const d = new DataTransfer(); d.items.add(new File(['attacker bytes'], ${JSON.stringify(opts.fileName)})); input.files = d.files;` : ''}
|
||||
f.append(input); document.body.append(f); f.submit();
|
||||
</script>`;
|
||||
}
|
||||
|
||||
async function hostileTab(context: BrowserContext, opts: Parameters<typeof hostilePage>[0]): Promise<Page> {
|
||||
await context.route(`${HOSTILE}/**`, (route) => route.fulfill({ contentType: 'text/html', body: hostilePage(opts) }));
|
||||
const tab = await context.newPage();
|
||||
await tab.goto(`${HOSTILE}/`);
|
||||
return tab;
|
||||
}
|
||||
|
||||
/** Every message on the server with this subject. */
|
||||
async function draftsWithSubject(subject: string): Promise<string[]> {
|
||||
const client = await jmapAs(ENT.alice);
|
||||
const res = await client.request([['Email/query', { accountId: client.accountId, filter: { subject } }, 'q']]);
|
||||
return res.methodResponses[0][1].ids as string[];
|
||||
}
|
||||
|
||||
async function deleteDrafts(ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const client = await jmapAs(ENT.alice);
|
||||
await client.request([['Email/set', { accountId: client.accountId, destroy: ids }, 'd']]);
|
||||
}
|
||||
|
||||
const storedShareEntries = (page: Page) => page.evaluate(async (name) => {
|
||||
if (!(await caches.has(name))) return 0;
|
||||
return (await (await caches.open(name)).keys()).length;
|
||||
}, SHARE_CACHE);
|
||||
|
||||
test.beforeAll(() => requireEntEnv());
|
||||
|
||||
test.describe('manifest', () => {
|
||||
test('offers shortcuts, file handlers and the share target, in the browser language', async ({ browser }) => {
|
||||
const context = await browser.newContext(contextOptions());
|
||||
const res = await context.request.get('/manifest.webmanifest', { headers: { 'Accept-Language': 'de-DE,de;q=0.9' } });
|
||||
expect(res.ok()).toBe(true);
|
||||
const manifest = await res.json();
|
||||
expect(manifest.shortcuts.map((s: { name: string; url: string }) => [s.name, s.url])).toEqual([
|
||||
['Neue Nachricht', '/?compose=1'],
|
||||
['Kalender', '/calendar'],
|
||||
['Kontakte', '/contacts'],
|
||||
]);
|
||||
expect(manifest.file_handlers.map((h: { action: string }) => h.action)).toEqual(['/mail', '/calendar', '/contacts']);
|
||||
expect(manifest.share_target).toMatchObject({ action: '/protocol/share', method: 'POST', enctype: 'multipart/form-data' });
|
||||
expect(manifest.protocol_handlers).toHaveLength(2);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('every address in it answers', async ({ browser }) => {
|
||||
const context = await browser.newContext(contextOptions());
|
||||
const manifest = await (await context.request.get('/manifest.webmanifest')).json();
|
||||
for (const shortcut of manifest.shortcuts) {
|
||||
expect((await context.request.get(shortcut.url)).ok(), shortcut.url).toBe(true);
|
||||
expect((await context.request.get(shortcut.icons[0].src)).ok(), shortcut.icons[0].src).toBe(true);
|
||||
}
|
||||
for (const handler of manifest.file_handlers) {
|
||||
expect((await context.request.get(handler.action)).ok(), handler.action).toBe(true);
|
||||
}
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('share target', () => {
|
||||
test('files and a link go through the service worker into a new draft', async ({ browser }) => {
|
||||
const page = await signedIn(browser);
|
||||
await workerReady(page);
|
||||
|
||||
const subject = `Shared ${RUN}`;
|
||||
await postShare(page, { title: subject, text: 'Have a look', url: 'https://example.org/page' }, [
|
||||
{ name: `shared-${RUN}.txt`, type: 'text/plain', body: 'shared bytes' },
|
||||
]);
|
||||
await page.waitForURL((url) => url.pathname.endsWith('/') || !url.pathname.includes('protocol'));
|
||||
await startMessage(page).click({ timeout: 60_000 });
|
||||
await signInIfAsked(page, ENT.alice);
|
||||
|
||||
await expect(composer(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue(subject);
|
||||
await expect(composer(page).locator('[contenteditable="true"]').first()).toContainText('https://example.org/page');
|
||||
await expect(composer(page).getByText(`shared-${RUN}.txt`)).toBeVisible();
|
||||
// It reached the app through the worker's store, not the server's fallback.
|
||||
await expect(page.getByText("The shared files couldn't be kept")).toHaveCount(0);
|
||||
await expect.poll(() => new URL(page.url()).hash).toBe('');
|
||||
await expect.poll(() => storedShareEntries(page)).toBe(0);
|
||||
await shot(page, 'pwa-launch-share');
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('survives a sign-in that reloads the page', async ({ browser }) => {
|
||||
const page = await (await launchContext(browser)).newPage();
|
||||
await page.goto('/');
|
||||
await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await workerReady(page);
|
||||
|
||||
await postShare(page, { url: 'https://example.org/waited' }, [{ name: `waited-${RUN}.txt`, type: 'text/plain', body: 'x' }]);
|
||||
await startMessage(page).click({ timeout: 60_000 });
|
||||
await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await expect.poll(() => storedShareEntries(page)).toBe(3);
|
||||
// What SSO does: away and back, in the same tab.
|
||||
await page.reload();
|
||||
await signIn(page, ENT.alice);
|
||||
|
||||
await expect(composer(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(composer(page).getByText(`waited-${RUN}.txt`)).toBeVisible();
|
||||
await expect.poll(() => storedShareEntries(page)).toBe(0);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('without a service worker the link still arrives and the app says the files did not', async ({ browser }) => {
|
||||
const page = await signedIn(browser, { serviceWorkers: 'block' });
|
||||
await postShare(page, { url: 'https://example.org/no-worker' }, [{ name: 'lost.txt', type: 'text/plain', body: 'x' }]);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await startMessage(page).click({ timeout: 60_000 });
|
||||
await signInIfAsked(page, ENT.alice);
|
||||
|
||||
await expect(composer(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(composer(page).locator('[contenteditable="true"]').first()).toContainText('https://example.org/no-worker');
|
||||
await expect(composer(page).getByText('lost.txt')).toHaveCount(0);
|
||||
await expect(page.getByText("The shared files couldn't be kept").first()).toBeVisible();
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('the server takes the POST from outside the origin, which /api/ would refuse', async ({ browser }) => {
|
||||
const context = await browser.newContext(contextOptions());
|
||||
const res = await context.request.post('/protocol/share', {
|
||||
headers: { Origin: 'https://other.example', 'Sec-Fetch-Site': 'cross-site' },
|
||||
multipart: { url: 'https://example.org/direct' },
|
||||
maxRedirects: 0,
|
||||
});
|
||||
expect(res.status()).toBe(303);
|
||||
const location = new URL(res.headers().location, 'http://x.invalid');
|
||||
// In the fragment: shared text stays out of access logs.
|
||||
expect(location.search).toBe('');
|
||||
const fragment = new URLSearchParams(location.hash.slice(1));
|
||||
expect(fragment.get('share')).toBe('unavailable');
|
||||
expect(fragment.get('share_url')).toBe('https://example.org/direct');
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('long non-Latin text fits the address the fallback sends on', async ({ browser }) => {
|
||||
const context = await browser.newContext(contextOptions());
|
||||
const res = await context.request.post('/protocol/share', {
|
||||
multipart: { text: 'あ'.repeat(3000) },
|
||||
maxRedirects: 0,
|
||||
});
|
||||
expect(res.status()).toBe(303);
|
||||
const location = res.headers().location;
|
||||
expect(location.length).toBeLessThan(4000);
|
||||
expect((await context.request.get(location)).ok()).toBe(true);
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('a share nobody asked for', () => {
|
||||
test('a POST from another site leaves no draft on the server and asks first', async ({ browser }) => {
|
||||
const signedInPage = await signedIn(browser);
|
||||
await workerReady(signedInPage);
|
||||
const subject = `hostile-${RUN}-a`;
|
||||
const tab = await hostileTab(signedInPage.context(), { subject, fileName: `invoice-${RUN}.exe` });
|
||||
await tab.waitForURL((url) => url.href.startsWith(WEBMAIL_URL));
|
||||
|
||||
// The question comes before anything else, also before a sign-in.
|
||||
await expect(startMessage(tab)).toBeVisible({ timeout: 30_000 }).catch(() => {});
|
||||
if (await startMessage(tab).isVisible()) {
|
||||
await expect(tab.getByTestId('share-confirm-details')).toContainText(`invoice-${RUN}.exe`);
|
||||
} else {
|
||||
await signInIfAsked(tab, ENT.alice);
|
||||
}
|
||||
await tab.waitForTimeout(6000);
|
||||
const drafts = await draftsWithSubject(subject);
|
||||
await deleteDrafts(drafts);
|
||||
expect(drafts).toEqual([]);
|
||||
await expect(composer(tab)).toHaveCount(0);
|
||||
await expect(startMessage(tab)).toBeVisible();
|
||||
await shot(tab, 'pwa-launch-share-confirm');
|
||||
|
||||
// Refused: what it stored goes too.
|
||||
await dontStart(tab).click();
|
||||
await expect.poll(() => storedShareEntries(tab)).toBe(0);
|
||||
await expect(composer(tab)).toHaveCount(0);
|
||||
await signedInPage.context().close();
|
||||
});
|
||||
|
||||
test('agreed, it is only the starting state: no draft until the first real edit', async ({ browser }) => {
|
||||
const signedInPage = await signedIn(browser);
|
||||
await workerReady(signedInPage);
|
||||
const subject = `hostile-${RUN}-b`;
|
||||
const tab = await hostileTab(signedInPage.context(), { subject, fileName: `kept-${RUN}.txt` });
|
||||
await tab.waitForURL((url) => url.href.startsWith(WEBMAIL_URL));
|
||||
await startMessage(tab).click({ timeout: 60_000 });
|
||||
await signInIfAsked(tab, ENT.alice);
|
||||
await expect(composer(tab).getByText(`kept-${RUN}.txt`)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(composer(tab).getByPlaceholder(/subject/i)).toHaveValue(subject);
|
||||
|
||||
await tab.waitForTimeout(6000);
|
||||
expect(await draftsWithSubject(subject)).toEqual([]);
|
||||
|
||||
// Leaving doesn't save it either.
|
||||
await tab.evaluate(() => { window.dispatchEvent(new Event('beforeunload')); });
|
||||
await tab.waitForTimeout(3000);
|
||||
expect(await draftsWithSubject(subject)).toEqual([]);
|
||||
|
||||
// The user's own edit: from here it is a draft like any other.
|
||||
await composer(tab).getByPlaceholder(/subject/i).fill(`${subject} edited`);
|
||||
await expect(composer(tab).getByPlaceholder(/subject/i)).toHaveValue(`${subject} edited`);
|
||||
await expect.poll(() => draftsWithSubject(subject), { timeout: 20_000 }).toHaveLength(1);
|
||||
await deleteDrafts(await draftsWithSubject(subject));
|
||||
await signedInPage.context().close();
|
||||
});
|
||||
|
||||
test('a hidden frame stores nothing', async ({ browser }) => {
|
||||
const page = await (await launchContext(browser)).newPage();
|
||||
await page.goto('/');
|
||||
await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await workerReady(page);
|
||||
// From the app's own origin: another site's frame doesn't reach this worker
|
||||
// at all in Chromium (partitioned storage), so only this shows the check.
|
||||
await page.evaluate(() => {
|
||||
const frame = document.createElement('iframe');
|
||||
frame.name = 'hidden-share';
|
||||
frame.style.display = 'none';
|
||||
document.body.append(frame);
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.enctype = 'multipart/form-data';
|
||||
form.action = '/protocol/share';
|
||||
form.target = 'hidden-share';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'title';
|
||||
input.value = 'frame';
|
||||
form.append(input);
|
||||
document.body.append(form);
|
||||
form.submit();
|
||||
});
|
||||
await page.waitForTimeout(3000);
|
||||
expect(await storedShareEntries(page)).toBe(0);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('text in an address anyone can link to asks first too', async ({ browser }) => {
|
||||
const page = await signedIn(browser);
|
||||
await page.goto(`/#share=unavailable&share_title=${encodeURIComponent(`linked-${RUN}`)}&share_text=hello`);
|
||||
await expect(startMessage(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(composer(page)).toHaveCount(0);
|
||||
await dontStart(page).click();
|
||||
await expect(composer(page)).toHaveCount(0);
|
||||
await page.context().close();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('a handoff while a composer is open', () => {
|
||||
test('one set aside by reading a message is not replaced by the next', async ({ browser }) => {
|
||||
const page = await signedIn(browser);
|
||||
await page.locator('[data-testid="email-list-item"]').first().waitFor({ timeout: 30_000 });
|
||||
const subject = `setaside${RUN}`;
|
||||
await launch(page, { target: '/?compose=1' });
|
||||
await expect(composer(page)).toBeVisible({ timeout: 30_000 });
|
||||
await composer(page).getByPlaceholder(/subject/i).fill(subject);
|
||||
await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue(subject);
|
||||
// Reading a message suspends the composer before its autosave ran.
|
||||
await page.locator('[data-testid="email-list-item"]').first().click();
|
||||
await expect(composer(page)).toHaveCount(0);
|
||||
await page.waitForTimeout(2100);
|
||||
await launch(page, { target: '/?compose=1' });
|
||||
await expect(page.getByText('Close this message to start the next one.').first()).toBeVisible({ timeout: 10_000 });
|
||||
await expect(composer(page)).toHaveCount(0);
|
||||
await expect(page.getByText(subject).first()).toBeVisible();
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('two at once: the second waits until the first composer closes', async ({ browser }) => {
|
||||
const page = await signedIn(browser);
|
||||
// Let the page settle after the sign-in: the composer shouldn't race the first mail load.
|
||||
await page.waitForTimeout(3000);
|
||||
await launch(page, { target: '/?compose=1' });
|
||||
await expect(composer(page)).toBeVisible({ timeout: 30_000 });
|
||||
const subject = `kept-open-${RUN}`;
|
||||
await composer(page).getByPlaceholder(/subject/i).fill(subject);
|
||||
await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue(subject);
|
||||
|
||||
await page.waitForTimeout(2100);
|
||||
await launch(page, { target: '/?compose=1' });
|
||||
await expect(page.getByText('Close this message to start the next one.').first()).toBeVisible({ timeout: 10_000 });
|
||||
await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue(subject);
|
||||
|
||||
await page.locator('[data-testid="composer-close"]').first().click();
|
||||
await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue('', { timeout: 15_000 });
|
||||
await deleteDrafts(await draftsWithSubject(subject));
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('a .vcf launch saves what was just typed before leaving the composer', async ({ browser }) => {
|
||||
const page = await signedIn(browser);
|
||||
await launch(page, { target: '/?compose=1' });
|
||||
await expect(composer(page)).toBeVisible({ timeout: 30_000 });
|
||||
const subject = `typedfirst${RUN}`;
|
||||
await composer(page).getByPlaceholder(/subject/i).fill(subject);
|
||||
// Well inside the autosave's 2 seconds.
|
||||
await launch(page, { target: '/contacts', files: [VCF] });
|
||||
await page.waitForURL(/\/contacts/);
|
||||
await expect(page.getByText(`Launched Person ${RUN}`).first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect.poll(() => draftsWithSubject(subject), { timeout: 10_000 }).toHaveLength(1);
|
||||
await deleteDrafts(await draftsWithSubject(subject));
|
||||
await page.context().close();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('file launches', () => {
|
||||
test('.eml opens in the viewer, over the page', async ({ browser }) => {
|
||||
const page = await signedIn(browser);
|
||||
const before = new URL(page.url()).pathname;
|
||||
await launch(page, { target: '/mail', files: [EML] });
|
||||
await expect(page.getByRole('heading', { name: `Launched message ${RUN}` })).toBeVisible({ timeout: 20_000 });
|
||||
expect(new URL(page.url()).pathname).toBe(before);
|
||||
await expect(page.getByRole('button', { name: 'Download', exact: true }).first()).toBeVisible();
|
||||
await shot(page, 'pwa-launch-eml');
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('.eml opens before signing in', async ({ browser }) => {
|
||||
const page = await (await launchContext(browser)).newPage();
|
||||
await page.goto('/');
|
||||
await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await launch(page, { target: '/mail', files: [EML] });
|
||||
await expect(page.getByRole('heading', { name: `Launched message ${RUN}` })).toBeVisible({ timeout: 20_000 });
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('.eml with downloads blocked: the viewer opens, with nothing to save', async ({ browser }) => {
|
||||
const admin = await adminContext(browser);
|
||||
const saved = await getPolicy(admin);
|
||||
const organisation = (saved.organisation ?? {}) as Record<string, unknown>;
|
||||
await putPolicy(admin, {
|
||||
...saved,
|
||||
organisation: {
|
||||
...organisation,
|
||||
devices: { ...((organisation.devices ?? {}) as object), noDownloads: true },
|
||||
},
|
||||
});
|
||||
try {
|
||||
const page = await signedIn(browser);
|
||||
await launch(page, { target: '/mail', files: [EML] });
|
||||
await expect(page.getByRole('heading', { name: `Launched message ${RUN}` })).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByRole('button', { name: 'Download', exact: true })).toHaveCount(0);
|
||||
await page.context().close();
|
||||
} finally {
|
||||
await putPolicy(admin, saved);
|
||||
await admin.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('.eml without a connection or the viewer chunk: a message, not the error page', async ({ browser }) => {
|
||||
const page = await signedIn(browser);
|
||||
await page.context().setOffline(true);
|
||||
await launch(page, { target: '/mail', files: [EML] });
|
||||
await expect(page.getByText("The file couldn't be opened").first()).toBeVisible({ timeout: 20_000 });
|
||||
await expect(accountSwitcher(page)).toBeVisible();
|
||||
|
||||
await page.context().setOffline(false);
|
||||
await launch(page, { target: '/mail', files: [EML] });
|
||||
await expect(page.getByRole('heading', { name: `Launched message ${RUN}` })).toBeVisible({ timeout: 20_000 });
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('.ics goes to the calendar import, which the server parses', async ({ browser }) => {
|
||||
const page = await signedIn(browser);
|
||||
await launch(page, { target: '/calendar', files: [ICS] });
|
||||
await page.waitForURL(/\/calendar/);
|
||||
await expect(page.getByText(`Launched event ${RUN}`).first()).toBeVisible({ timeout: 30_000 });
|
||||
await shot(page, 'pwa-launch-ics');
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('.ics while offline says the server is needed, and carries on once back', async ({ browser }) => {
|
||||
const page = await signedIn(browser);
|
||||
// Into the calendar first: its code isn't on the device without offline mode.
|
||||
await launch(page, { target: '/calendar' });
|
||||
await page.waitForURL(/\/calendar/);
|
||||
await page.context().setOffline(true);
|
||||
await launch(page, { target: '/calendar', files: [ICS] });
|
||||
await expect(page.getByText('Importing a calendar file needs a connection to the server.')).toBeVisible({ timeout: 20_000 });
|
||||
await shot(page, 'pwa-launch-ics-offline');
|
||||
|
||||
await page.context().setOffline(false);
|
||||
await expect(page.getByText(`Launched event ${RUN}`).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('.vcf goes to the contacts import, parsed in the browser', async ({ browser }) => {
|
||||
const page = await signedIn(browser);
|
||||
await launch(page, { target: '/contacts', files: [VCF] });
|
||||
await page.waitForURL(/\/contacts/);
|
||||
await expect(page.getByText(`Launched Person ${RUN}`).first()).toBeVisible({ timeout: 30_000 });
|
||||
await shot(page, 'pwa-launch-vcf');
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('a file launched before signing in waits for it', async ({ browser }) => {
|
||||
const page = await (await launchContext(browser)).newPage();
|
||||
await page.goto('/');
|
||||
await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await launch(page, { target: '/contacts', files: [VCF] });
|
||||
await expect(page.locator('#username')).toBeVisible();
|
||||
await signIn(page, ENT.alice);
|
||||
await page.waitForURL(/\/contacts/);
|
||||
await expect(page.getByText(`Launched Person ${RUN}`).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('a file launched into a signed-in page with a reload on the way still arrives', async ({ browser }) => {
|
||||
const page = await (await launchContext(browser)).newPage();
|
||||
await page.goto('/');
|
||||
await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await launch(page, { target: '/calendar', files: [ICS] });
|
||||
await expect.poll(() => storedShareEntries(page)).toBe(3);
|
||||
await page.reload();
|
||||
await signIn(page, ENT.alice);
|
||||
await page.waitForURL(/\/calendar/);
|
||||
await expect(page.getByText(`Launched event ${RUN}`).first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect.poll(() => storedShareEntries(page)).toBe(0);
|
||||
await page.context().close();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('shortcuts', () => {
|
||||
test('New message opens a composer from the address, once, and clears the address', async ({ browser }) => {
|
||||
const page = await (await launchContext(browser)).newPage();
|
||||
await page.goto('/?compose=1');
|
||||
await signIn(page, ENT.alice);
|
||||
await expect(composer(page)).toBeVisible({ timeout: 30_000 });
|
||||
expect(new URL(page.url()).search).toBe('');
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('New message in an open window', async ({ browser }) => {
|
||||
const page = await signedIn(browser);
|
||||
await expect(composer(page)).toHaveCount(0);
|
||||
await launch(page, { target: '/?compose=1' });
|
||||
await expect(composer(page)).toBeVisible({ timeout: 30_000 });
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('Calendar and Contacts in an open window', async ({ browser }) => {
|
||||
const page = await signedIn(browser);
|
||||
await launch(page, { target: '/calendar' });
|
||||
await page.waitForURL(/\/calendar/);
|
||||
await launch(page, { target: '/contacts' });
|
||||
await page.waitForURL(/\/contacts/);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('the plain app icon leaves an open window where it is', async ({ browser }) => {
|
||||
const page = await signedIn(browser);
|
||||
await launch(page, { target: '/calendar' });
|
||||
await page.waitForURL(/\/calendar/);
|
||||
await launch(page, { target: '/' });
|
||||
await page.waitForTimeout(1000);
|
||||
expect(new URL(page.url()).pathname).toMatch(/\/calendar(\/|$)/);
|
||||
await expect(composer(page)).toHaveCount(0);
|
||||
await page.context().close();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('in the Pro shell', () => {
|
||||
const proOn = () => {
|
||||
const key = 'settings-storage';
|
||||
const parsed = JSON.parse(localStorage.getItem(key) ?? '{"state":{},"version":0}');
|
||||
parsed.state = { ...parsed.state, proInterface: true };
|
||||
localStorage.setItem(key, JSON.stringify(parsed));
|
||||
};
|
||||
|
||||
test('a shared file reaches the composer tab, and a .ics the calendar tab', async ({ browser }) => {
|
||||
const context = await launchContext(browser);
|
||||
await context.addInitScript(proOn);
|
||||
const page = await context.newPage();
|
||||
await page.goto('/');
|
||||
await signIn(page, ENT.alice);
|
||||
await page.waitForURL(/\/pro/);
|
||||
await workerReady(page);
|
||||
|
||||
await postShare(page, { url: 'https://example.org/pro' }, [{ name: `pro-${RUN}.txt`, type: 'text/plain', body: 'x' }]);
|
||||
await startMessage(page).click({ timeout: 60_000 });
|
||||
await signInIfAsked(page, ENT.alice);
|
||||
await page.waitForURL(/\/pro/);
|
||||
await expect(page.getByText(`pro-${RUN}.txt`).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await launch(page, { target: '/calendar', files: [ICS] });
|
||||
await expect(page.getByText(`Launched event ${RUN}`).first()).toBeVisible({ timeout: 30_000 });
|
||||
await shot(page, 'pwa-launch-pro');
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,526 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FakeCaches, loadWorker } from './sw-harness';
|
||||
import {
|
||||
HANDOFF_CACHE,
|
||||
HANDOFF_MAX_BYTES,
|
||||
HANDOFF_MAX_ENTRIES,
|
||||
HANDOFF_TTL_MS,
|
||||
adoptStoredHandoff,
|
||||
clearHandoffs,
|
||||
discardStoredHandoff,
|
||||
expireHandoffs,
|
||||
handOffDraft,
|
||||
handOffFiles,
|
||||
handOffLaunchFiles,
|
||||
handOffNewMessage,
|
||||
kindForFile,
|
||||
parseLaunchLanding,
|
||||
peekStoredHandoff,
|
||||
pendingHandoffs,
|
||||
restoreStoredHandoffs,
|
||||
shareDraft,
|
||||
shortcutFromTargetUrl,
|
||||
stripLandingParams,
|
||||
subscribeHandoff,
|
||||
subscribeHandoffLost,
|
||||
takeHandoff,
|
||||
} from '../launch-handoff';
|
||||
|
||||
// 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 });
|
||||
// Sealing runs on Web Crypto's thread pool.
|
||||
const settle = () => new Promise((r) => setTimeout(r, 60));
|
||||
const whenStored = () => vi.waitFor(() => expect(sessionStorage.getItem('bulwark:launch-handoffs')).not.toBeNull(), { timeout: 5000 });
|
||||
|
||||
function memoryStorage() {
|
||||
const backing = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => backing.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { backing.set(key, value); },
|
||||
removeItem: (key: string) => { backing.delete(key); },
|
||||
};
|
||||
}
|
||||
|
||||
let caches: FakeCaches;
|
||||
|
||||
beforeEach(() => {
|
||||
caches = new FakeCaches();
|
||||
vi.stubGlobal('caches', caches);
|
||||
vi.stubGlobal('sessionStorage', memoryStorage());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearHandoffs();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('where a launched file goes', () => {
|
||||
it.each([
|
||||
['mail.eml', '', 'eml'],
|
||||
['MAIL.EML', '', 'eml'],
|
||||
['invite.ics', '', 'calendar'],
|
||||
['invite.ical', '', 'calendar'],
|
||||
['people.vcf', '', 'contacts'],
|
||||
['people.vcard', '', 'contacts'],
|
||||
['noextension', 'message/rfc822', 'eml'],
|
||||
['noextension', 'text/calendar', 'calendar'],
|
||||
['noextension', 'text/x-vcard', 'contacts'],
|
||||
['photo.png', 'image/png', null],
|
||||
])('%s (%s) goes to %s', (name, type, kind) => {
|
||||
expect(kindForFile(file(name, type))).toBe(kind);
|
||||
});
|
||||
|
||||
it('splits one launch by type and names what nothing handles', async () => {
|
||||
const eml = file('a.eml');
|
||||
const ics = file('b.ics');
|
||||
const vcf = file('c.vcf');
|
||||
const result = await handOffLaunchFiles([handle(eml), handle(ics), handle(vcf), handle(file('d.png'))]);
|
||||
|
||||
expect(result).toEqual({ unsupported: ['d.png'], failed: 0 });
|
||||
expect(takeHandoff('eml')?.files).toEqual([eml]);
|
||||
expect(takeHandoff('calendar')?.files).toEqual([ics]);
|
||||
expect(takeHandoff('contacts')?.files).toEqual([vcf]);
|
||||
});
|
||||
|
||||
it('counts a file it could not read', async () => {
|
||||
const result = await handOffLaunchFiles([{ kind: 'file', getFile: async () => { throw new Error('gone'); } }]);
|
||||
expect(result).toEqual({ unsupported: [], failed: 1 });
|
||||
expect(pendingHandoffs()).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips a directory handle', async () => {
|
||||
const result = await handOffLaunchFiles([{ kind: 'directory' }]);
|
||||
expect(result).toEqual({ unsupported: [], failed: 0 });
|
||||
expect(pendingHandoffs()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handing off', () => {
|
||||
it('gives a handoff to one taker, once', () => {
|
||||
const ics = file('a.ics');
|
||||
handOffFiles([ics], 'calendar');
|
||||
expect(takeHandoff('contacts')).toBeNull();
|
||||
expect(takeHandoff('calendar')?.files).toEqual([ics]);
|
||||
expect(takeHandoff('calendar')).toBeNull();
|
||||
});
|
||||
|
||||
it('tells the app first, while the handoff is still pending, then the taker', () => {
|
||||
const order: string[] = [];
|
||||
const offKind = subscribeHandoff('contacts', () => {
|
||||
order.push(`taker:${takeHandoff('contacts') ? 'got it' : 'nothing'}`);
|
||||
});
|
||||
const offAny = subscribeHandoff('any', (kind) => {
|
||||
order.push(`app:${kind}:${pendingHandoffs().length}`);
|
||||
});
|
||||
handOffFiles([file('a.vcf')], 'contacts');
|
||||
expect(order).toEqual(['app:contacts:1', 'taker:got it']);
|
||||
offKind();
|
||||
offAny();
|
||||
});
|
||||
|
||||
it('stops telling a listener that unsubscribed', () => {
|
||||
const listener = vi.fn();
|
||||
subscribeHandoff('calendar', listener)();
|
||||
handOffFiles([file('a.ics')], 'calendar');
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing for no files', () => {
|
||||
handOffFiles([], 'calendar');
|
||||
expect(pendingHandoffs()).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops what waited too long', () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
handOffFiles([file('a.ics')], 'calendar');
|
||||
vi.advanceTimersByTime(HANDOFF_TTL_MS + 1);
|
||||
expect(takeHandoff('calendar')).toBeNull();
|
||||
expect(pendingHandoffs()).toEqual([]);
|
||||
});
|
||||
|
||||
it('counts a new message that arrives twice in one launch once', () => {
|
||||
handOffNewMessage();
|
||||
handOffNewMessage();
|
||||
expect(takeHandoff('compose')).toMatchObject({ files: [] });
|
||||
expect(takeHandoff('compose')).toBeNull();
|
||||
});
|
||||
|
||||
it('carries the text of a draft', () => {
|
||||
handOffDraft({ subject: 'Hi', body: 'There', files: [file('a.pdf')] });
|
||||
const handoff = takeHandoff('compose');
|
||||
expect(handoff).toMatchObject({ subject: 'Hi', body: 'There' });
|
||||
expect(handoff?.files.map((f) => f.name)).toEqual(['a.pdf']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('across a page load', () => {
|
||||
// A sign-in through SSO leaves the page. A new module state stands in for
|
||||
// the new page: the queue is emptied, sessionStorage and the cache stay.
|
||||
async function newPage() {
|
||||
pendingHandoffs();
|
||||
while (takeHandoffQuietly()) { /* the old page's memory goes */ }
|
||||
}
|
||||
function takeHandoffQuietly() {
|
||||
const list = pendingHandoffs() as unknown as unknown[];
|
||||
return list.length > 0 ? list.splice(0, list.length) : null;
|
||||
}
|
||||
|
||||
it('brings back a file that waited for a sign-in, with its name and type', async () => {
|
||||
handOffFiles([file('a.ics', 'text/calendar', 'BEGIN:VCALENDAR')], 'calendar');
|
||||
await whenStored();
|
||||
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 whenStored();
|
||||
const store = await caches.open(HANDOFF_CACHE);
|
||||
expect((await store.keys()).length).toBe(3);
|
||||
|
||||
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 whenStored();
|
||||
await newPage();
|
||||
// Another window: its own sessionStorage, the same cache.
|
||||
vi.stubGlobal('sessionStorage', memoryStorage());
|
||||
await restoreStoredHandoffs();
|
||||
expect(takeHandoff('calendar')).toBeNull();
|
||||
expect((await (await caches.open(HANDOFF_CACHE)).keys()).length).toBe(3);
|
||||
});
|
||||
|
||||
it('keeps no readable bytes, name or text at rest', async () => {
|
||||
handOffDraft({ subject: 'Secret subject', body: 'Secret text', files: [file('secret-name.txt', 'text/plain', 'secret bytes')] });
|
||||
await whenStored();
|
||||
const store = await caches.open(HANDOFF_CACHE);
|
||||
const keys = await store.keys();
|
||||
expect(keys.length).toBeGreaterThanOrEqual(3);
|
||||
for (const key of keys) {
|
||||
const stored = await (await store.match(key))!.text();
|
||||
for (const secret of ['Secret subject', 'Secret text', 'secret-name', 'secret bytes']) expect(stored).not.toContain(secret);
|
||||
}
|
||||
});
|
||||
|
||||
it('cannot read it once the tab with the key is gone', async () => {
|
||||
handOffFiles([file('a.ics')], 'calendar');
|
||||
await whenStored();
|
||||
await newPage();
|
||||
vi.stubGlobal('sessionStorage', memoryStorage());
|
||||
const store = await caches.open(HANDOFF_CACHE);
|
||||
const metaKey = (await store.keys()).find((key) => key.url.endsWith('/meta'))!.url;
|
||||
const id = metaKey.split('/').slice(-2)[0];
|
||||
expect(await adoptStoredHandoff(id)).toBe(false);
|
||||
expect(await adoptStoredHandoff(id, 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')).toBe(false);
|
||||
});
|
||||
|
||||
it('says so when a stored handoff of this tab is gone after the reload', async () => {
|
||||
const losses: string[] = [];
|
||||
const off = subscribeHandoffLost((loss) => losses.push(loss));
|
||||
handOffFiles([file('a.ics')], 'calendar');
|
||||
await whenStored();
|
||||
await newPage();
|
||||
await caches.delete(HANDOFF_CACHE);
|
||||
await restoreStoredHandoffs();
|
||||
expect(losses).toEqual(['expired']);
|
||||
expect(sessionStorage.getItem('bulwark:launch-handoffs')).toBeNull();
|
||||
off();
|
||||
});
|
||||
|
||||
it('says so when a stored file is missing, and delivers the rest', async () => {
|
||||
const losses: string[] = [];
|
||||
const off = subscribeHandoffLost((loss) => losses.push(loss));
|
||||
handOffDraft({ subject: 'S', files: [file('one.txt'), file('two.txt')] });
|
||||
await whenStored();
|
||||
await newPage();
|
||||
const store = await caches.open(HANDOFF_CACHE);
|
||||
const first = (await store.keys()).find((key) => key.url.endsWith('/0'))!;
|
||||
await store.delete(first);
|
||||
await restoreStoredHandoffs();
|
||||
expect(losses).toEqual(['missing']);
|
||||
expect(takeHandoff('compose')?.files.map((f) => f.name)).toEqual(['two.txt']);
|
||||
off();
|
||||
});
|
||||
|
||||
it('keeps no more than the limit, oldest out first', async () => {
|
||||
const created: string[] = [];
|
||||
for (let n = 0; n < HANDOFF_MAX_ENTRIES + 2; n += 1) {
|
||||
const name = `f${n}.ics`;
|
||||
handOffFiles([file(name)], 'calendar');
|
||||
created.push(name);
|
||||
await settle();
|
||||
await settle();
|
||||
}
|
||||
const store = await caches.open(HANDOFF_CACHE);
|
||||
const metas = (await store.keys()).filter((key) => key.url.endsWith('/meta'));
|
||||
expect(metas.length).toBeLessThanOrEqual(HANDOFF_MAX_ENTRIES);
|
||||
});
|
||||
|
||||
it('does not store what is over the byte limit, and keeps it in memory', async () => {
|
||||
const big = file('big.ics');
|
||||
Object.defineProperty(big, 'size', { value: HANDOFF_MAX_BYTES + 1 });
|
||||
handOffFiles([big], 'calendar');
|
||||
await settle();
|
||||
expect(await caches.has(HANDOFF_CACHE)).toBe(false);
|
||||
expect(takeHandoff('calendar')?.files).toEqual([big]);
|
||||
});
|
||||
|
||||
it('sweeps what nobody took, and keeps what is recent', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
handOffFiles([file('old.ics')], 'calendar');
|
||||
await whenStored();
|
||||
await newPage();
|
||||
vi.stubGlobal('sessionStorage', memoryStorage());
|
||||
vi.advanceTimersByTime(HANDOFF_TTL_MS + 1);
|
||||
handOffFiles([file('new.vcf')], 'contacts');
|
||||
await whenStored();
|
||||
|
||||
await restoreStoredHandoffs();
|
||||
const store = await caches.open(HANDOFF_CACHE);
|
||||
const urls = (await store.keys()).map((key) => key.url);
|
||||
expect(urls).toHaveLength(3);
|
||||
const meta = await store.match(urls.find((url) => url.endsWith('/meta'))!);
|
||||
expect(((await meta!.json()) as { kind: string }).kind).toBe('contacts');
|
||||
});
|
||||
|
||||
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('while the page stays open', () => {
|
||||
it('drops what waited too long on a timer, and says so', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] });
|
||||
const losses: string[] = [];
|
||||
const off = subscribeHandoffLost((loss) => losses.push(loss));
|
||||
handOffFiles([file('a.eml')], 'eml');
|
||||
await vi.advanceTimersByTimeAsync(HANDOFF_TTL_MS + 2000);
|
||||
expect(pendingHandoffs()).toEqual([]);
|
||||
expect(losses).toEqual(['expired']);
|
||||
off();
|
||||
});
|
||||
|
||||
it('says so when a taker finds only an expired handoff', () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
const losses: string[] = [];
|
||||
const off = subscribeHandoffLost((loss) => losses.push(loss));
|
||||
handOffFiles([file('a.eml')], 'eml');
|
||||
vi.advanceTimersByTime(HANDOFF_TTL_MS + 1);
|
||||
expect(takeHandoff('eml')).toBeNull();
|
||||
expect(losses).toEqual(['expired']);
|
||||
off();
|
||||
});
|
||||
|
||||
it('keeps what is still in time', async () => {
|
||||
handOffFiles([file('a.eml')], 'eml');
|
||||
await expireHandoffs();
|
||||
expect(pendingHandoffs()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('leaves the second of two drafts queued until it is taken', () => {
|
||||
handOffDraft({ subject: 'first' });
|
||||
handOffDraft({ subject: 'second' });
|
||||
expect(takeHandoff('compose')?.subject).toBe('first');
|
||||
expect(pendingHandoffs().map((h) => h.subject)).toEqual(['second']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sign-out', () => {
|
||||
it('forgets what is waiting, in memory and stored', async () => {
|
||||
handOffFiles([file('a.ics')], 'calendar');
|
||||
await whenStored();
|
||||
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', destination: 'document', formData: async () => form } as never);
|
||||
const location = new URL(answer!.headers.get('location')!);
|
||||
const landing = parseLaunchLanding(location.search, location.hash);
|
||||
expect(landing?.type).toBe('stored');
|
||||
const { id, key } = landing as { id: string; key: string };
|
||||
|
||||
// Without the key from the address it can't be read; asking shows what it is.
|
||||
expect(await peekStoredHandoff(id, '')).toBeNull();
|
||||
expect(await peekStoredHandoff(id, key)).toEqual({ subject: 'A link', body: 'Look at this\n\nhttps://example.org/page', files: ['photo.png'] });
|
||||
expect(pendingHandoffs()).toEqual([]);
|
||||
|
||||
expect(await adoptStoredHandoff(id, key)).toBe(true);
|
||||
const handoff = takeHandoff('compose');
|
||||
expect(handoff).toMatchObject({ subject: 'A link', body: 'Look at this\n\nhttps://example.org/page' });
|
||||
expect(handoff?.files[0].name).toBe('photo.png');
|
||||
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', 'AAAA')).toBe(false);
|
||||
expect(await peekStoredHandoff('11111111-2222', 'AAAA')).toBeNull();
|
||||
});
|
||||
|
||||
it('goes when the user refuses it', async () => {
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
const form = new FormData();
|
||||
form.append('files', file('photo.png'));
|
||||
const answer = await worker.request({ url: 'https://mail.example.com/protocol/share', method: 'POST', mode: 'navigate', destination: 'document', formData: async () => form } as never);
|
||||
const location = new URL(answer!.headers.get('location')!);
|
||||
const { id, key } = parseLaunchLanding(location.search, location.hash) as { id: string; key: string };
|
||||
await discardStoredHandoff(id);
|
||||
expect((await (await caches.open(HANDOFF_CACHE)).keys()).length).toBe(0);
|
||||
expect(await peekStoredHandoff(id, key)).toBeNull();
|
||||
});
|
||||
|
||||
it('cleans control characters out of names and text, as for a mailto: link', async () => {
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
const form = new FormData();
|
||||
form.set('title', 'Hi\r\nBcc: evil@example.org');
|
||||
form.append('files', file('a\u202Egnp.exe'));
|
||||
const answer = await worker.request({ url: 'https://mail.example.com/protocol/share', method: 'POST', mode: 'navigate', destination: 'document', formData: async () => form } as never);
|
||||
const location = new URL(answer!.headers.get('location')!);
|
||||
const { id, key } = parseLaunchLanding(location.search, location.hash) as { id: string; key: string };
|
||||
expect(await peekStoredHandoff(id, key)).toMatchObject({ subject: 'HiBcc: evil@example.org', files: ['agnp.exe'] });
|
||||
});
|
||||
|
||||
it('is gone when it is too old', async () => {
|
||||
const store = await caches.open(HANDOFF_CACHE);
|
||||
await store.put('/__bulwark/handoff/old/meta', new Response(JSON.stringify({ kind: 'compose', createdAt: Date.now() - HANDOFF_TTL_MS - 1, bytes: 0 })));
|
||||
expect(await adoptStoredHandoff('old', 'AAAA')).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' });
|
||||
});
|
||||
|
||||
it('strips control characters from the subject, so no header can be planted', () => {
|
||||
expect(shareDraft({ title: 'Hi\r\nBcc: evil@example.org' }).subject).toBe('HiBcc: evil@example.org');
|
||||
});
|
||||
|
||||
it('keeps line breaks in the body and drops other control characters', () => {
|
||||
expect(shareDraft({ text: 'one\r\ntwo\u0000\u0007' }).body).toBe('one\ntwo');
|
||||
});
|
||||
|
||||
it('applies the limits of a mailto: link', () => {
|
||||
const draft = shareDraft({ title: 'x'.repeat(2000), text: 'y'.repeat(100_000), url: 'https://a.example' });
|
||||
expect(draft.subject).toHaveLength(998);
|
||||
expect(draft.body).toHaveLength(64 * 1024);
|
||||
});
|
||||
|
||||
it('applies them to a draft handed in directly as well', () => {
|
||||
handOffDraft({ subject: 'a\r\nb', body: 'y'.repeat(100_000) });
|
||||
const handoff = takeHandoff('compose');
|
||||
expect(handoff?.subject).toBe('ab');
|
||||
expect(handoff?.body).toHaveLength(64 * 1024);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the address a launch lands on', () => {
|
||||
it.each([
|
||||
['?compose=1', '', { type: 'new-message' }],
|
||||
['', '#share=abc-123&key=k1', { type: 'stored', id: 'abc-123', key: 'k1' }],
|
||||
['', '#share=unavailable&share_title=T&share_text=B&share_files=1', { type: 'inline', subject: 'T', body: 'B', filesLost: true, cut: false }],
|
||||
['', '#share=unavailable&share_url=https%3A%2F%2Fa.example&share_cut=1', { type: 'inline', subject: '', body: 'https://a.example', filesLost: false, cut: true }],
|
||||
['', '#share=..%2F..%2Fetc', null],
|
||||
// Shared text stays out of the query, which reaches logs.
|
||||
['?share=unavailable&share_text=x', '', null],
|
||||
['?compose=0', '', null],
|
||||
['?openLatestUnread=1', '#x', null],
|
||||
['', '', null],
|
||||
])('%s %s', (search, hash, expected) => {
|
||||
expect(parseLaunchLanding(search, hash)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('cleans text that came in the address', () => {
|
||||
const landing = parseLaunchLanding('', '#share=unavailable&share_title=Hi%0D%0ABcc%3A%20evil');
|
||||
expect(landing).toMatchObject({ subject: 'HiBcc: evil' });
|
||||
});
|
||||
|
||||
it('leaves only what is not about the launch', () => {
|
||||
expect(stripLandingParams('?compose=1', '')).toEqual({ search: '', hash: '' });
|
||||
expect(stripLandingParams('?slot=2', '#share=unavailable&share_text=x')).toEqual({ search: '?slot=2', hash: '' });
|
||||
expect(stripLandingParams('?compose=1&slot=2', '#section')).toEqual({ search: '?slot=2', hash: '#section' });
|
||||
expect(stripLandingParams('', '#share=a&key=b')).toEqual({ search: '', hash: '' });
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
// @vitest-environment node
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Shortcuts, file handlers and the share target in the manifest, with what
|
||||
// the route already did (per-domain branding, the deployment subpath).
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
requestHeaders: {} as Record<string, string>,
|
||||
config: {} as Record<string, unknown>,
|
||||
sources: {} as Record<string, { source: string }>,
|
||||
}));
|
||||
|
||||
vi.mock('next/headers', () => ({
|
||||
headers: async () => new Headers(hoisted.requestHeaders),
|
||||
}));
|
||||
|
||||
// The real strings, so a missing or misnamed key fails here.
|
||||
vi.mock('next-intl/server', () => ({
|
||||
getTranslations: async ({ locale, namespace }: { locale: string; namespace: string }) => {
|
||||
const messages = JSON.parse(readFileSync(join(process.cwd(), 'locales', locale, 'common.json'), 'utf8'));
|
||||
return (key: string) => {
|
||||
const value = messages[namespace]?.[key];
|
||||
if (typeof value !== 'string') throw new Error(`missing ${namespace}.${key} in ${locale}`);
|
||||
return value;
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: {
|
||||
ensureLoaded: async () => {},
|
||||
get: (key: string, fallback: unknown) => hoisted.config[key] ?? fallback,
|
||||
getAllWithSources: () => hoisted.sources,
|
||||
},
|
||||
}));
|
||||
|
||||
type Manifest = {
|
||||
name: string;
|
||||
shortcuts: Array<{ name: string; url: string; icons: Array<{ src: string }> }>;
|
||||
file_handlers: Array<{ action: string; accept: Record<string, string[]> }>;
|
||||
share_target: { action: string; method: string; enctype: string; params: Record<string, unknown> };
|
||||
protocol_handlers: Array<{ protocol: string; url: string }>;
|
||||
};
|
||||
|
||||
async function load(): Promise<Manifest> {
|
||||
vi.resetModules();
|
||||
const { default: manifest } = await import('@/app/manifest');
|
||||
return (await manifest()) as unknown as Manifest;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
hoisted.requestHeaders = {};
|
||||
hoisted.config = {};
|
||||
hoisted.sources = {
|
||||
pwaIconUrl: { source: 'default' },
|
||||
faviconUrl: { source: 'default' },
|
||||
pwaScreenshotMobileUrl: { source: 'default' },
|
||||
pwaScreenshotDesktopUrl: { source: 'default' },
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('manifest launch fields', () => {
|
||||
it('offers the three shortcuts', async () => {
|
||||
const m = await load();
|
||||
expect(m.shortcuts.map((s) => [s.name, s.url])).toEqual([
|
||||
['New message', '/?compose=1'],
|
||||
['Calendar', '/calendar'],
|
||||
['Contacts', '/contacts'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('names the shortcuts in the language the browser asks in', async () => {
|
||||
hoisted.requestHeaders = { 'accept-language': 'de-DE,de;q=0.9,en;q=0.8' };
|
||||
expect((await load()).shortcuts.map((s) => s.name)).toEqual(['Neue Nachricht', 'Kalender', 'Kontakte']);
|
||||
});
|
||||
|
||||
it('falls back to English for a language it does not have', async () => {
|
||||
hoisted.requestHeaders = { 'accept-language': 'xx' };
|
||||
expect((await load()).shortcuts[0].name).toBe('New message');
|
||||
});
|
||||
|
||||
it('handles .eml, .ics and .vcf', async () => {
|
||||
const m = await load();
|
||||
const extensions = (action: string) => Object.values(m.file_handlers.find((h) => h.action === action)?.accept ?? {}).flat();
|
||||
expect(extensions('/mail')).toEqual(['.eml']);
|
||||
expect(extensions('/calendar')).toEqual(expect.arrayContaining(['.ics']));
|
||||
expect(extensions('/contacts')).toEqual(expect.arrayContaining(['.vcf']));
|
||||
expect(m.file_handlers).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('takes shares as multipart POST outside /api/, with files and links', async () => {
|
||||
const { share_target: share } = await load();
|
||||
expect(share.action).toBe('/protocol/share');
|
||||
expect(share.action.startsWith('/api/')).toBe(false);
|
||||
expect(share.method).toBe('POST');
|
||||
expect(share.enctype).toBe('multipart/form-data');
|
||||
expect(share.params).toEqual({ title: 'title', text: 'text', url: 'url', files: [{ name: 'files', accept: ['*/*'] }] });
|
||||
});
|
||||
|
||||
it('prefixes every new address with the subpath', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BASE_PATH', '/webmail');
|
||||
const m = await load();
|
||||
expect(m.shortcuts.map((s) => s.url)).toEqual(['/webmail/?compose=1', '/webmail/calendar', '/webmail/contacts']);
|
||||
expect(m.shortcuts[0].icons[0].src).toBe('/webmail/icon-192x192.png');
|
||||
expect(m.file_handlers.map((h) => h.action)).toEqual(['/webmail/mail', '/webmail/calendar', '/webmail/contacts']);
|
||||
expect(m.share_target.action).toBe('/webmail/protocol/share');
|
||||
});
|
||||
|
||||
it("keeps a domain's branding, its icon on the shortcuts too", async () => {
|
||||
hoisted.requestHeaders = { host: 'mail.acme.example' };
|
||||
hoisted.config = { domainBranding: [{ host: 'mail.acme.example', appName: 'Acme Mail', pwaIconUrl: 'https://cdn.example/i.png' }] };
|
||||
const m = await load();
|
||||
expect(m.name).toBe('Acme Mail');
|
||||
expect(m.shortcuts[0].icons[0].src).toBe('/api/pwa-icon/192');
|
||||
expect(m.file_handlers).toHaveLength(3);
|
||||
expect(m.share_target.action).toBe('/protocol/share');
|
||||
});
|
||||
|
||||
it('leaves the protocol handlers as they are', async () => {
|
||||
expect((await load()).protocol_handlers.map((h) => h.url)).toEqual(['/protocol/mailto?url=%s', '/protocol/webcal?url=%s']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lite', () => {
|
||||
it('has no share target or file handlers: no service worker, no launch consumer behind them', async () => {
|
||||
const { buildManifest } = await import('@/scripts/lite/lib.mjs');
|
||||
const lite = buildManifest({ appName: 'Acme' });
|
||||
expect(lite).not.toHaveProperty('share_target');
|
||||
expect(lite).not.toHaveProperty('file_handlers');
|
||||
expect(lite).not.toHaveProperty('shortcuts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('share route without a service worker', () => {
|
||||
const route = () => import('@/app/(main)/protocol/share/route');
|
||||
|
||||
function post(form: FormData | null, contentLength?: string) {
|
||||
const request = new Request('https://mail.example.com/protocol/share', { method: 'POST', ...(form ? { body: form } : {}) });
|
||||
if (contentLength !== undefined) {
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set('content-length', contentLength);
|
||||
return { headers, formData: () => request.formData() } as unknown as Request;
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
it('sends text and links on to a new draft, and goes to the app with a 303', async () => {
|
||||
const { POST } = await route();
|
||||
const form = new FormData();
|
||||
form.set('title', 'T');
|
||||
form.set('text', 'B');
|
||||
form.set('url', 'https://example.org/');
|
||||
const res = await POST(post(form, '500'));
|
||||
expect(res.status).toBe(303);
|
||||
const landing = new URL(res.headers.get('location')!, 'https://x.example');
|
||||
expect(landing.pathname).toBe('/');
|
||||
// In the fragment: it never reaches an access log.
|
||||
expect(landing.search).toBe('');
|
||||
expect(Object.fromEntries(new URLSearchParams(landing.hash.slice(1)))).toEqual({ share: 'unavailable', share_title: 'T', share_text: 'B', share_url: 'https://example.org/' });
|
||||
});
|
||||
|
||||
it('says files were lost', async () => {
|
||||
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(res.headers.get('location')).toContain('share_files=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(res.headers.get('location')).toContain('share_files=1');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Latin', 'x'],
|
||||
['Japanese, 9 encoded bytes each', 'あ'],
|
||||
['emoji, whole characters only', '😀'],
|
||||
])('keeps the address short and says it cut: %s', async (_name, character) => {
|
||||
const { POST } = await route();
|
||||
const form = new FormData();
|
||||
form.set('text', character.repeat(3000));
|
||||
form.set('title', character.repeat(3000));
|
||||
form.set('url', character.repeat(3000));
|
||||
const res = await POST(post(form, '100000'));
|
||||
const location = res.headers.get('location')!;
|
||||
expect(location.length).toBeLessThan(4000);
|
||||
const fragment = new URLSearchParams(new URL(location, 'https://x.example').hash.slice(1));
|
||||
expect(fragment.get('share_cut')).toBe('1');
|
||||
// Decodes cleanly: no half a character at the end.
|
||||
expect(fragment.get('share_text')!.endsWith(character)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not say it cut what fits', async () => {
|
||||
const { POST } = await route();
|
||||
const form = new FormData();
|
||||
form.set('text', 'あ'.repeat(100));
|
||||
const res = await POST(post(form, '1000'));
|
||||
expect(res.headers.get('location')).not.toContain('share_cut');
|
||||
});
|
||||
|
||||
it('honours the subpath', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BASE_PATH', '/webmail');
|
||||
vi.resetModules();
|
||||
const { GET } = await route();
|
||||
expect(GET().headers.get('location')).toBe('/webmail/');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -45,12 +45,13 @@ export function loadWorker(opts: {
|
||||
/** What showNotification does, after it was recorded in `shown`. */
|
||||
showNotification?: (title: string, options: Record<string, unknown>) => Promise<void>;
|
||||
notifications?: Array<Record<string, unknown>>;
|
||||
location?: string;
|
||||
}) {
|
||||
const listeners = new Map<string, (event: unknown) => void>();
|
||||
const shown: ShownNotification[] = [];
|
||||
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, openWindow: opts.openWindow },
|
||||
@@ -62,7 +63,7 @@ export function loadWorker(opts: {
|
||||
getNotifications: async () => opts.notifications ?? [],
|
||||
},
|
||||
};
|
||||
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, ...opts.globals });
|
||||
const context = vm.createContext({ self, caches: opts.caches, fetch: opts.fetch ?? (async () => { throw new TypeError('offline'); }), URL, URLSearchParams, Request, Response, Headers, Date, console, AbortController, setTimeout, clearTimeout, crypto: globalThis.crypto, TextEncoder, btoa, ...opts.globals });
|
||||
vm.runInContext(SW_SOURCE, context);
|
||||
async function dispatch(type: string, event: Record<string, unknown> = {}) {
|
||||
const waits: Promise<unknown>[] = [];
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
// @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. Any site can POST here, signed in or not, so what the
|
||||
// worker keeps is limited and sealed. Before: a POST went to the server when
|
||||
// offline mode was off, and files sat readable in the cache with no limit.
|
||||
|
||||
const ORIGIN = 'https://mail.example.com';
|
||||
const CACHE = 'bulwark-launch-handoff-v1';
|
||||
const TTL = 15 * 60 * 1000;
|
||||
const settle = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
function shareRequest(form: FormData, overrides: Record<string, unknown> = {}) {
|
||||
return { url: `${ORIGIN}/protocol/share`, method: 'POST', mode: 'navigate', destination: 'document', 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(['secret bytes'], 'photo.png', { type: 'image/png' }));
|
||||
return form;
|
||||
}
|
||||
|
||||
const landingOf = (answer: Response | null) => {
|
||||
const url = new URL(answer!.headers.get('location')!);
|
||||
return { url, fragment: new URLSearchParams(url.hash.slice(1)) };
|
||||
};
|
||||
|
||||
async function shellOn(caches: FakeCaches, on: boolean) {
|
||||
const cache = await caches.open('bulwark-sw-state-v1');
|
||||
await cache.put('/__bulwark/offline-shell', new Response(JSON.stringify({ on })));
|
||||
}
|
||||
|
||||
async function plantEntry(caches: FakeCaches, id: string, createdAt: number, bytes = 0) {
|
||||
const store = await caches.open(CACHE);
|
||||
await store.put(`/__bulwark/handoff/${id}/meta`, new Response(JSON.stringify({ kind: 'compose', createdAt, bytes })));
|
||||
await store.put(`/__bulwark/handoff/${id}/sealed`, new Response('x'));
|
||||
}
|
||||
|
||||
async function entryIds(caches: FakeCaches): Promise<string[]> {
|
||||
const store = await caches.open(CACHE);
|
||||
return (await store.keys()).filter((key) => key.url.endsWith('/meta')).map((key) => key.url.split('/').slice(-2)[0]);
|
||||
}
|
||||
|
||||
function htmlPage(body: string) {
|
||||
const res = new Response(body, { status: 200, headers: { 'Content-Type': 'text/html' } });
|
||||
Object.defineProperty(res, 'type', { value: 'basic' });
|
||||
return res;
|
||||
}
|
||||
|
||||
describe('share target', () => {
|
||||
it.each([[false], [true]])('takes the POST with offline mode on=%s, keeps the files sealed and goes to the app', async (on) => {
|
||||
const caches = new FakeCaches();
|
||||
await shellOn(caches, on);
|
||||
const fetchMock = vi.fn();
|
||||
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 { url, fragment } = landingOf(answer);
|
||||
expect(`${url.origin}${url.pathname}`).toBe(`${ORIGIN}/`);
|
||||
// Id and key in the fragment: neither ever reaches the server.
|
||||
expect(url.search).toBe('');
|
||||
const id = fragment.get('share')!;
|
||||
expect(id).toMatch(/^[\w-]+$/);
|
||||
expect(fragment.get('key')).toMatch(/^[\w-]{43}$/);
|
||||
|
||||
const store = await caches.open(CACHE);
|
||||
const meta = (await (await store.match(`/__bulwark/handoff/${id}/meta`))!.json()) as Record<string, unknown>;
|
||||
expect(meta).toMatchObject({ kind: 'compose', bytes: 12 });
|
||||
// Only what a sweep needs is readable.
|
||||
expect(Object.keys(meta).sort()).toEqual(['bytes', 'createdAt', 'kind']);
|
||||
for (const part of ['meta', 'sealed', '0']) {
|
||||
const text = await (await store.match(`/__bulwark/handoff/${id}/${part}`))!.text();
|
||||
for (const secret of ['secret bytes', 'photo.png', 'A link', 'example.org']) expect(text).not.toContain(secret);
|
||||
}
|
||||
});
|
||||
|
||||
it('answers while the worker has only just started and has not read its state', async () => {
|
||||
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 = landingOf(answer).fragment.get('share')!;
|
||||
const store = await caches.open(CACHE);
|
||||
const meta = (await (await store.match(`/__bulwark/handoff/${id}/meta`))!.json()) as { bytes: number };
|
||||
expect(meta.bytes).toBe(0);
|
||||
expect(await store.match(`/__bulwark/handoff/${id}/0`)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the text and says the files are lost when there is no room', async () => {
|
||||
const caches = new FakeCaches();
|
||||
const original = caches.open.bind(caches);
|
||||
const store = await original(CACHE);
|
||||
vi.spyOn(caches, 'open').mockImplementation(async (name: string) => (name === CACHE
|
||||
? { ...store, put: async () => { throw new Error('QuotaExceededError'); } }
|
||||
: original(name)));
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
|
||||
const { fragment } = landingOf(await worker.request(shareRequest(sharedForm())));
|
||||
|
||||
expect(fragment.get('share')).toBe('unavailable');
|
||||
expect(fragment.get('share_files')).toBe('1');
|
||||
expect(fragment.get('share_title')).toBe('A link');
|
||||
expect(fragment.get('share_url')).toBe('https://example.org/');
|
||||
expect((await store.keys()).length).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps that address short too, and says it cut', async () => {
|
||||
const caches = new FakeCaches();
|
||||
const original = caches.open.bind(caches);
|
||||
const store = await original(CACHE);
|
||||
vi.spyOn(caches, 'open').mockImplementation(async (name: string) => (name === CACHE
|
||||
? { ...store, put: async () => { throw new Error('QuotaExceededError'); } }
|
||||
: original(name)));
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
const form = new FormData();
|
||||
form.set('text', 'あ'.repeat(3000));
|
||||
|
||||
const answer = await worker.request(shareRequest(form));
|
||||
|
||||
expect(answer!.headers.get('location')!.length).toBeLessThan(4000);
|
||||
expect(landingOf(answer).fragment.get('share_cut')).toBe('1');
|
||||
});
|
||||
|
||||
it('says so when the form cannot be read', async () => {
|
||||
const worker = loadWorker({ buildId: 'a', caches: new FakeCaches(), clients: [] });
|
||||
const answer = await worker.request(shareRequest(new FormData(), { formData: async () => { throw new TypeError('bad body'); } }));
|
||||
const { fragment } = landingOf(answer);
|
||||
expect(fragment.get('share')).toBe('unavailable');
|
||||
expect(fragment.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', destination: 'document' } as never)).toBeNull();
|
||||
expect(await caches.has(CACHE)).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves a POST into a hidden frame to the network: it stores nothing', async () => {
|
||||
const caches = new FakeCaches();
|
||||
await shellOn(caches, false);
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
await settle();
|
||||
expect(await worker.request(shareRequest(sharedForm(), { destination: 'iframe' }))).toBeNull();
|
||||
expect(await worker.request(shareRequest(sharedForm(), { destination: 'embed' }))).toBeNull();
|
||||
expect(await caches.has(CACHE)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('limits on what the worker keeps', () => {
|
||||
it('drops the oldest entries over the entry limit, on every share', async () => {
|
||||
const caches = new FakeCaches();
|
||||
const now = Date.now();
|
||||
for (let n = 0; n < 5; n += 1) await plantEntry(caches, `old-${n}`, now - 1000 * (5 - n));
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
|
||||
const answer = await worker.request(shareRequest(sharedForm()));
|
||||
|
||||
const ids = await entryIds(caches);
|
||||
expect(ids).toHaveLength(5);
|
||||
expect(ids).not.toContain('old-0');
|
||||
expect(ids).toContain('old-1');
|
||||
expect(ids).toContain(landingOf(answer).fragment.get('share'));
|
||||
// Everything of the dropped one goes, not only its list.
|
||||
expect(await (await caches.open(CACHE)).match('/__bulwark/handoff/old-0/sealed')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops the oldest entries over the byte limit', async () => {
|
||||
const caches = new FakeCaches();
|
||||
const now = Date.now();
|
||||
await plantEntry(caches, 'big', now - 2000, 100 * 1024 * 1024 - 5);
|
||||
await plantEntry(caches, 'small', now - 1000, 2);
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
|
||||
await worker.request(shareRequest(sharedForm()));
|
||||
|
||||
const ids = await entryIds(caches);
|
||||
expect(ids).not.toContain('big');
|
||||
expect(ids).toContain('small');
|
||||
});
|
||||
|
||||
it('stores nothing from a share over the byte limit, says the files are lost, and keeps the rest', async () => {
|
||||
const caches = new FakeCaches();
|
||||
await plantEntry(caches, 'kept', Date.now() - 1000, 10);
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
const big = new File(['x'], 'huge.bin');
|
||||
Object.defineProperty(big, 'size', { value: 100 * 1024 * 1024 + 1 });
|
||||
const fields = new Map<string, unknown>([['title', 'T']]);
|
||||
const form = { get: (name: string) => fields.get(name) ?? null, getAll: () => [big] } as unknown as FormData;
|
||||
|
||||
const { fragment } = landingOf(await worker.request(shareRequest(form)));
|
||||
|
||||
expect(fragment.get('share')).toBe('unavailable');
|
||||
expect(fragment.get('share_files')).toBe('1');
|
||||
expect(await entryIds(caches)).toEqual(['kept']);
|
||||
});
|
||||
|
||||
it('sweeps what is too old on every share', async () => {
|
||||
const caches = new FakeCaches();
|
||||
await plantEntry(caches, 'stale', Date.now() - TTL - 1);
|
||||
await plantEntry(caches, 'fresh', Date.now() - 1000);
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
await worker.request(shareRequest(sharedForm()));
|
||||
const ids = await entryIds(caches);
|
||||
expect(ids).not.toContain('stale');
|
||||
expect(ids).toContain('fresh');
|
||||
});
|
||||
|
||||
it('sweeps what is too old when it activates', async () => {
|
||||
const caches = new FakeCaches();
|
||||
await plantEntry(caches, 'stale', Date.now() - TTL - 1);
|
||||
await plantEntry(caches, 'fresh', Date.now() - 1000);
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
await worker.dispatch('activate');
|
||||
expect(await entryIds(caches)).toEqual(['fresh']);
|
||||
});
|
||||
|
||||
it('creates no cache when it activates with nothing kept', async () => {
|
||||
const caches = new FakeCaches();
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
await worker.dispatch('activate');
|
||||
expect(await caches.has(CACHE)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a mailto: launch without a connection', () => {
|
||||
it('gets the kept handler page instead of a network error', async () => {
|
||||
const caches = new FakeCaches();
|
||||
await shellOn(caches, true);
|
||||
const online = vi.fn(async () => htmlPage('<html>mailto page'));
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: online });
|
||||
await settle();
|
||||
|
||||
await worker.dispatch('message', { data: { type: 'offline-shell', on: true, urls: ['/protocol/mailto'], buildId: 'a' } });
|
||||
await settle();
|
||||
|
||||
online.mockImplementation(async () => { throw new TypeError('offline'); });
|
||||
const answer = await worker.request({ url: `${ORIGIN}/protocol/mailto?url=mailto%3Aa%40example.org`, mode: 'navigate' });
|
||||
expect(await answer!.text()).toBe('<html>mailto page');
|
||||
});
|
||||
|
||||
it('falls back to the start page when that page was never kept', async () => {
|
||||
const caches = new FakeCaches();
|
||||
await shellOn(caches, true);
|
||||
const shell = await caches.open('bulwark-shell-a');
|
||||
await shell.put(ORIGIN, htmlPage('<html>start'));
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
await settle();
|
||||
|
||||
const answer = await worker.request({ url: `${ORIGIN}/protocol/webcal?url=x`, mode: 'navigate' });
|
||||
expect(await answer!.text()).toBe('<html>start');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,587 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* What is stored is sealed with a key of its own, and the key stays in this
|
||||
* tab's sessionStorage, so a second window can't take it. The worker passes
|
||||
* its key in the landing address's fragment. What is stored goes when it is
|
||||
* taken, after HANDOFF_TTL_MS, when newer handoffs need the room, at sign-out
|
||||
* and when offline mode wipes.
|
||||
*
|
||||
* Anyone can POST to the share target, or link to a landing address, so what
|
||||
* arrives that way is untrusted: the provider asks the user before it gets
|
||||
* here (peekStoredHandoff, then adoptStoredHandoff or discardStoredHandoff).
|
||||
* Chromium's link capturing can bring a cross-site form POST into an installed
|
||||
* app's window, so that goes for app windows too. Only a bridge skips it.
|
||||
*/
|
||||
|
||||
import { stripBidiControls } from '@/lib/bidi';
|
||||
import { matchSurface } from '@/lib/deep-links';
|
||||
import { cleanMailtoBody, cleanMailtoSubject } from '@/lib/protocol-handlers/mailto';
|
||||
|
||||
export type HandoffKind = 'compose' | 'eml' | 'calendar' | 'contacts';
|
||||
|
||||
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 and with the same limits. */
|
||||
export const HANDOFF_CACHE = 'bulwark-launch-handoff-v1';
|
||||
export const HANDOFF_TTL_MS = 15 * 60 * 1000;
|
||||
export const HANDOFF_MAX_ENTRIES = 5;
|
||||
export const HANDOFF_MAX_BYTES = 100 * 1024 * 1024;
|
||||
const STORE_PREFIX = '/__bulwark/handoff/';
|
||||
const TAB_KEYS_KEY = 'bulwark:launch-handoffs';
|
||||
/** A launch can arrive twice: as launchQueue's target and as the page's own address. */
|
||||
const NEW_MESSAGE_REPEAT_MS = 2000;
|
||||
|
||||
type Listener = (kind: HandoffKind) => void;
|
||||
export type HandoffLoss = 'expired' | 'missing';
|
||||
|
||||
const queue: Handoff[] = [];
|
||||
const stored = new Map<string, Promise<void>>();
|
||||
const kindListeners = new Map<HandoffKind, Set<Listener>>();
|
||||
const anyListeners = new Set<Listener>();
|
||||
const lossListeners = new Set<(loss: HandoffLoss) => void>();
|
||||
let lastNewMessageAt = 0;
|
||||
let expiryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Bumped by clearHandoffs: a store still under way when it ran takes itself back. */
|
||||
let clearCount = 0;
|
||||
|
||||
function newId(): string {
|
||||
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? 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 readTabKeys(): Record<string, string> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(sessionStorage.getItem(TAB_KEYS_KEY) ?? '{}');
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, string> : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeTabKeys(keys: Record<string, string>): void {
|
||||
try {
|
||||
if (Object.keys(keys).length > 0) sessionStorage.setItem(TAB_KEYS_KEY, JSON.stringify(keys));
|
||||
else sessionStorage.removeItem(TAB_KEYS_KEY);
|
||||
} catch {
|
||||
// Hardened or private modes: the handoff stays in memory only.
|
||||
}
|
||||
}
|
||||
|
||||
function setTabKey(id: string, key: string | null): void {
|
||||
const keys = readTabKeys();
|
||||
if (key) keys[id] = key;
|
||||
else delete keys[id];
|
||||
writeTabKeys(keys);
|
||||
}
|
||||
|
||||
function toBase64Url(bytes: Uint8Array): string {
|
||||
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function fromBase64Url(text: string): Uint8Array<ArrayBuffer> {
|
||||
const binary = atob(text.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** A 12-byte IV, then the AES-GCM ciphertext. */
|
||||
async function seal(key: CryptoKey, data: BufferSource): Promise<Uint8Array<ArrayBuffer>> {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const body = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data));
|
||||
const sealed = new Uint8Array(new ArrayBuffer(iv.length + body.length));
|
||||
sealed.set(iv);
|
||||
sealed.set(body, iv.length);
|
||||
return sealed;
|
||||
}
|
||||
|
||||
async function unseal(key: CryptoKey, sealed: ArrayBuffer): Promise<ArrayBuffer> {
|
||||
return crypto.subtle.decrypt({ name: 'AES-GCM', iv: sealed.slice(0, 12) }, key, sealed.slice(12));
|
||||
}
|
||||
|
||||
function canSeal(): boolean {
|
||||
return typeof crypto !== 'undefined' && !!crypto.subtle;
|
||||
}
|
||||
|
||||
function entryPrefix(id: string): string {
|
||||
return `${STORE_PREFIX}${id}/`;
|
||||
}
|
||||
|
||||
function pathOf(key: { url: string }): string {
|
||||
return new URL(key.url, 'https://x.invalid').pathname;
|
||||
}
|
||||
|
||||
async function deleteEntry(cache: Cache, prefix: string): Promise<void> {
|
||||
const keys = await cache.keys();
|
||||
const own = keys.filter((key) => pathOf(key).startsWith(prefix));
|
||||
// The list last: the sweep goes by it.
|
||||
await Promise.all(own.filter((key) => !pathOf(key).endsWith('/meta')).map((key) => cache.delete(key)));
|
||||
await cache.delete(`${prefix}meta`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops what is too old and, when `incoming` wants in, the oldest entries that
|
||||
* are in the way. Returns when the oldest remaining entry was stored.
|
||||
*/
|
||||
async function sweep(cache: Cache, incoming: { bytes: number } | null): Promise<number | null> {
|
||||
const entries: Array<{ prefix: string; createdAt: number; bytes: number }> = [];
|
||||
for (const key of await cache.keys()) {
|
||||
const path = pathOf(key);
|
||||
if (!path.endsWith('/meta')) continue;
|
||||
const response = await cache.match(key);
|
||||
const meta = response ? ((await response.json().catch(() => null)) as { createdAt?: unknown; bytes?: unknown } | null) : null;
|
||||
entries.push({
|
||||
prefix: path.slice(0, -'meta'.length),
|
||||
createdAt: typeof meta?.createdAt === 'number' ? meta.createdAt : 0,
|
||||
bytes: typeof meta?.bytes === 'number' ? meta.bytes : 0,
|
||||
});
|
||||
}
|
||||
entries.sort((a, b) => a.createdAt - b.createdAt);
|
||||
const now = Date.now();
|
||||
let total = entries.reduce((sum, entry) => sum + entry.bytes, 0);
|
||||
while (entries.length > 0) {
|
||||
const tooOld = now - entries[0].createdAt > HANDOFF_TTL_MS;
|
||||
const inTheWay = incoming !== null
|
||||
&& (entries.length >= HANDOFF_MAX_ENTRIES || total + incoming.bytes > HANDOFF_MAX_BYTES);
|
||||
if (!tooOld && !inTheWay) break;
|
||||
const [oldest] = entries.splice(0, 1);
|
||||
total -= oldest.bytes;
|
||||
await deleteEntry(cache, oldest.prefix);
|
||||
}
|
||||
return entries[0]?.createdAt ?? null;
|
||||
}
|
||||
|
||||
async function writeStored(handoff: Handoff): Promise<void> {
|
||||
const api = cacheApi();
|
||||
if (!api || !canSeal()) return;
|
||||
const bytes = handoff.files.reduce((sum, file) => sum + file.size, 0);
|
||||
if (bytes > HANDOFF_MAX_BYTES) return;
|
||||
const prefix = entryPrefix(handoff.id);
|
||||
const startedAt = clearCount;
|
||||
try {
|
||||
const cache = await api.open(HANDOFF_CACHE);
|
||||
await sweep(cache, { bytes });
|
||||
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
||||
const rawKey = new Uint8Array(await crypto.subtle.exportKey('raw', key));
|
||||
// The list first: the sweep goes by it, so no file can stay behind unlisted.
|
||||
await cache.put(`${prefix}meta`, new Response(JSON.stringify({ kind: handoff.kind, createdAt: handoff.createdAt, bytes })));
|
||||
const contents = JSON.stringify({
|
||||
title: handoff.subject ?? '',
|
||||
text: handoff.body ?? '',
|
||||
url: '',
|
||||
files: handoff.files.map((file) => ({ name: file.name, type: file.type })),
|
||||
});
|
||||
await cache.put(`${prefix}sealed`, new Response(await seal(key, new TextEncoder().encode(contents))));
|
||||
for (const [index, file] of handoff.files.entries()) {
|
||||
await cache.put(`${prefix}${index}`, new Response(await seal(key, await file.arrayBuffer())));
|
||||
}
|
||||
if (startedAt !== clearCount) {
|
||||
await deleteEntry(cache, prefix);
|
||||
return;
|
||||
}
|
||||
setTabKey(handoff.id, toBase64Url(rawKey));
|
||||
} catch {
|
||||
// Quota, or no Cache API here: the handoff stays in memory only.
|
||||
await deleteStored(handoff.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteStored(id: string): Promise<void> {
|
||||
setTabKey(id, null);
|
||||
const api = cacheApi();
|
||||
if (!api) return;
|
||||
try {
|
||||
if (!(await api.has(HANDOFF_CACHE))) return;
|
||||
await deleteEntry(await api.open(HANDOFF_CACHE), entryPrefix(id));
|
||||
} 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 reportLoss(loss: HandoffLoss): void {
|
||||
for (const listener of [...lossListeners]) listener(loss);
|
||||
}
|
||||
|
||||
function dropFromQueue(index: number): Handoff {
|
||||
const [handoff] = queue.splice(index, 1);
|
||||
void (stored.get(handoff.id) ?? Promise.resolve()).then(() => deleteStored(handoff.id));
|
||||
stored.delete(handoff.id);
|
||||
return handoff;
|
||||
}
|
||||
|
||||
function armExpiry(oldestStored: number | null): void {
|
||||
if (expiryTimer !== null) clearTimeout(expiryTimer);
|
||||
expiryTimer = null;
|
||||
const times = [...queue.map((handoff) => handoff.createdAt), ...(oldestStored === null ? [] : [oldestStored])];
|
||||
if (times.length === 0) return;
|
||||
const delay = Math.max(0, Math.min(...times) + HANDOFF_TTL_MS - Date.now()) + 1000;
|
||||
expiryTimer = setTimeout(() => { void expireHandoffs(); }, delay);
|
||||
}
|
||||
|
||||
/** What waited too long goes, in memory and stored. Also runs on a timer while this page is open. */
|
||||
export async function expireHandoffs(): Promise<void> {
|
||||
const now = Date.now();
|
||||
let lost = false;
|
||||
for (let index = queue.length - 1; index >= 0; index -= 1) {
|
||||
if (now - queue[index].createdAt <= HANDOFF_TTL_MS) continue;
|
||||
dropFromQueue(index);
|
||||
lost = true;
|
||||
}
|
||||
if (lost) reportLoss('expired');
|
||||
await sweepStoredHandoffs();
|
||||
}
|
||||
|
||||
async function sweepStoredHandoffs(): Promise<void> {
|
||||
let oldest: number | null = null;
|
||||
const api = cacheApi();
|
||||
try {
|
||||
if (api && (await api.has(HANDOFF_CACHE))) oldest = await sweep(await api.open(HANDOFF_CACHE), null);
|
||||
} catch {
|
||||
// Tried again at the next page load.
|
||||
}
|
||||
armExpiry(oldest);
|
||||
}
|
||||
|
||||
function enqueue(handoff: Handoff, persist: boolean): void {
|
||||
queue.push(handoff);
|
||||
armExpiry(null);
|
||||
if (persist) stored.set(handoff.id, writeStored(handoff));
|
||||
notify(handoff.kind);
|
||||
}
|
||||
|
||||
/** 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. Trusted: what came from outside was asked about first. */
|
||||
export function handOffDraft(draft: { subject?: string; body?: string; files?: File[] }): void {
|
||||
const subject = cleanMailtoSubject(draft.subject ?? '');
|
||||
const body = cleanMailtoBody(draft.body ?? '');
|
||||
enqueue({
|
||||
id: newId(),
|
||||
kind: 'compose',
|
||||
files: draft.files ?? [],
|
||||
...(subject ? { subject } : {}),
|
||||
...(body ? { body } : {}),
|
||||
createdAt: Date.now(),
|
||||
}, true);
|
||||
}
|
||||
|
||||
/** Opens an empty new message. */
|
||||
export function handOffNewMessage(): void {
|
||||
const now = Date.now();
|
||||
if (now - lastNewMessageAt < NEW_MESSAGE_REPEAT_MS) return;
|
||||
lastNewMessageAt = now;
|
||||
enqueue({ id: newId(), kind: 'compose', files: [], createdAt: now }, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes files from a launch by their type. Returns the names nothing handles
|
||||
* and how many couldn't be read.
|
||||
*/
|
||||
export async function handOffLaunchFiles(
|
||||
handles: ReadonlyArray<{ kind?: string; getFile?: () => Promise<File> }>,
|
||||
): Promise<{ unsupported: string[]; failed: number }> {
|
||||
const byKind = new Map<HandoffKind, File[]>();
|
||||
const unsupported: string[] = [];
|
||||
let failed = 0;
|
||||
for (const handle of handles) {
|
||||
if (typeof handle.getFile !== 'function') continue;
|
||||
try {
|
||||
const file = await handle.getFile();
|
||||
const kind = kindForFile(file);
|
||||
if (!kind) {
|
||||
unsupported.push(file.name);
|
||||
continue;
|
||||
}
|
||||
byKind.set(kind, [...(byKind.get(kind) ?? []), file]);
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
for (const [kind, files] of byKind) handOffFiles(files, kind);
|
||||
return { unsupported, failed };
|
||||
}
|
||||
|
||||
/** The oldest handoff of this kind; it is gone from here after this. */
|
||||
export function takeHandoff(kind: HandoffKind): Handoff | null {
|
||||
const now = Date.now();
|
||||
let lost = false;
|
||||
let found: Handoff | null = null;
|
||||
for (let index = 0; index < queue.length && !found; index += 1) {
|
||||
if (queue[index].kind !== kind) continue;
|
||||
const handoff = dropFromQueue(index);
|
||||
index -= 1;
|
||||
if (now - handoff.createdAt <= HANDOFF_TTL_MS) found = handoff;
|
||||
else lost = true;
|
||||
}
|
||||
if (lost) reportLoss('expired');
|
||||
return found;
|
||||
}
|
||||
|
||||
/** What is waiting, oldest first. Nothing is taken. */
|
||||
export function pendingHandoffs(): readonly Handoff[] {
|
||||
return queue;
|
||||
}
|
||||
|
||||
/** `kind` "any" hears every handoff, before those listening for that kind. */
|
||||
export function subscribeHandoff(kind: HandoffKind | 'any', listener: Listener): () => void {
|
||||
if (kind === 'any') {
|
||||
anyListeners.add(listener);
|
||||
return () => { anyListeners.delete(listener); };
|
||||
}
|
||||
const set = kindListeners.get(kind) ?? new Set<Listener>();
|
||||
kindListeners.set(kind, set);
|
||||
set.add(listener);
|
||||
return () => { set.delete(listener); };
|
||||
}
|
||||
|
||||
/** Hears about a handoff that went without arriving, so the user can be told. */
|
||||
export function subscribeHandoffLost(listener: (loss: HandoffLoss) => void): () => void {
|
||||
lossListeners.add(listener);
|
||||
return () => { lossListeners.delete(listener); };
|
||||
}
|
||||
|
||||
/** Subject and body of a draft from what the OS shared, under the same rules as a mailto: link. */
|
||||
export function shareDraft(shared: { title?: string; text?: string; url?: string }): { subject: string; body: string } {
|
||||
const text = cleanMailtoBody(shared.text ?? '').trim();
|
||||
const url = cleanMailtoSubject(shared.url ?? '').trim();
|
||||
// Android often puts the link in the text as well.
|
||||
const body = url && !text.includes(url) ? [text, url].filter(Boolean).join('\n\n') : text;
|
||||
return { subject: cleanMailtoSubject(shared.title ?? '').trim(), body: cleanMailtoBody(body) };
|
||||
}
|
||||
|
||||
/** A name from outside, as it may be shown and attached. */
|
||||
function cleanFileName(name: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return stripBidiControls(name.replace(/[\u0000-\u001F\u007F]/g, '')).slice(0, 255) || 'file';
|
||||
}
|
||||
|
||||
interface StoredContents {
|
||||
meta: { kind: HandoffKind; createdAt: number };
|
||||
subject: string;
|
||||
body: string;
|
||||
files: Array<{ name: string; type: string }>;
|
||||
key: CryptoKey;
|
||||
cache: Cache;
|
||||
}
|
||||
|
||||
async function openStored(id: string, encodedKey: string): Promise<StoredContents | null> {
|
||||
const api = cacheApi();
|
||||
if (!api || !canSeal() || !encodedKey) return null;
|
||||
const prefix = entryPrefix(id);
|
||||
try {
|
||||
if (!(await api.has(HANDOFF_CACHE))) return null;
|
||||
const cache = await api.open(HANDOFF_CACHE);
|
||||
const metaResponse = await cache.match(`${prefix}meta`);
|
||||
if (!metaResponse) return null;
|
||||
const meta = (await metaResponse.json()) as StoredContents['meta'];
|
||||
if (Date.now() - meta.createdAt > HANDOFF_TTL_MS) {
|
||||
await deleteStored(id);
|
||||
return null;
|
||||
}
|
||||
const sealedResponse = await cache.match(`${prefix}sealed`);
|
||||
if (!sealedResponse) return null;
|
||||
const key = await crypto.subtle.importKey('raw', fromBase64Url(encodedKey), 'AES-GCM', false, ['decrypt']);
|
||||
const contents = JSON.parse(new TextDecoder().decode(await unseal(key, await sealedResponse.arrayBuffer()))) as {
|
||||
title?: string; text?: string; url?: string; files?: Array<{ name: string; type?: string }>;
|
||||
};
|
||||
return {
|
||||
meta,
|
||||
...shareDraft(contents),
|
||||
files: (contents.files ?? []).map((file) => ({ name: cleanFileName(file.name), type: file.type ?? '' })),
|
||||
key,
|
||||
cache,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** What a stored handoff holds, to ask the user about. Null when it is gone or can't be read. */
|
||||
export async function peekStoredHandoff(id: string, encodedKey: string): Promise<{ subject: string; body: string; files: string[] } | null> {
|
||||
const contents = await openStored(id, encodedKey);
|
||||
return contents && { subject: contents.subject, body: contents.body, files: contents.files.map((file) => file.name) };
|
||||
}
|
||||
|
||||
/** The user refused it. */
|
||||
export function discardStoredHandoff(id: string): Promise<void> {
|
||||
return deleteStored(id);
|
||||
}
|
||||
|
||||
/** Brings a stored handoff into this tab. False when it is gone. Without `encodedKey`: this tab's own key. */
|
||||
export async function adoptStoredHandoff(id: string, encodedKey?: string): Promise<boolean> {
|
||||
if (queue.some((handoff) => handoff.id === id)) return true;
|
||||
const key = encodedKey ?? readTabKeys()[id] ?? '';
|
||||
const contents = await openStored(id, key);
|
||||
if (!contents) return false;
|
||||
const prefix = entryPrefix(id);
|
||||
const files: File[] = [];
|
||||
let missing = false;
|
||||
for (const [index, entry] of contents.files.entries()) {
|
||||
try {
|
||||
const response = await contents.cache.match(`${prefix}${index}`);
|
||||
if (!response) throw new Error('gone');
|
||||
const bytes = await unseal(contents.key, await response.arrayBuffer());
|
||||
files.push(new File([bytes], entry.name, { type: entry.type }));
|
||||
} catch {
|
||||
missing = true;
|
||||
}
|
||||
}
|
||||
// The awaits above: another caller may have got here first.
|
||||
if (queue.some((handoff) => handoff.id === id)) return true;
|
||||
setTabKey(id, key);
|
||||
enqueue({
|
||||
id,
|
||||
kind: contents.meta.kind,
|
||||
files,
|
||||
...(contents.subject ? { subject: contents.subject } : {}),
|
||||
...(contents.body ? { body: contents.body } : {}),
|
||||
createdAt: contents.meta.createdAt,
|
||||
}, false);
|
||||
if (missing) reportLoss('missing');
|
||||
return true;
|
||||
}
|
||||
|
||||
/** After a page load: this tab's stored handoffs come back, what is too old goes. */
|
||||
export async function restoreStoredHandoffs(): Promise<void> {
|
||||
let lost = false;
|
||||
await Promise.all(Object.keys(readTabKeys()).map(async (id) => {
|
||||
if (await adoptStoredHandoff(id)) return;
|
||||
setTabKey(id, null);
|
||||
lost = true;
|
||||
}));
|
||||
if (lost) reportLoss('expired');
|
||||
await sweepStoredHandoffs();
|
||||
}
|
||||
|
||||
/** Sign-out and offline wipes: nothing that was on its way into an account stays. */
|
||||
export function clearHandoffs(): void {
|
||||
clearCount += 1;
|
||||
queue.length = 0;
|
||||
stored.clear();
|
||||
writeTabKeys({});
|
||||
lastNewMessageAt = 0;
|
||||
if (expiryTimer !== null) clearTimeout(expiryTimer);
|
||||
expiryTimer = null;
|
||||
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; key: string }
|
||||
/** It couldn't be stored, or no worker ran: the text came along in the address. */
|
||||
| { type: 'inline'; subject: string; body: string; filesLost: boolean; cut: boolean };
|
||||
|
||||
const LANDING_HASH_PARAMS = ['share', 'key', 'share_title', 'share_text', 'share_url', 'share_files', 'share_cut'];
|
||||
|
||||
/** What the page's own address asks for, once. See stripLandingParams. */
|
||||
export function parseLaunchLanding(search: string, hash: string): LaunchLanding | null {
|
||||
if (new URLSearchParams(search).get('compose') === '1') return { type: 'new-message' };
|
||||
const params = new URLSearchParams(hash.replace(/^#/, ''));
|
||||
const share = params.get('share');
|
||||
if (!share) return null;
|
||||
if (share === 'unavailable') {
|
||||
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',
|
||||
cut: params.get('share_cut') === '1',
|
||||
};
|
||||
}
|
||||
return /^[\w-]{1,64}$/.test(share) ? { type: 'stored', id: share, key: params.get('key') ?? '' } : null;
|
||||
}
|
||||
|
||||
/** The address without what parseLaunchLanding read, so a reload doesn't do it again. */
|
||||
export function stripLandingParams(search: string, hash: string): { search: string; hash: string } {
|
||||
const query = new URLSearchParams(search);
|
||||
query.delete('compose');
|
||||
const rest = query.toString();
|
||||
let leftHash = hash;
|
||||
const fragment = new URLSearchParams(hash.replace(/^#/, ''));
|
||||
if (fragment.has('share')) {
|
||||
for (const name of LANDING_HASH_PARAMS) fragment.delete(name);
|
||||
const left = fragment.toString();
|
||||
leftHash = left ? `#${left}` : '';
|
||||
}
|
||||
return { search: rest ? `?${rest}` : '', hash: leftHash };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
||||
import { handOffFiles, pendingHandoffs } from '@/lib/launch-handoff';
|
||||
import { disableOffline, enforcePolicy, wipeEverything } from '../offline-mode';
|
||||
import { forgetIndexes } from '../cache';
|
||||
import { forgetDeviceKey } from '../crypto';
|
||||
import { allowOfflineDbCreation, deleteOfflineDb } from '../db';
|
||||
import { useOfflineStore, writeEnabled } from '../state';
|
||||
|
||||
// Offline mode's wipes take what was on its way into an account along.
|
||||
|
||||
function waiting() {
|
||||
handOffFiles([new File(['x'], 'a.ics')], 'calendar');
|
||||
expect(pendingHandoffs()).toHaveLength(1);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
forgetIndexes();
|
||||
forgetDeviceKey();
|
||||
await deleteOfflineDb();
|
||||
allowOfflineDbCreation();
|
||||
localStorage.clear();
|
||||
useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, browserOffline: false, downServers: [], startedOffline: false });
|
||||
usePolicyStore.setState({ loaded: true, subject: null, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } } as never);
|
||||
useAccountStore.setState({ accounts: [{ id: 'acc', label: 'alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await wipeEverything();
|
||||
});
|
||||
|
||||
describe('offline wipes and handoffs', () => {
|
||||
it('wipeEverything clears them', async () => {
|
||||
waiting();
|
||||
await wipeEverything();
|
||||
expect(pendingHandoffs()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('disableOffline clears them', async () => {
|
||||
writeEnabled(['acc']);
|
||||
waiting();
|
||||
await disableOffline('acc');
|
||||
expect(pendingHandoffs()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('enforcePolicy clears them when the organisation forbids offline mode', async () => {
|
||||
writeEnabled(['acc']);
|
||||
waiting();
|
||||
usePolicyStore.setState({ policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, noOffline: true } } } });
|
||||
enforcePolicy();
|
||||
await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual([]));
|
||||
expect(pendingHandoffs()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('enforcePolicy leaves them while offline mode is allowed', () => {
|
||||
writeEnabled(['acc']);
|
||||
waiting();
|
||||
enforcePolicy();
|
||||
expect(pendingHandoffs()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -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']));
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
type OutboxSummary,
|
||||
type PolicyFingerprint,
|
||||
} from './queue';
|
||||
import { clearHandoffs } from '@/lib/launch-handoff';
|
||||
import { isOfflineNow, OFFLINE_ACCOUNTS_KEY, readEnabledFrom, serverKey, setServerDown, useOfflineStore, writeEnabled, type OutboxRow } from './state';
|
||||
import { flushQueue, settleStaleSends } from './flush';
|
||||
import { LEGACY_DB_NAME, moveLegacyOutbox } from './migrate-legacy';
|
||||
@@ -646,6 +647,7 @@ export async function enableOffline(account: string): Promise<void> {
|
||||
/** Switches offline mode off for an account (the user confirmed losing unsent mail) and removes everything it kept here. */
|
||||
export async function disableOffline(account: string): Promise<void> {
|
||||
confirmLoss(account);
|
||||
clearHandoffs();
|
||||
writeEnabled(useOfflineStore.getState().enabled.filter((id) => id !== account));
|
||||
await wipeAccount(account);
|
||||
}
|
||||
@@ -733,6 +735,7 @@ export async function wipeEverything(): Promise<void> {
|
||||
}
|
||||
await tellServiceWorker(false);
|
||||
await deleteShellCaches();
|
||||
clearHandoffs();
|
||||
confirmed.clear();
|
||||
named.clear();
|
||||
useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [], usageBytes: 0, syncing: [], lastSyncedAt: {} });
|
||||
@@ -755,6 +758,7 @@ export function enforcePolicy(): void {
|
||||
for (const account of useOfflineStore.getState().enabled) {
|
||||
if (!policyDecided(account)) continue;
|
||||
if (!offlineAllowed(account)) {
|
||||
clearHandoffs();
|
||||
void wipeAccount(account);
|
||||
notify({ kind: 'forbidden' });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -39,6 +39,16 @@ function stripBodyControlChars(value: string): string {
|
||||
.replace(CONTROL_CHARS_EXCEPT_LINE_BREAKS, "");
|
||||
}
|
||||
|
||||
/** A subject from outside the app: no control characters, no longer than a mail header allows. */
|
||||
export function cleanMailtoSubject(value: string): string {
|
||||
return stripControlChars(value).slice(0, MAX_SUBJECT_LENGTH);
|
||||
}
|
||||
|
||||
/** A body from outside the app: line breaks kept, other control characters gone, limited in length. */
|
||||
export function cleanMailtoBody(value: string): string {
|
||||
return stripBodyControlChars(value).slice(0, MAX_BODY_LENGTH);
|
||||
}
|
||||
|
||||
function splitRecipients(value: string): string[] {
|
||||
// Quote/angle-aware split so a `"Doe, John" <john@doo.org>` display name with
|
||||
// an embedded comma stays a single recipient instead of being torn in two.
|
||||
@@ -125,8 +135,8 @@ export function parseMailto(raw: string): ParsedMailto | null {
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
subject: stripControlChars(getQueryValue(searchParams, "subject")).slice(0, MAX_SUBJECT_LENGTH),
|
||||
body: stripBodyControlChars(getQueryValue(searchParams, "body")).slice(0, MAX_BODY_LENGTH),
|
||||
subject: cleanMailtoSubject(getQueryValue(searchParams, "subject")),
|
||||
body: cleanMailtoBody(getQueryValue(searchParams, "body")),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
import { purgeStoreFiles } from '@/lib/store/client/purge';
|
||||
@@ -117,6 +118,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();
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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": "يحتاج استيراد جهات الاتصال إلى اتصال بالخادم. حاول مرة أخرى عند عودتك إلى الاتصال بالإنترنت.",
|
||||
"share_confirm_title": "هل تريد بدء رسالة جديدة؟",
|
||||
"share_confirm_message": "يريد موقع أو تطبيق آخر بدء رسالة. لن يُرفع أو يُحفظ أي شيء إلا إذا وافقت.",
|
||||
"share_confirm_subject": "الموضوع:",
|
||||
"share_confirm_text": "النص:",
|
||||
"share_confirm_files": "{count, plural, one {ملف:} other {ملفات:}}",
|
||||
"share_confirm_accept": "بدء الرسالة",
|
||||
"share_confirm_decline": "عدم البدء",
|
||||
"share_cut": "كان النص المشترك طويلًا جدًا وتم اختصاره.",
|
||||
"handoff_expired": "انتظر شيء فتحته أو شاركته مدة طويلة ولم يعد موجودًا. يرجى فتحه أو مشاركته مرة أخرى.",
|
||||
"compose_waiting": "أغلق هذه الرسالة لبدء الرسالة التالية.",
|
||||
"save_first_failed": "تعذر حفظ رسالتك، لذلك لم يُفتح الملف. احفظ الرسالة ثم افتح التقويم أو جهات الاتصال."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "تطبيقات الشريط الجانبي",
|
||||
"add_new": "إضافة تطبيق",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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.",
|
||||
"share_confirm_title": "Voleu començar un missatge nou?",
|
||||
"share_confirm_message": "Un altre lloc o aplicació vol començar un missatge. No es puja ni es desa res si no hi esteu d'acord.",
|
||||
"share_confirm_subject": "Assumpte:",
|
||||
"share_confirm_text": "Text:",
|
||||
"share_confirm_files": "{count, plural, one {Fitxer:} other {Fitxers:}}",
|
||||
"share_confirm_accept": "Comença el missatge",
|
||||
"share_confirm_decline": "No el comencis",
|
||||
"share_cut": "El text compartit era massa llarg i s'ha escurçat.",
|
||||
"handoff_expired": "Una cosa que heu obert o compartit ha esperat massa i ja no hi és. Torneu-la a obrir o compartir.",
|
||||
"compose_waiting": "Tanqueu aquest missatge per començar el següent.",
|
||||
"save_first_failed": "No s'ha pogut desar el missatge, per això no s'ha obert el fitxer. Deseu el missatge i obriu el calendari o els contactes."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Aplicacions de la barra lateral",
|
||||
"add_new": "Afegeix una aplicació",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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.",
|
||||
"share_confirm_title": "Začít novou zprávu?",
|
||||
"share_confirm_message": "Jiný web nebo aplikace chce začít zprávu. Bez vašeho souhlasu se nic nenahraje ani neuloží.",
|
||||
"share_confirm_subject": "Předmět:",
|
||||
"share_confirm_text": "Text:",
|
||||
"share_confirm_files": "{count, plural, one {Soubor:} few {Soubory:} many {Souborů:} other {Soubory:}}",
|
||||
"share_confirm_accept": "Začít zprávu",
|
||||
"share_confirm_decline": "Nezačínat",
|
||||
"share_cut": "Sdílený text byl příliš dlouhý a byl zkrácen.",
|
||||
"handoff_expired": "To, co jste otevřeli nebo sdíleli, čekalo příliš dlouho a už tu není. Otevřete to nebo sdílejte znovu.",
|
||||
"compose_waiting": "Zavřete tuto zprávu, abyste mohli začít další.",
|
||||
"save_first_failed": "Zprávu se nepodařilo uložit, proto se soubor neotevřel. Uložte zprávu a pak otevřete kalendář nebo kontakty."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Aplikace postranního panelu",
|
||||
"add_new": "Přidat aplikaci",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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.",
|
||||
"share_confirm_title": "Vil du starte en ny besked?",
|
||||
"share_confirm_message": "Et andet websted eller en anden app vil starte en besked. Intet uploades eller gemmes, medmindre du accepterer.",
|
||||
"share_confirm_subject": "Emne:",
|
||||
"share_confirm_text": "Tekst:",
|
||||
"share_confirm_files": "{count, plural, one {Fil:} other {Filer:}}",
|
||||
"share_confirm_accept": "Start besked",
|
||||
"share_confirm_decline": "Start ikke",
|
||||
"share_cut": "Den delte tekst var for lang og er blevet forkortet.",
|
||||
"handoff_expired": "Noget, du åbnede eller delte, ventede for længe og er væk. Åbn eller del det igen.",
|
||||
"compose_waiting": "Luk denne besked for at starte den næste.",
|
||||
"save_first_failed": "Din besked kunne ikke gemmes, så filen er ikke åbnet. Gem beskeden, og åbn derefter kalenderen eller kontakterne."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Sidepanel-apps",
|
||||
"add_new": "Tilføj app",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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.",
|
||||
"share_confirm_title": "Neue Nachricht beginnen?",
|
||||
"share_confirm_message": "Eine andere Website oder App möchte eine Nachricht beginnen. Es wird nichts hochgeladen oder gespeichert, wenn Sie nicht zustimmen.",
|
||||
"share_confirm_subject": "Betreff:",
|
||||
"share_confirm_text": "Text:",
|
||||
"share_confirm_files": "{count, plural, one {Datei:} other {Dateien:}}",
|
||||
"share_confirm_accept": "Nachricht beginnen",
|
||||
"share_confirm_decline": "Nicht beginnen",
|
||||
"share_cut": "Der geteilte Text war zu lang und wurde gekürzt.",
|
||||
"handoff_expired": "Etwas, das Sie geöffnet oder geteilt haben, hat zu lange gewartet und ist nicht mehr da. Bitte öffnen oder teilen Sie es noch einmal.",
|
||||
"compose_waiting": "Schließen Sie diese Nachricht, um die nächste zu beginnen.",
|
||||
"save_first_failed": "Ihre Nachricht konnte nicht gespeichert werden, deshalb wurde die Datei nicht geöffnet. Speichern Sie die Nachricht und öffnen Sie dann den Kalender oder die Kontakte."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Sidebar-Apps",
|
||||
"add_new": "App hinzufügen",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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.",
|
||||
"share_confirm_title": "Start a new message?",
|
||||
"share_confirm_message": "Another site or app wants to start a message. Nothing is uploaded or saved unless you agree.",
|
||||
"share_confirm_subject": "Subject:",
|
||||
"share_confirm_text": "Text:",
|
||||
"share_confirm_files": "{count, plural, one {File:} other {Files:}}",
|
||||
"share_confirm_accept": "Start message",
|
||||
"share_confirm_decline": "Don't start",
|
||||
"share_cut": "The shared text was too long and has been shortened.",
|
||||
"handoff_expired": "Something you opened or shared waited too long and is gone. Please open or share it again.",
|
||||
"compose_waiting": "Close this message to start the next one.",
|
||||
"save_first_failed": "Your message couldn't be saved, so the file hasn't been opened. Save the message, then open the calendar or contacts."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Sidebar apps",
|
||||
"add_new": "Add app",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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.",
|
||||
"share_confirm_title": "¿Empezar un mensaje nuevo?",
|
||||
"share_confirm_message": "Otro sitio o aplicación quiere empezar un mensaje. No se sube ni se guarda nada si no aceptas.",
|
||||
"share_confirm_subject": "Asunto:",
|
||||
"share_confirm_text": "Texto:",
|
||||
"share_confirm_files": "{count, plural, one {Archivo:} other {Archivos:}}",
|
||||
"share_confirm_accept": "Empezar mensaje",
|
||||
"share_confirm_decline": "No empezar",
|
||||
"share_cut": "El texto compartido era demasiado largo y se ha acortado.",
|
||||
"handoff_expired": "Algo que abriste o compartiste esperó demasiado y ya no está. Ábrelo o compártelo de nuevo.",
|
||||
"compose_waiting": "Cierra este mensaje para empezar el siguiente.",
|
||||
"save_first_failed": "No se pudo guardar tu mensaje, así que el archivo no se ha abierto. Guarda el mensaje y luego abre el calendario o los contactos."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Aplicaciones de la barra lateral",
|
||||
"add_new": "Añadir aplicación",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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.",
|
||||
"share_confirm_title": "Kas alustada uut kirja?",
|
||||
"share_confirm_message": "Teine sait või rakendus soovib kirja alustada. Ilma sinu nõusolekuta ei laadita ega salvestata midagi.",
|
||||
"share_confirm_subject": "Teema:",
|
||||
"share_confirm_text": "Tekst:",
|
||||
"share_confirm_files": "{count, plural, one {Fail:} other {Failid:}}",
|
||||
"share_confirm_accept": "Alusta kirja",
|
||||
"share_confirm_decline": "Ära alusta",
|
||||
"share_cut": "Jagatud tekst oli liiga pikk ja seda lühendati.",
|
||||
"handoff_expired": "Midagi, mille avasid või jagasid, ootas liiga kaua ja seda pole enam. Ava või jaga see uuesti.",
|
||||
"compose_waiting": "Järgmise alustamiseks sulge see kiri.",
|
||||
"save_first_failed": "Kirja ei saanud salvestada, seega faili ei avatud. Salvesta kiri ja ava siis kalender või kontaktid."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Külgriba rakendused",
|
||||
"add_new": "Lisa rakendus",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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": "وارد کردن مخاطبان به اتصال به سرور نیاز دارد. وقتی دوباره آنلاین شدید، دوباره امتحان کنید.",
|
||||
"share_confirm_title": "پیام جدیدی شروع شود؟",
|
||||
"share_confirm_message": "سایت یا برنامه دیگری میخواهد پیامی را شروع کند. تا موافقت نکنید چیزی بارگذاری یا ذخیره نمیشود.",
|
||||
"share_confirm_subject": "موضوع:",
|
||||
"share_confirm_text": "متن:",
|
||||
"share_confirm_files": "{count, plural, one {فایل:} other {فایلها:}}",
|
||||
"share_confirm_accept": "شروع پیام",
|
||||
"share_confirm_decline": "شروع نشود",
|
||||
"share_cut": "متن همرسانیشده خیلی طولانی بود و کوتاه شد.",
|
||||
"handoff_expired": "چیزی که باز یا همرسانی کردید بیش از حد منتظر ماند و دیگر نیست. لطفاً دوباره آن را باز یا همرسانی کنید.",
|
||||
"compose_waiting": "برای شروع پیام بعدی این پیام را ببندید.",
|
||||
"save_first_failed": "پیام شما ذخیره نشد، بنابراین فایل باز نشد. پیام را ذخیره کنید و سپس تقویم یا مخاطبان را باز کنید."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "برنامههای نوار کناری",
|
||||
"add_new": "افزودن برنامه",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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.",
|
||||
"share_confirm_title": "Commencer un nouveau message ?",
|
||||
"share_confirm_message": "Un autre site ou une autre application veut commencer un message. Rien n'est envoyé ni enregistré sans votre accord.",
|
||||
"share_confirm_subject": "Objet :",
|
||||
"share_confirm_text": "Texte :",
|
||||
"share_confirm_files": "{count, plural, one {Fichier :} other {Fichiers :}}",
|
||||
"share_confirm_accept": "Commencer le message",
|
||||
"share_confirm_decline": "Ne pas commencer",
|
||||
"share_cut": "Le texte partagé était trop long et a été raccourci.",
|
||||
"handoff_expired": "Un élément que vous avez ouvert ou partagé a attendu trop longtemps et n'est plus disponible. Ouvrez-le ou partagez-le à nouveau.",
|
||||
"compose_waiting": "Fermez ce message pour commencer le suivant.",
|
||||
"save_first_failed": "Votre message n'a pas pu être enregistré, le fichier n'a donc pas été ouvert. Enregistrez le message, puis ouvrez le calendrier ou les contacts."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Applications de la barre latérale",
|
||||
"add_new": "Ajouter une application",
|
||||
|
||||
@@ -5275,6 +5275,28 @@
|
||||
"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": "ייבוא אנשי קשר דורש חיבור לשרת. נסו שוב כשתחזרו להיות מחוברים.",
|
||||
"share_confirm_title": "להתחיל הודעה חדשה?",
|
||||
"share_confirm_message": "אתר או אפליקציה אחרים רוצים להתחיל הודעה. שום דבר לא יועלה או יישמר בלי הסכמתכם.",
|
||||
"share_confirm_subject": "נושא:",
|
||||
"share_confirm_text": "טקסט:",
|
||||
"share_confirm_files": "{count, plural, one {קובץ:} other {קבצים:}}",
|
||||
"share_confirm_accept": "התחלת הודעה",
|
||||
"share_confirm_decline": "לא להתחיל",
|
||||
"share_cut": "הטקסט ששותף היה ארוך מדי וקוצר.",
|
||||
"handoff_expired": "משהו שפתחתם או שיתפתם המתין יותר מדי זמן וכבר אינו כאן. פתחו או שתפו אותו שוב.",
|
||||
"compose_waiting": "סגרו הודעה זו כדי להתחיל את הבאה.",
|
||||
"save_first_failed": "לא ניתן היה לשמור את ההודעה, לכן הקובץ לא נפתח. שמרו את ההודעה ואז פתחו את לוח השנה או את אנשי הקשר."
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "סמן תיקייה כקרויה",
|
||||
"mark_folder_tree_read": "סמן תיקייה ותת־תיקיות כקרויות",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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.",
|
||||
"share_confirm_title": "Új üzenetet kezd?",
|
||||
"share_confirm_message": "Egy másik webhely vagy alkalmazás üzenetet szeretne kezdeni. Az Ön beleegyezése nélkül semmi sem kerül feltöltésre vagy mentésre.",
|
||||
"share_confirm_subject": "Tárgy:",
|
||||
"share_confirm_text": "Szöveg:",
|
||||
"share_confirm_files": "{count, plural, one {Fájl:} other {Fájlok:}}",
|
||||
"share_confirm_accept": "Üzenet kezdése",
|
||||
"share_confirm_decline": "Ne kezdje el",
|
||||
"share_cut": "A megosztott szöveg túl hosszú volt, ezért rövidítettük.",
|
||||
"handoff_expired": "Valami, amit megnyitott vagy megosztott, túl sokáig várakozott, és már nincs meg. Kérjük, nyissa meg vagy ossza meg újra.",
|
||||
"compose_waiting": "Zárja be ezt az üzenetet a következő kezdéséhez.",
|
||||
"save_first_failed": "Az üzenetet nem sikerült menteni, ezért a fájl nem nyílt meg. Mentse az üzenetet, majd nyissa meg a naptárat vagy a névjegyeket."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Oldalsáv alkalmazások",
|
||||
"add_new": "Alkalmazás hozzáadása",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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.",
|
||||
"share_confirm_title": "Iniziare un nuovo messaggio?",
|
||||
"share_confirm_message": "Un altro sito o app vuole iniziare un messaggio. Non viene caricato o salvato nulla senza il tuo consenso.",
|
||||
"share_confirm_subject": "Oggetto:",
|
||||
"share_confirm_text": "Testo:",
|
||||
"share_confirm_files": "{count, plural, one {File:} other {File:}}",
|
||||
"share_confirm_accept": "Inizia messaggio",
|
||||
"share_confirm_decline": "Non iniziare",
|
||||
"share_cut": "Il testo condiviso era troppo lungo ed è stato accorciato.",
|
||||
"handoff_expired": "Qualcosa che hai aperto o condiviso ha atteso troppo a lungo e non c'è più. Aprilo o condividilo di nuovo.",
|
||||
"compose_waiting": "Chiudi questo messaggio per iniziare il successivo.",
|
||||
"save_first_failed": "Non è stato possibile salvare il messaggio, quindi il file non è stato aperto. Salva il messaggio, poi apri il calendario o i contatti."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "App della barra laterale",
|
||||
"add_new": "Aggiungi app",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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": "連絡先のインポートにはサーバーへの接続が必要です。オンラインに戻ってからもう一度お試しください。",
|
||||
"share_confirm_title": "新しいメッセージを開始しますか?",
|
||||
"share_confirm_message": "別のサイトまたはアプリがメッセージを開始しようとしています。同意しない限り、何もアップロードも保存もされません。",
|
||||
"share_confirm_subject": "件名:",
|
||||
"share_confirm_text": "本文:",
|
||||
"share_confirm_files": "{count, plural, other {ファイル:}}",
|
||||
"share_confirm_accept": "メッセージを開始",
|
||||
"share_confirm_decline": "開始しない",
|
||||
"share_cut": "共有されたテキストが長すぎるため短縮されました。",
|
||||
"handoff_expired": "開いた、または共有した内容は待機時間が長すぎたためなくなりました。もう一度開くか共有してください。",
|
||||
"compose_waiting": "次のメッセージを開始するには、このメッセージを閉じてください。",
|
||||
"save_first_failed": "メッセージを保存できなかったため、ファイルを開いていません。メッセージを保存してから、カレンダーまたは連絡先を開いてください。"
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "サイドバーアプリ",
|
||||
"add_new": "アプリを追加",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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": "연락처를 가져오려면 서버에 연결되어 있어야 합니다. 다시 온라인 상태가 되면 다시 시도해 주세요.",
|
||||
"share_confirm_title": "새 메시지를 시작할까요?",
|
||||
"share_confirm_message": "다른 사이트나 앱에서 메시지를 시작하려고 합니다. 동의하지 않으면 아무것도 업로드하거나 저장하지 않습니다.",
|
||||
"share_confirm_subject": "제목:",
|
||||
"share_confirm_text": "내용:",
|
||||
"share_confirm_files": "{count, plural, other {파일:}}",
|
||||
"share_confirm_accept": "메시지 시작",
|
||||
"share_confirm_decline": "시작 안 함",
|
||||
"share_cut": "공유한 텍스트가 너무 길어서 줄였습니다.",
|
||||
"handoff_expired": "열었거나 공유한 항목이 너무 오래 대기하여 사라졌습니다. 다시 열거나 공유해 주세요.",
|
||||
"compose_waiting": "다음 메시지를 시작하려면 이 메시지를 닫으세요.",
|
||||
"save_first_failed": "메시지를 저장할 수 없어 파일을 열지 않았습니다. 메시지를 저장한 다음 캘린더나 연락처를 여세요."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "사이드바 앱",
|
||||
"add_new": "앱 추가",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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ē.",
|
||||
"share_confirm_title": "Vai sākt jaunu ziņu?",
|
||||
"share_confirm_message": "Cita vietne vai lietotne vēlas sākt ziņu. Bez jūsu piekrišanas nekas netiek augšupielādēts vai saglabāts.",
|
||||
"share_confirm_subject": "Tēma:",
|
||||
"share_confirm_text": "Teksts:",
|
||||
"share_confirm_files": "{count, plural, zero {Faili:} one {Fails:} other {Faili:}}",
|
||||
"share_confirm_accept": "Sākt ziņu",
|
||||
"share_confirm_decline": "Nesākt",
|
||||
"share_cut": "Kopīgotais teksts bija pārāk garš un tika saīsināts.",
|
||||
"handoff_expired": "Tas, ko atvērāt vai kopīgojāt, gaidīja pārāk ilgi un vairs nav pieejams. Atveriet vai kopīgojiet to vēlreiz.",
|
||||
"compose_waiting": "Aizveriet šo ziņu, lai sāktu nākamo.",
|
||||
"save_first_failed": "Ziņu neizdevās saglabāt, tāpēc fails netika atvērts. Saglabājiet ziņu un pēc tam atveriet kalendāru vai kontaktus."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Sānu joslas lietotnes",
|
||||
"add_new": "Pievienot lietotni",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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": "Харилцагчдыг импортлоход сервертэй холболт хэрэгтэй. Дахин онлайн болсон үедээ оролдоно уу.",
|
||||
"share_confirm_title": "Шинэ зурвас эхлүүлэх үү?",
|
||||
"share_confirm_message": "Өөр сайт эсвэл апп зурвас эхлүүлэхийг хүсэж байна. Та зөвшөөрөхгүй бол юу ч байршуулахгүй, хадгалахгүй.",
|
||||
"share_confirm_subject": "Гарчиг:",
|
||||
"share_confirm_text": "Текст:",
|
||||
"share_confirm_files": "{count, plural, one {Файл:} other {Файлууд:}}",
|
||||
"share_confirm_accept": "Зурвас эхлүүлэх",
|
||||
"share_confirm_decline": "Эхлүүлэхгүй",
|
||||
"share_cut": "Хуваалцсан текст хэт урт байсан тул богиносгосон.",
|
||||
"handoff_expired": "Таны нээсэн эсвэл хуваалцсан зүйл хэт удаан хүлээсэн тул байхгүй болсон. Дахин нээх эсвэл хуваалцана уу.",
|
||||
"compose_waiting": "Дараагийнхыг эхлүүлэхийн тулд энэ зурвасыг хаана уу.",
|
||||
"save_first_failed": "Зурвасыг хадгалж чадаагүй тул файлыг нээгээгүй. Зурвасаа хадгалаад хуанли эсвэл харилцагчдыг нээнэ үү."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Sidebar програмууд",
|
||||
"add_new": "Апп нэмэх",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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.",
|
||||
"share_confirm_title": "Vil du starte en ny melding?",
|
||||
"share_confirm_message": "Et annet nettsted eller en annen app vil starte en melding. Ingenting lastes opp eller lagres uten at du godtar det.",
|
||||
"share_confirm_subject": "Emne:",
|
||||
"share_confirm_text": "Tekst:",
|
||||
"share_confirm_files": "{count, plural, one {Fil:} other {Filer:}}",
|
||||
"share_confirm_accept": "Start melding",
|
||||
"share_confirm_decline": "Ikke start",
|
||||
"share_cut": "Den delte teksten var for lang og er forkortet.",
|
||||
"handoff_expired": "Noe du åpnet eller delte, ventet for lenge og er borte. Åpne eller del det på nytt.",
|
||||
"compose_waiting": "Lukk denne meldingen for å starte den neste.",
|
||||
"save_first_failed": "Meldingen kunne ikke lagres, så filen er ikke åpnet. Lagre meldingen, og åpne deretter kalenderen eller kontaktene."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Apper i sidefeltet",
|
||||
"add_new": "Legg til app",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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.",
|
||||
"share_confirm_title": "Een nieuw bericht beginnen?",
|
||||
"share_confirm_message": "Een andere site of app wil een bericht beginnen. Er wordt niets geüpload of opgeslagen zonder je toestemming.",
|
||||
"share_confirm_subject": "Onderwerp:",
|
||||
"share_confirm_text": "Tekst:",
|
||||
"share_confirm_files": "{count, plural, one {Bestand:} other {Bestanden:}}",
|
||||
"share_confirm_accept": "Bericht beginnen",
|
||||
"share_confirm_decline": "Niet beginnen",
|
||||
"share_cut": "De gedeelde tekst was te lang en is ingekort.",
|
||||
"handoff_expired": "Iets wat je hebt geopend of gedeeld, heeft te lang gewacht en is weg. Open of deel het opnieuw.",
|
||||
"compose_waiting": "Sluit dit bericht om het volgende te beginnen.",
|
||||
"save_first_failed": "Je bericht kon niet worden opgeslagen, dus het bestand is niet geopend. Sla het bericht op en open daarna de agenda of de contacten."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Zijbalk-apps",
|
||||
"add_new": "App toevoegen",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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.",
|
||||
"share_confirm_title": "Rozpocząć nową wiadomość?",
|
||||
"share_confirm_message": "Inna witryna lub aplikacja chce rozpocząć wiadomość. Bez Twojej zgody nic nie zostanie wysłane ani zapisane.",
|
||||
"share_confirm_subject": "Temat:",
|
||||
"share_confirm_text": "Tekst:",
|
||||
"share_confirm_files": "{count, plural, one {Plik:} few {Pliki:} many {Pliki:} other {Pliki:}}",
|
||||
"share_confirm_accept": "Rozpocznij wiadomość",
|
||||
"share_confirm_decline": "Nie rozpoczynaj",
|
||||
"share_cut": "Udostępniony tekst był za długi i został skrócony.",
|
||||
"handoff_expired": "To, co otwarto lub udostępniono, czekało zbyt długo i już go nie ma. Otwórz lub udostępnij to ponownie.",
|
||||
"compose_waiting": "Zamknij tę wiadomość, aby rozpocząć następną.",
|
||||
"save_first_failed": "Nie udało się zapisać wiadomości, więc plik nie został otwarty. Zapisz wiadomość, a potem otwórz kalendarz lub kontakty."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Aplikacje paska bocznego",
|
||||
"add_new": "Dodaj aplikację",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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.",
|
||||
"share_confirm_title": "Começar uma nova mensagem?",
|
||||
"share_confirm_message": "Outro site ou aplicação quer começar uma mensagem. Nada é carregado ou guardado sem o seu acordo.",
|
||||
"share_confirm_subject": "Assunto:",
|
||||
"share_confirm_text": "Texto:",
|
||||
"share_confirm_files": "{count, plural, one {Ficheiro:} other {Ficheiros:}}",
|
||||
"share_confirm_accept": "Começar mensagem",
|
||||
"share_confirm_decline": "Não começar",
|
||||
"share_cut": "O texto partilhado era demasiado longo e foi encurtado.",
|
||||
"handoff_expired": "Algo que abriu ou partilhou esperou demasiado tempo e já não está disponível. Abra ou partilhe novamente.",
|
||||
"compose_waiting": "Feche esta mensagem para começar a seguinte.",
|
||||
"save_first_failed": "Não foi possível guardar a mensagem, por isso o ficheiro não foi aberto. Guarde a mensagem e depois abra o calendário ou os contactos."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Apps da barra lateral",
|
||||
"add_new": "Adicionar app",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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.",
|
||||
"share_confirm_title": "Începeți un mesaj nou?",
|
||||
"share_confirm_message": "Alt site sau altă aplicație vrea să înceapă un mesaj. Nu se încarcă și nu se salvează nimic fără acordul dvs.",
|
||||
"share_confirm_subject": "Subiect:",
|
||||
"share_confirm_text": "Text:",
|
||||
"share_confirm_files": "{count, plural, one {Fișier:} few {Fișiere:} other {Fișiere:}}",
|
||||
"share_confirm_accept": "Începeți mesajul",
|
||||
"share_confirm_decline": "Nu începeți",
|
||||
"share_cut": "Textul partajat era prea lung și a fost scurtat.",
|
||||
"handoff_expired": "Ceva ce ați deschis sau partajat a așteptat prea mult și nu mai este disponibil. Deschideți sau partajați din nou.",
|
||||
"compose_waiting": "Închideți acest mesaj pentru a-l începe pe următorul.",
|
||||
"save_first_failed": "Mesajul nu a putut fi salvat, așa că fișierul nu a fost deschis. Salvați mesajul, apoi deschideți calendarul sau contactele."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Aplicații din bara laterală",
|
||||
"add_new": "Adăugați aplicația",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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": "Для импорта контактов нужно подключение к серверу. Попробуйте ещё раз, когда снова будете в сети.",
|
||||
"share_confirm_title": "Начать новое письмо?",
|
||||
"share_confirm_message": "Другой сайт или приложение хочет начать письмо. Без вашего согласия ничего не будет загружено или сохранено.",
|
||||
"share_confirm_subject": "Тема:",
|
||||
"share_confirm_text": "Текст:",
|
||||
"share_confirm_files": "{count, plural, one {Файл:} few {Файлы:} many {Файлы:} other {Файлы:}}",
|
||||
"share_confirm_accept": "Начать письмо",
|
||||
"share_confirm_decline": "Не начинать",
|
||||
"share_cut": "Полученный текст был слишком длинным и был сокращён.",
|
||||
"handoff_expired": "То, что вы открыли или чем поделились, ждало слишком долго и больше недоступно. Откройте или поделитесь ещё раз.",
|
||||
"compose_waiting": "Закройте это письмо, чтобы начать следующее.",
|
||||
"save_first_failed": "Не удалось сохранить письмо, поэтому файл не открыт. Сохраните письмо, затем откройте календарь или контакты."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Приложения боковой панели",
|
||||
"add_new": "Добавить приложение",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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.",
|
||||
"share_confirm_title": "Začať novú správu?",
|
||||
"share_confirm_message": "Iný web alebo aplikácia chce začať správu. Bez vášho súhlasu sa nič nenahrá ani neuloží.",
|
||||
"share_confirm_subject": "Predmet:",
|
||||
"share_confirm_text": "Text:",
|
||||
"share_confirm_files": "{count, plural, one {Súbor:} few {Súbory:} many {Súborov:} other {Súbory:}}",
|
||||
"share_confirm_accept": "Začať správu",
|
||||
"share_confirm_decline": "Nezačínať",
|
||||
"share_cut": "Zdieľaný text bol príliš dlhý a bol skrátený.",
|
||||
"handoff_expired": "To, čo ste otvorili alebo zdieľali, čakalo príliš dlho a už tu nie je. Otvorte to alebo zdieľajte znova.",
|
||||
"compose_waiting": "Zatvorte túto správu, aby ste mohli začať ďalšiu.",
|
||||
"save_first_failed": "Správu sa nepodarilo uložiť, preto sa súbor neotvoril. Uložte správu a potom otvorte kalendár alebo kontakty."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Aplikácie postranného panela",
|
||||
"add_new": "Pridať aplikáciu",
|
||||
|
||||
@@ -2395,6 +2395,28 @@
|
||||
"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": "การนำเข้ารายชื่อติดต่อต้องเชื่อมต่อกับเซิร์ฟเวอร์ โปรดลองอีกครั้งเมื่อคุณกลับมาออนไลน์",
|
||||
"share_confirm_title": "เริ่มข้อความใหม่หรือไม่",
|
||||
"share_confirm_message": "เว็บไซต์หรือแอปอื่นต้องการเริ่มข้อความ จะไม่มีการอัปโหลดหรือบันทึกสิ่งใดหากคุณไม่ยินยอม",
|
||||
"share_confirm_subject": "หัวเรื่อง:",
|
||||
"share_confirm_text": "ข้อความ:",
|
||||
"share_confirm_files": "{count, plural, other {ไฟล์:}}",
|
||||
"share_confirm_accept": "เริ่มข้อความ",
|
||||
"share_confirm_decline": "ไม่เริ่ม",
|
||||
"share_cut": "ข้อความที่แชร์ยาวเกินไปจึงถูกตัดให้สั้นลง",
|
||||
"handoff_expired": "สิ่งที่คุณเปิดหรือแชร์รอนานเกินไปและไม่อยู่แล้ว โปรดเปิดหรือแชร์อีกครั้ง",
|
||||
"compose_waiting": "ปิดข้อความนี้เพื่อเริ่มข้อความถัดไป",
|
||||
"save_first_failed": "บันทึกข้อความไม่ได้ จึงยังไม่เปิดไฟล์ โปรดบันทึกข้อความแล้วเปิดปฏิทินหรือรายชื่อติดต่อ"
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "แอปพลิเคชันแถบด้านข้าง",
|
||||
"add_new": "เพิ่มแอป",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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.",
|
||||
"share_confirm_title": "Yeni bir ileti başlatılsın mı?",
|
||||
"share_confirm_message": "Başka bir site veya uygulama ileti başlatmak istiyor. Siz kabul etmedikçe hiçbir şey yüklenmez veya kaydedilmez.",
|
||||
"share_confirm_subject": "Konu:",
|
||||
"share_confirm_text": "Metin:",
|
||||
"share_confirm_files": "{count, plural, one {Dosya:} other {Dosyalar:}}",
|
||||
"share_confirm_accept": "İletiyi başlat",
|
||||
"share_confirm_decline": "Başlatma",
|
||||
"share_cut": "Paylaşılan metin çok uzundu ve kısaltıldı.",
|
||||
"handoff_expired": "Açtığınız veya paylaştığınız bir şey çok uzun süre bekledi ve artık yok. Lütfen yeniden açın veya paylaşın.",
|
||||
"compose_waiting": "Sonrakini başlatmak için bu iletiyi kapatın.",
|
||||
"save_first_failed": "İletiniz kaydedilemedi, bu yüzden dosya açılmadı. İletiyi kaydedin, ardından takvimi veya kişileri açın."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Kenar çubuğu uygulamaları",
|
||||
"add_new": "Uygulama ekle",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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": "Для імпорту контактів потрібне підключення до сервера. Спробуйте ще раз, коли знову будете в мережі.",
|
||||
"share_confirm_title": "Почати новий лист?",
|
||||
"share_confirm_message": "Інший сайт або застосунок хоче почати лист. Без вашої згоди нічого не буде завантажено чи збережено.",
|
||||
"share_confirm_subject": "Тема:",
|
||||
"share_confirm_text": "Текст:",
|
||||
"share_confirm_files": "{count, plural, one {Файл:} few {Файли:} many {Файли:} other {Файли:}}",
|
||||
"share_confirm_accept": "Почати лист",
|
||||
"share_confirm_decline": "Не починати",
|
||||
"share_cut": "Отриманий текст був задовгий, тому його скорочено.",
|
||||
"handoff_expired": "Те, що ви відкрили або чим поділилися, чекало надто довго і більше недоступне. Відкрийте або поділіться ще раз.",
|
||||
"compose_waiting": "Закрийте цей лист, щоб почати наступний.",
|
||||
"save_first_failed": "Не вдалося зберегти лист, тому файл не відкрито. Збережіть лист, а потім відкрийте календар або контакти."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Програми бічної панелі",
|
||||
"add_new": "Додати додаток",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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.",
|
||||
"share_confirm_title": "Bắt đầu thư mới?",
|
||||
"share_confirm_message": "Một trang web hoặc ứng dụng khác muốn bắt đầu một thư. Sẽ không có gì được tải lên hoặc lưu nếu bạn không đồng ý.",
|
||||
"share_confirm_subject": "Chủ đề:",
|
||||
"share_confirm_text": "Nội dung:",
|
||||
"share_confirm_files": "{count, plural, other {Tệp:}}",
|
||||
"share_confirm_accept": "Bắt đầu thư",
|
||||
"share_confirm_decline": "Không bắt đầu",
|
||||
"share_cut": "Văn bản được chia sẻ quá dài và đã được rút ngắn.",
|
||||
"handoff_expired": "Nội dung bạn đã mở hoặc chia sẻ đã chờ quá lâu và không còn nữa. Vui lòng mở hoặc chia sẻ lại.",
|
||||
"compose_waiting": "Đóng thư này để bắt đầu thư tiếp theo.",
|
||||
"save_first_failed": "Không thể lưu thư của bạn nên tệp chưa được mở. Hãy lưu thư, rồi mở lịch hoặc danh bạ."
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Ứng dụng thanh bên",
|
||||
"add_new": "Thêm ứng dụng",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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": "匯入聯絡人需要連線到伺服器。請在恢復連線後再試一次。",
|
||||
"share_confirm_title": "要開始撰寫新郵件嗎?",
|
||||
"share_confirm_message": "另一個網站或應用程式想要開始撰寫郵件。除非您同意,否則不會上傳或儲存任何內容。",
|
||||
"share_confirm_subject": "主旨:",
|
||||
"share_confirm_text": "內文:",
|
||||
"share_confirm_files": "{count, plural, other {檔案:}}",
|
||||
"share_confirm_accept": "開始撰寫郵件",
|
||||
"share_confirm_decline": "不要開始",
|
||||
"share_cut": "分享的文字太長,已被縮短。",
|
||||
"handoff_expired": "您開啟或分享的內容等待太久,已不存在。請重新開啟或分享。",
|
||||
"compose_waiting": "請關閉此郵件以開始下一封。",
|
||||
"save_first_failed": "無法儲存您的郵件,因此未開啟檔案。請儲存郵件,然後開啟行事曆或聯絡人。"
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "側邊欄應用程式",
|
||||
"add_new": "新增應用程式",
|
||||
|
||||
@@ -249,6 +249,28 @@
|
||||
"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": "导入联系人需要连接到服务器。请在恢复联网后重试。",
|
||||
"share_confirm_title": "要开始写新邮件吗?",
|
||||
"share_confirm_message": "另一个网站或应用想要开始写一封邮件。除非您同意,否则不会上传或保存任何内容。",
|
||||
"share_confirm_subject": "主题:",
|
||||
"share_confirm_text": "正文:",
|
||||
"share_confirm_files": "{count, plural, other {文件:}}",
|
||||
"share_confirm_accept": "开始写邮件",
|
||||
"share_confirm_decline": "不开始",
|
||||
"share_cut": "分享的文本太长,已被缩短。",
|
||||
"handoff_expired": "您打开或分享的内容等待时间过长,已不存在。请重新打开或分享。",
|
||||
"compose_waiting": "请关闭此邮件以开始下一封。",
|
||||
"save_first_failed": "无法保存您的邮件,因此未打开文件。请保存邮件,然后打开日历或联系人。"
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "侧边栏应用",
|
||||
"add_new": "添加应用",
|
||||
|
||||
+145
-2
@@ -62,7 +62,7 @@ self.addEventListener("install", (event) => {
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(Promise.all([self.clients.claim(), deleteStaleShellCaches()]));
|
||||
event.waitUntil(Promise.all([self.clients.claim(), deleteStaleShellCaches(), sweepHandoffsOnActivate()]));
|
||||
});
|
||||
|
||||
// ---- Offline mode (#523): the app shell ----
|
||||
@@ -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) {
|
||||
@@ -314,7 +314,150 @@ 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 and limits as lib/launch-handoff.ts, which also removes them). Any
|
||||
// site can POST here, signed in or not, and the worker can't tell who did: the
|
||||
// page asks the user. So what is kept is limited, and sealed with a key that
|
||||
// only the landing address's fragment knows. A browser without share_target
|
||||
// gets the same question. Without a worker the POST reaches
|
||||
// app/(main)/protocol/share/route.ts.
|
||||
const HANDOFF_CACHE = "bulwark-launch-handoff-v1";
|
||||
const HANDOFF_TTL_MS = 15 * 60 * 1000;
|
||||
const HANDOFF_MAX_ENTRIES = 5;
|
||||
const HANDOFF_MAX_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
function isShareTarget(request) {
|
||||
// A document only: a hidden frame doesn't get to store anything.
|
||||
if (request.method !== "POST" || request.mode !== "navigate" || request.destination !== "document") return false;
|
||||
const url = new URL(request.url);
|
||||
return url.origin === self.location.origin && url.pathname.replace(/\/+$/, "") === BASE_PATH + "/protocol/share";
|
||||
}
|
||||
|
||||
/** Drops what is too old and, when `incomingBytes` wants in, the oldest entries in the way. */
|
||||
async function sweepHandoffs(cache, incomingBytes) {
|
||||
const keys = await cache.keys();
|
||||
const pathOf = (key) => new URL(key.url, self.location.origin).pathname;
|
||||
const entries = [];
|
||||
for (const key of keys) {
|
||||
const path = pathOf(key);
|
||||
if (!path.endsWith("/meta")) continue;
|
||||
const response = await cache.match(key);
|
||||
const meta = response ? await response.json().catch(() => null) : null;
|
||||
entries.push({ prefix: path.slice(0, -"meta".length), createdAt: (meta && meta.createdAt) || 0, bytes: (meta && meta.bytes) || 0 });
|
||||
}
|
||||
entries.sort((a, b) => a.createdAt - b.createdAt);
|
||||
let total = entries.reduce((sum, entry) => sum + entry.bytes, 0);
|
||||
while (entries.length > 0) {
|
||||
const tooOld = Date.now() - entries[0].createdAt > HANDOFF_TTL_MS;
|
||||
const inTheWay = incomingBytes !== null && (entries.length >= HANDOFF_MAX_ENTRIES || total + incomingBytes > HANDOFF_MAX_BYTES);
|
||||
if (!tooOld && !inTheWay) break;
|
||||
const oldest = entries.shift();
|
||||
total -= oldest.bytes;
|
||||
const own = keys.filter((key) => pathOf(key).startsWith(oldest.prefix));
|
||||
// The list last: this sweep goes by it.
|
||||
await Promise.all(own.filter((key) => !pathOf(key).endsWith("/meta")).map((key) => cache.delete(key)));
|
||||
await cache.delete(oldest.prefix + "meta");
|
||||
}
|
||||
}
|
||||
|
||||
async function sweepHandoffsOnActivate() {
|
||||
try {
|
||||
if (typeof caches === "undefined" || !(await caches.has(HANDOFF_CACHE))) return;
|
||||
await sweepHandoffs(await caches.open(HANDOFF_CACHE), null);
|
||||
} catch (_) {
|
||||
// Tried again at the next share.
|
||||
}
|
||||
}
|
||||
|
||||
/** A 12-byte IV, then the AES-GCM ciphertext. */
|
||||
async function sealHandoff(key, data) {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const body = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, data));
|
||||
const sealed = new Uint8Array(iv.length + body.length);
|
||||
sealed.set(iv);
|
||||
sealed.set(body, iv.length);
|
||||
return sealed;
|
||||
}
|
||||
|
||||
/** As much of `value` as takes up to `budget` bytes once encoded, as in the server's fallback. */
|
||||
function fitEncoded(value, budget, flags) {
|
||||
let used = 0;
|
||||
let out = "";
|
||||
for (const character of value) {
|
||||
try {
|
||||
used += encodeURIComponent(character).length;
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
if (used > budget) {
|
||||
flags.cut = true;
|
||||
return out;
|
||||
}
|
||||
out += character;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function receiveShare(request) {
|
||||
// In the fragment: it stays out of access logs, and the key out of the server's sight.
|
||||
const land = (params) => Response.redirect(self.location.origin + BASE_PATH + "/#" + new URLSearchParams(params), 303);
|
||||
let form;
|
||||
try {
|
||||
form = await request.formData();
|
||||
} 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 + "/";
|
||||
const bytes = files.reduce((sum, file) => sum + file.size, 0);
|
||||
let cache = null;
|
||||
try {
|
||||
if (bytes > HANDOFF_MAX_BYTES) throw new Error("too large");
|
||||
cache = await caches.open(HANDOFF_CACHE);
|
||||
await sweepHandoffs(cache, bytes);
|
||||
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt"]);
|
||||
const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key));
|
||||
const contents = JSON.stringify({ ...shared, files: files.map((file) => ({ name: file.name, type: file.type })) });
|
||||
// The list first: the sweep goes by it.
|
||||
await cache.put(prefix + "meta", new Response(JSON.stringify({ kind: "compose", createdAt: Date.now(), bytes })));
|
||||
await cache.put(prefix + "sealed", new Response(await sealHandoff(key, new TextEncoder().encode(contents))));
|
||||
for (let index = 0; index < files.length; index += 1) {
|
||||
await cache.put(prefix + index, new Response(await sealHandoff(key, await files[index].arrayBuffer())));
|
||||
}
|
||||
const encodedKey = btoa(String.fromCharCode(...rawKey)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
return land({ share: id, key: encodedKey });
|
||||
} catch (_) {
|
||||
// No room for the files. The text can still come along in the address.
|
||||
if (cache) await Promise.all([...files.map((_file, index) => cache.delete(prefix + index)), cache.delete(prefix + "sealed"), cache.delete(prefix + "meta")]).catch(() => {});
|
||||
const flags = { cut: false };
|
||||
const title = fitEncoded(shared.title, 400, flags);
|
||||
const text = fitEncoded(shared.text, 1800, flags);
|
||||
const link = fitEncoded(shared.url, 1000, flags);
|
||||
return land({
|
||||
share: "unavailable",
|
||||
share_title: title,
|
||||
share_text: text,
|
||||
share_url: link,
|
||||
...(files.length > 0 ? { share_files: "1" } : {}),
|
||||
...(flags.cut ? { share_cut: "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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user