fix: ask before a share starts a message and save nothing until the first edit

Any site could POST a form to the share target on load: with no clicks
the composer opened with its subject and file, and autosave stored a
real draft on the server 2 seconds later. The worker can't see where a
POST came from, and Chromium's link capturing accepts form submissions,
so an installed-app window proves nothing either.

- every share landing now asks first, in every window and browser; a
  text landing address gets the same question. Nothing goes up before
  the answer.
- handed-in text and files are the composer's starting state: no
  autosave, page-close save or sign-out flush before the first real
  edit.
- the worker ignores frames, keeps at most 5 entries and 100 MB for 15
  minutes, sweeps on activate, on each share and from a page timer,
  and seals what it keeps with a key passed in the landing fragment.
  Offline wipes clear it too.
- the server fallback uses the fragment and caps by encoded length:
  3000 Japanese characters made a 27 KB `Location` and a 431.
- a second compose handoff waits until the composer closes, also one
  set aside by reading a message, where it used to replace the first.
- a launch leaving the mail page saves what was just typed first.
- an expired handoff or a missing stored file now says so; shares get
  the control-character stripping and limits of `parseMailto`.

Existing tests changed because the landing moved from the query to the
fragment, stored entries gained a sealed part, and a share now asks
first.
This commit is contained in:
Linus Rath
2026-11-03 07:08:38 +01:00
parent c021031642
commit f716ea66ee
46 changed files with 1928 additions and 298 deletions
+36 -13
View File
@@ -1,8 +1,9 @@
/**
* The manifest's share_target, when no service worker took it (public/sw.js
* normally answers first): the first visit, a browser without one, or a worker
* from before this existed. Nothing keeps files here, so the text and links go
* on in the address and the page says the files are gone
* from before this existed. Nothing keeps files here, so text and links go on
* in the address's fragment, which stays out of access logs, and the page asks
* the user and says the files are gone
* (components/protocol/protocol-launch-handler-provider.tsx).
*
* Open to any origin, like a mailto: link: it writes nothing and acts as nobody.
@@ -12,11 +13,32 @@ const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
// A form with text and links is small. Anything bigger carries files, and
// isn't read into memory just to throw them away.
const MAX_READ_BYTES = 256 * 1024;
// Encoded lengths: a proxy in front has to pass the Location header, and one
// Japanese character takes 9 bytes in it.
const BUDGET = { title: 400, url: 1000, text: 1800 };
export const dynamic = "force-dynamic";
function land(query: string): Response {
return new Response(null, { status: 303, headers: { Location: `${BASE_PATH}/${query}` } });
function land(fragment: string): Response {
return new Response(null, { status: 303, headers: { Location: `${BASE_PATH}/${fragment}` } });
}
/** As much of `value` as takes up to `budget` bytes once encoded, whole characters only. */
function fitEncoded(value: string, budget: number): { value: string; cut: boolean } {
let used = 0;
let out = "";
for (const character of value) {
let size: number;
try {
size = encodeURIComponent(character).length;
} catch {
continue; // A lone surrogate.
}
used += size;
if (used > budget) return { value: out, cut: true };
out += character;
}
return { value: out, cut: false };
}
export async function POST(request: Request): Promise<Response> {
@@ -24,25 +46,26 @@ export async function POST(request: Request): Promise<Response> {
const params = new URLSearchParams({ share: "unavailable" });
if (!Number.isFinite(length) || length <= 0 || length > MAX_READ_BYTES) {
params.set("share_files", "1");
return land(`?${params}`);
return land(`#${params}`);
}
try {
const form = await request.formData();
const field = (name: string, max: number) => {
let cut = false;
for (const name of ["title", "text", "url"] as const) {
const value = form.get(name);
if (typeof value === "string" && value) params.set(`share_${name}`, value.slice(0, max));
};
// Short enough for what proxies in front allow in an address.
field("title", 300);
field("text", 4000);
field("url", 2000);
if (typeof value !== "string" || !value) continue;
const fitted = fitEncoded(value, BUDGET[name]);
params.set(`share_${name}`, fitted.value);
cut ||= fitted.cut;
}
if (cut) params.set("share_cut", "1");
if (form.getAll("files").some((file) => typeof file !== "string" && file.size > 0)) {
params.set("share_files", "1");
}
} catch {
params.set("share_files", "1");
}
return land(`?${params}`);
return land(`#${params}`);
}
/** Nothing to share: the app. */
@@ -241,6 +241,68 @@ describe('files handed to a new composer (a share, a launch)', () => {
const uploading = () => filesClient({ uploadBlob: vi.fn(() => new Promise(() => {})), getMaxSizeUpload: () => 0 });
const handedIn = (extra: Record<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' })]} />);
+47 -6
View File
@@ -217,6 +217,12 @@ type SuggestionItem = { name: string; email: string; group?: { id: string; membe
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;
@@ -1770,6 +1776,16 @@ export function EmailComposer({
}
changeVersionRef.current += 1;
}, [toStr, ccStr, bccStr, subject, body, attachments.length]);
// Handed-in text and files aren't the user's until they touch them. Nothing
// counts as dirty until then, so autosave, the page-close save and the flush
// before a sign-out all leave them alone.
const uneditedRef = useRef(initialData?.unedited === true);
const [firstEditSeen, setFirstEditSeen] = useState(false);
const markEdited = useCallback(() => {
if (!uneditedRef.current) return;
uneditedRef.current = false;
setFirstEditSeen(true);
}, []);
const isDirtyRef = useRef(false);
isDirtyRef.current = toStr !== initialValuesRef.current.to || ccStr !== initialValuesRef.current.cc ||
bccStr !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject ||
@@ -1781,6 +1797,19 @@ export function EmailComposer({
if (priority !== initialPriorityRef.current) isDirtyRef.current = true;
const initialLabelChoiceRef = useRef(classification.choice);
if (classification.choice !== initialLabelChoiceRef.current) isDirtyRef.current = true;
if (uneditedRef.current) isDirtyRef.current = false;
// Changes only the user makes, also without typing: a picked recipient, a
// removed attachment, a priority. Handed-in files move the count they're
// compared with (addFiles). The body isn't here, a signature can change it;
// typing in it comes through the composer root's input events.
useEffect(() => {
if (!uneditedRef.current) return;
const start = initialValuesRef.current;
if (toStr !== start.to || ccStr !== start.cc || bccStr !== start.bcc || subject !== start.subject
|| attachments.length !== start.attachmentCount || priority !== initialPriorityRef.current) {
markEdited();
}
}, [toStr, ccStr, bccStr, subject, attachments.length, priority, markEdited]);
const firstLabelRunRef = useRef(true);
useEffect(() => {
if (firstLabelRunRef.current) {
@@ -1837,7 +1866,9 @@ export function EmailComposer({
useEffect(() => {
return () => {
if (explicitCloseRef.current) return;
if (onSaveState && isDirtyRef.current) {
// Kept in the host's memory across a remount, handed-in or not. Only
// the server stays out of it.
if (onSaveState && (isDirtyRef.current || uneditedRef.current)) {
const s = stateRef.current;
onSaveState({
...s,
@@ -1848,7 +1879,8 @@ export function EmailComposer({
classification: classificationRef.current.draftChoice,
// Edits the last save didn't cover; a minimised composer with
// none needs no save before an automatic sign-out.
unsaved: changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current,
unsaved: !uneditedRef.current && (changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current),
...(uneditedRef.current ? { unedited: true } : {}),
});
}
};
@@ -2304,7 +2336,7 @@ export function EmailComposer({
return () => window.removeEventListener('keydown', handleTemplateKey);
}, [templatesEnabled]);
const addFiles = useCallback(async (files: File[]) => {
const addFiles = useCallback(async (files: File[], opts?: { handedIn?: boolean }) => {
if (!client || files.length === 0) return;
// Let plugins veto each upload before it's queued.
@@ -2358,6 +2390,8 @@ export function EmailComposer({
abortController: controller,
};
});
// Files handed in are where the composer starts, not an edit.
if (opts?.handedIn) initialValuesRef.current.attachmentCount += newAttachments.length;
setAttachments(prev => [...prev, ...newAttachments]);
for (let i = 0; i < files.length; i++) {
@@ -2474,7 +2508,7 @@ export function EmailComposer({
useEffect(() => {
if (!client || !initialFiles || initialFiles.length === 0 || attachedInitialFiles.has(initialFiles)) return;
attachedInitialFiles.add(initialFiles);
void addFiles(initialFiles);
void addFiles(initialFiles, { handedIn: true });
}, [client, initialFiles, addFiles]);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -2930,7 +2964,7 @@ export function EmailComposer({
// login page can say a draft wasn't saved.
useEffect(() => registerComposeFlush(async () => {
while (isSendingRef.current) await new Promise((resolve) => setTimeout(resolve, SEND_WAIT_POLL_MS));
if (sentRef.current) return;
if (sentRef.current || uneditedRef.current) return;
const unsaved = changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current;
if (!unsaved) return;
if (saveTimeoutRef.current) {
@@ -3283,7 +3317,7 @@ export function EmailComposer({
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- saveDraft reads current state when called, not when effect is set up
}, [toStr, ccStr, bccStr, subject, body, attachments, priority]);
}, [toStr, ccStr, bccStr, subject, body, attachments, priority, firstEditSeen]);
useEffect(() => {
return () => {
@@ -4290,6 +4324,13 @@ export function EmailComposer({
data-testid="email-composer"
className={cn("flex h-full bg-background relative", className)}
onKeyDownCapture={handleComposerKeyDown}
// Bubble phase, in the same batch as the field's own onChange. A state
// change in the capture phase renders first and puts the old value back.
onInput={markEdited}
onChange={markEdited}
onPaste={markEdited}
onCut={markEdited}
onDrop={markEdited}
>
<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>}
+61 -28
View File
@@ -142,7 +142,7 @@ import { usePluginStore } from "@/stores/plugin-store";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { canDownload } from "@/lib/device-policy";
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
import { subscribeHandoff, takeHandoff } from "@/lib/launch-handoff";
import { pendingHandoffs, subscribeHandoff, takeHandoff, type Handoff } from "@/lib/launch-handoff";
import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from "@/lib/protocol-handlers/mailto";
import { PLUGIN_REPLY_EVENT, type PluginReplyRequest } from "@/lib/plugin-sandbox/host-api-compose";
import { onOpenEmail, type OpenEmailHandler, type OpenEmailOutcome } from "@/lib/plugin-sandbox/open-email";
@@ -232,7 +232,7 @@ const UNIFIED_LIST_PUSH = {
};
/** A draft from outside: a mailto:, or a share with files. ParsedMailto stays JSON, for sessionStorage. */
type DraftRequest = ParsedMailto & { initialFiles?: File[] };
type DraftRequest = ParsedMailto & { initialFiles?: File[]; unedited?: boolean };
export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Static Lite build: the route params are empty, read the link from the URL.
@@ -1479,7 +1479,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
suppressComposerStateSaveSessionRef.current = composerSessionId;
}
setComposerSessionId((id) => id + 1);
setPendingDraft(mailtoComposerDraft(pending, useSettingsStore.getState().plainTextMode));
setPendingDraft({
...mailtoComposerDraft(pending, useSettingsStore.getState().plainTextMode),
...(pending.unedited ? { unedited: true } : {}),
});
setComposerInitialFiles(pending.initialFiles ?? null);
setComposerMode("compose");
setShowComposer(true);
@@ -1530,7 +1533,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}, []);
// A draft that comes from outside: which account it goes out from.
const routeOutsideDraft = useCallback((pending: DraftRequest) => {
const routeOutsideDraft = useCallback(async (pending: DraftRequest): Promise<void> => {
const protocolAccounts = getMailtoProtocolAccounts();
if (protocolAccounts.length > 1) {
setPendingMailtoAccountChoice(pending);
@@ -1539,11 +1542,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const accountId = protocolAccounts[0]?.id ?? activeAccountId;
if (accountId) {
void openMailtoForAccount(pending, accountId);
await openMailtoForAccount(pending, accountId);
return;
}
openMailtoDraft(pending);
await openMailtoDraft(pending);
}, [activeAccountId, getMailtoProtocolAccounts, openMailtoDraft, openMailtoForAccount]);
const handleMailtoProtocolRequest = useCallback(async (pending: ParsedMailto) => {
@@ -1553,7 +1556,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
openMailtoDraft(pending);
return;
}
routeOutsideDraft(pending);
void routeOutsideDraft(pending);
}, [mailtoAllowed, openMailtoDraft, routeOutsideDraft]);
// A new draft from a shortcut, a share or the desktop app
@@ -1561,35 +1564,65 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// it; onBeforeCompose still runs. The standard page that renders while the
// Pro shell takes over must leave it for the Pro one.
const proTakingOver = proInterfaceActive && !isEmbedded;
// One at a time. A composer that is open holds the user's text, so what comes
// next stays queued until it closes, and a second one can't replace the
// first. So does one set aside by reading a message, while the server hasn't
// got what it holds. Under Pro a composer moves to its own tab, so nothing
// waits long.
const handoffOpeningRef = useRef(false);
const handoffToldWaitingRef = useRef(new Set<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 openHandoff = () => {
for (let handoff = takeHandoff('compose'); handoff; handoff = takeHandoff('compose')) {
if (!handoff.subject && !handoff.body && handoff.files.length === 0) {
void withComposeGate('compose', {}, () => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
});
continue;
}
routeOutsideDraft({
to: [],
cc: [],
bcc: [],
subject: handoff.subject ?? '',
body: handoff.body ?? '',
initialFiles: handoff.files,
const open = async (handoff: Handoff) => {
if (!handoff.subject && !handoff.body && handoff.files.length === 0) {
await withComposeGate('compose', {}, () => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
});
return;
}
await routeOutsideDraft({
to: [],
cc: [],
bcc: [],
subject: handoff.subject ?? '',
body: handoff.body ?? '',
initialFiles: handoff.files,
unedited: true,
});
};
openHandoff();
return subscribeHandoff('compose', openHandoff);
const openNext = () => {
if (handoffOpeningRef.current) return;
if (composerInUseRef.current) {
const waiting = pendingHandoffs().find((handoff) => handoff.kind === 'compose');
if (waiting && !handoffToldWaitingRef.current.has(waiting.id)) {
handoffToldWaitingRef.current.add(waiting.id);
toast.info(t('pwaLaunch.compose_waiting'));
}
return;
}
const handoff = takeHandoff('compose');
if (!handoff) return;
handoffOpeningRef.current = true;
void open(handoff).finally(() => {
handoffOpeningRef.current = false;
setHandoffSettled((n) => n + 1);
});
};
openNext();
return subscribeHandoff('compose', openNext);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated, client, proTakingOver, routeOutsideDraft, withComposeGate, isMobile]);
}, [isAuthenticated, client, proTakingOver, routeOutsideDraft, withComposeGate, isMobile, composerInUse, handoffSettled]);
useEffect(() => {
if (!isAuthenticated || !client) return;
@@ -1,4 +1,4 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FakeCaches } from '@/lib/__tests__/sw-harness';
@@ -10,6 +10,11 @@ const hoisted = vi.hoisted(() => ({
push: vi.fn(),
pathname: { current: '/' },
toastError: vi.fn(),
toastInfo: vi.fn(),
flushComposers: vi.fn(async () => ({ total: 0, settled: 0, failed: 0, timedOut: false })),
peek: vi.fn(),
adopt: vi.fn(),
discard: vi.fn(async () => {}),
savePendingMailto: vi.fn(),
protocolOpenMode: 'active-session',
}));
@@ -19,7 +24,14 @@ vi.mock('@/i18n/navigation', () => ({
usePathname: () => hoisted.pathname.current,
}));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Bulwark' }) }));
vi.mock('@/stores/toast-store', () => ({ toast: { error: hoisted.toastError, info: vi.fn(), success: vi.fn() } }));
vi.mock('@/stores/toast-store', () => ({ toast: { error: hoisted.toastError, info: hoisted.toastInfo, success: vi.fn() } }));
vi.mock('@/lib/compose-flush-registry', () => ({ flushComposers: hoisted.flushComposers }));
vi.mock('@/lib/launch-handoff', async (importOriginal) => ({
...(await importOriginal<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 }) }),
}));
@@ -50,7 +62,7 @@ vi.mock('@/components/files/file-preview-modal', () => ({
},
}));
const { clearHandoffs, pendingHandoffs, takeHandoff } = await import('@/lib/launch-handoff');
const { clearHandoffs, handOffFiles, pendingHandoffs, takeHandoff, HANDOFF_TTL_MS } = await import('@/lib/launch-handoff');
const { setDownloadGuard } = await import('@/lib/download-guard');
const { ProtocolLaunchHandlerProvider } = await import('../protocol-launch-handler-provider');
@@ -70,6 +82,12 @@ beforeEach(() => {
setConsumer.mockClear();
hoisted.push.mockClear();
hoisted.toastError.mockClear();
hoisted.toastInfo.mockClear();
hoisted.flushComposers.mockReset();
hoisted.flushComposers.mockResolvedValue({ total: 0, settled: 0, failed: 0, timedOut: false });
hoisted.peek.mockReset();
hoisted.adopt.mockReset();
hoisted.discard.mockClear();
hoisted.savePendingMailto.mockClear();
hoisted.pathname.current = '/';
hoisted.protocolOpenMode = 'active-session';
@@ -212,6 +230,7 @@ describe('launches that are not files', () => {
it('goes to the calendar from its shortcut in an open window', async () => {
mount();
consumer!({ targetURL: `${window.location.origin}/calendar` });
await flush();
expect(hoisted.push).toHaveBeenCalledWith('/calendar');
});
@@ -238,6 +257,51 @@ describe('launches that are not files', () => {
});
});
describe('leaving a composer for a launched file', () => {
it('saves what was typed first, then goes', async () => {
const order: string[] = [];
hoisted.flushComposers.mockImplementation(async () => { order.push('flush'); return { total: 1, settled: 1, failed: 0, timedOut: false }; });
hoisted.push.mockImplementation(() => { order.push('push'); });
mount();
consumer!({ files: [handle('people.vcf')] });
await flush();
expect(order).toEqual(['flush', 'push']);
hoisted.push.mockReset();
});
it.each([
['the server refused the save', { total: 1, settled: 1, failed: 1, timedOut: false }],
['the save took too long', { total: 1, settled: 0, failed: 0, timedOut: true }],
])('stays when %s, with the file still waiting', async (_name, result) => {
hoisted.flushComposers.mockResolvedValue(result);
mount();
consumer!({ files: [handle('people.vcf')] });
await flush();
expect(hoisted.push).not.toHaveBeenCalled();
expect(hoisted.toastError).toHaveBeenCalledWith('save_first_failed');
expect(pendingHandoffs().map((h) => h.kind)).toEqual(['contacts']);
});
it('does not save for a .eml, which leaves nothing', async () => {
mount();
consumer!({ files: [handle('note.eml')] });
await screen.findByTestId('eml-viewer');
expect(hoisted.flushComposers).not.toHaveBeenCalled();
});
});
describe('a handoff that goes without arriving', () => {
it('says so', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
mount();
handOffFiles([new File(['x'], 'a.ics')], 'calendar');
vi.advanceTimersByTime(HANDOFF_TTL_MS + 1);
takeHandoff('calendar');
vi.useRealTimers();
expect(hoisted.toastError).toHaveBeenCalledWith('handoff_expired');
});
});
describe('the address a launch lands on', () => {
it('opens a new message once and removes the parameter', async () => {
window.history.replaceState(null, '', '/?compose=1&slot=2#x');
@@ -256,21 +320,100 @@ describe('the address a launch lands on', () => {
expect(takeHandoff('compose')).not.toBeNull();
expect(takeHandoff('compose')).toBeNull();
});
});
it('turns text in the address into a draft and says the files were lost', async () => {
window.history.replaceState(null, '', '/?share=unavailable&share_title=T&share_text=B&share_files=1');
describe('a share from an address anyone can make', () => {
const dialogText = () => screen.getByTestId('share-confirm-details').textContent ?? '';
const agree = () => fireEvent.click(screen.getByRole('button', { name: 'share_confirm_accept' }));
const refuse = () => fireEvent.click(screen.getByRole('button', { name: 'share_confirm_decline' }));
it('asks about text in the address, and starts nothing before the answer', async () => {
window.history.replaceState(null, '', '/#share=unavailable&share_title=T&share_text=B&share_files=1&share_cut=1');
mount();
expect(window.location.search).toBe('');
expect(window.location.hash).toBe('');
await screen.findByText('share_confirm_message');
expect(dialogText()).toContain('T');
expect(pendingHandoffs()).toEqual([]);
expect(hoisted.push).not.toHaveBeenCalled();
agree();
await flush();
expect(takeHandoff('compose')).toMatchObject({ subject: 'T', body: 'B' });
expect(hoisted.toastError).toHaveBeenCalledWith('share_files_lost');
expect(hoisted.toastInfo).toHaveBeenCalledWith('share_cut');
});
it('starts nothing when refused', async () => {
window.history.replaceState(null, '', '/#share=unavailable&share_text=B');
mount();
await screen.findByText('share_confirm_message');
refuse();
await flush();
expect(pendingHandoffs()).toEqual([]);
});
it('only reports lost files when there was no text to ask about', async () => {
window.history.replaceState(null, '', '/#share=unavailable&share_files=1');
mount();
await flush();
expect(screen.queryByText('share_confirm_message')).toBeNull();
expect(hoisted.toastError).toHaveBeenCalledWith('share_files_lost');
expect(pendingHandoffs()).toEqual([]);
});
it('ignores it in the query, which reaches logs and any link', async () => {
window.history.replaceState(null, '', '/?share=unavailable&share_text=B');
mount();
await flush();
expect(screen.queryByText('share_confirm_message')).toBeNull();
expect(pendingHandoffs()).toEqual([]);
});
it('asks about a stored share, naming the files, before anything is taken in', async () => {
hoisted.peek.mockResolvedValue({ subject: 'S', body: '', files: ['invoice.exe'] });
hoisted.adopt.mockResolvedValue(true);
window.history.replaceState(null, '', '/#share=abc&key=k1');
mount();
expect(window.location.hash).toBe('');
await screen.findByText('share_confirm_message');
expect(hoisted.peek).toHaveBeenCalledWith('abc', 'k1');
expect(dialogText()).toContain('invoice.exe');
expect(hoisted.adopt).not.toHaveBeenCalledWith('abc', 'k1');
agree();
await waitFor(() => expect(hoisted.adopt).toHaveBeenCalledWith('abc', 'k1'));
expect(hoisted.discard).not.toHaveBeenCalled();
});
it('asks in an installed-app window as well: link capturing can bring a POST there', async () => {
const original = window.matchMedia;
window.matchMedia = ((query: string) => ({ matches: query.includes('standalone'), media: query, addEventListener: () => {}, removeEventListener: () => {} })) as never;
hoisted.peek.mockResolvedValue({ subject: 'S', body: '', files: [] });
window.history.replaceState(null, '', '/#share=abc&key=k1');
mount();
await screen.findByText('share_confirm_message');
expect(hoisted.adopt).not.toHaveBeenCalledWith('abc', 'k1');
window.matchMedia = original;
});
it('drops a stored share when refused', async () => {
hoisted.peek.mockResolvedValue({ subject: 'S', body: '', files: ['invoice.exe'] });
window.history.replaceState(null, '', '/#share=abc&key=k1');
mount();
await screen.findByText('share_confirm_message');
refuse();
await waitFor(() => expect(hoisted.discard).toHaveBeenCalledWith('abc'));
expect(hoisted.adopt).not.toHaveBeenCalledWith('abc', 'k1');
});
it('says so when what was shared is gone', async () => {
window.history.replaceState(null, '', '/?share=abc');
hoisted.peek.mockResolvedValue(null);
window.history.replaceState(null, '', '/#share=abc&key=k1');
mount();
expect(window.location.search).toBe('');
expect(window.location.hash).toBe('');
await flush();
expect(hoisted.toastError).toHaveBeenCalledWith('share_gone');
expect(screen.queryByText('share_confirm_message')).toBeNull();
});
});
@@ -1,23 +1,29 @@
"use client";
import { useEffect, useRef } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { useTranslations } from "next-intl";
import { usePathname, useRouter } from "@/i18n/navigation";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfig } from "@/hooks/use-config";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { flushComposers } from "@/lib/compose-flush-registry";
import { getPathPrefix } from "@/lib/browser-navigation";
import { matchSurface, type AppSurface } from "@/lib/deep-links";
import {
adoptStoredHandoff,
discardStoredHandoff,
handOffDraft,
handOffLaunchFiles,
handOffNewMessage,
parseLaunchLanding,
peekStoredHandoff,
pendingHandoffs,
restoreStoredHandoffs,
shortcutFromTargetUrl,
stripLandingParams,
subscribeHandoff,
subscribeHandoffLost,
type HandoffKind,
} from "@/lib/launch-handoff";
import { productName } from "@/lib/product-name";
@@ -77,6 +83,14 @@ const HANDOFF_TARGET: Partial<Record<HandoffKind, { surface: AppSurface; path: s
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;
@@ -101,32 +115,79 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle
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 landing = parseLaunchLanding(window.location.search);
const stopLossNotes = subscribeHandoffLost((loss) => {
toast.error(tLaunch(loss === "missing" ? "share_files_lost" : "handoff_expired"));
});
const { search, hash, pathname: path } = window.location;
const landing = parseLaunchLanding(search, hash);
if (landing) {
window.history.replaceState(
window.history.state,
"",
window.location.pathname + stripLandingParams(window.location.search) + window.location.hash,
);
const left = stripLandingParams(search, hash);
window.history.replaceState(window.history.state, "", path + left.search + left.hash);
}
if (landing?.type === "new-message") {
handOffNewMessage();
} else if (landing?.type === "inline") {
if (landing.subject || landing.body) handOffDraft({ subject: landing.subject, body: landing.body });
if (landing.filesLost) toast.error(tLaunch("share_files_lost"));
void (async () => {
const { subject, body } = landing;
if ((subject || body) && !(await askAboutShare({ subject, body, files: [] }))) return;
if (subject || body) handOffDraft({ subject, body });
if (landing.filesLost) toast.error(tLaunch("share_files_lost"));
if (landing.cut) toast.info(tLaunch("share_cut"));
})();
} else if (landing?.type === "stored") {
void adoptStoredHandoff(landing.id).then((found) => {
if (!found) toast.error(tLaunch("share_gone"));
});
void (async () => {
const shared = await peekStoredHandoff(landing.id, landing.key);
if (!shared) {
await discardStoredHandoff(landing.id);
toast.error(tLaunch("share_gone"));
return;
}
if (!(await askAboutShare(shared))) {
await discardStoredHandoff(landing.id);
return;
}
if (!(await adoptStoredHandoff(landing.id, landing.key))) toast.error(tLaunch("share_gone"));
})();
}
void restoreStoredHandoffs();
return stopLossNotes;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Leaving the mail page ends an open composer, and its autosave waits 2
// seconds: what was just typed is saved first. If it can't be, stay. A file
// keeps waiting and opens once the user goes to that page.
const leaveFor = useCallback(async (path: "/" | "/calendar" | "/contacts") => {
const result = await flushComposers(FLUSH_BEFORE_LEAVING_MS);
if (result.failed > 0 || result.timedOut) {
toast.error(tLaunch("save_first_failed"));
return;
}
router.push(path);
}, [router, tLaunch]);
// Takes a handoff to the page that handles it, once each: on arrival, or
// when the sign-in it had to wait for is done.
useEffect(() => {
@@ -136,13 +197,13 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle
const target = HANDOFF_TARGET[handoff.kind];
if (!target || sentToTarget.current.has(handoff.id)) continue;
sentToTarget.current.add(handoff.id);
if (matchSurface(pathname)?.surface !== target.surface) router.push(target.path);
if (matchSurface(pathname)?.surface !== target.surface) void leaveFor(target.path as "/" | "/calendar" | "/contacts");
return;
}
};
goToTarget();
return subscribeHandoff("any", goToTarget);
}, [pathname, router]);
}, [pathname, leaveFor]);
useEffect(() => {
if (pathname.startsWith("/protocol/")) return;
@@ -177,7 +238,7 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle
if (!launch) {
const shortcut = shortcutFromTargetUrl(launchParams.targetURL, window.location.origin);
if (shortcut === "new-message") handOffNewMessage();
else if (shortcut && matchSurface(pathname)?.surface !== shortcut) router.push(`/${shortcut}`);
else if (shortcut && matchSurface(pathname)?.surface !== shortcut) void leaveFor(`/${shortcut}`);
return;
}
@@ -213,12 +274,23 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle
notifyPendingWebcal();
if (pathname !== "/calendar") router.push("/calendar");
});
}, [pathname, router, tLaunch]);
}, [pathname, router, tLaunch, leaveFor]);
const offeredFiles = offer?.files ?? [];
return (
<>
{children}
<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>
)}
/>
</>
);
}
+221 -6
View File
@@ -1,6 +1,7 @@
import { test, expect, type Browser, type BrowserContext, type Page } from '@playwright/test';
import { accountSwitcher, neutralizeDevOverlay } from './helpers/app';
import { ENT, ENT_PASSWORD, adminContext, contextOptions, getPolicy, putPolicy, requireEntEnv, shot } from './helpers/ent';
import { WEBMAIL_URL } from './helpers/config';
import { ENT, ENT_PASSWORD, adminContext, contextOptions, getPolicy, jmapAs, putPolicy, requireEntEnv, shot } from './helpers/ent';
/**
* The installed app's launches through the real UI: shortcuts, files opened
@@ -149,6 +150,45 @@ async function workerReady(page: Page): Promise<void> {
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;
@@ -197,6 +237,7 @@ test.describe('share target', () => {
{ name: `shared-${RUN}.txt`, type: 'text/plain', body: 'shared bytes' },
]);
await page.waitForURL((url) => url.pathname.endsWith('/') || !url.pathname.includes('protocol'));
await startMessage(page).click({ timeout: 60_000 });
await signInIfAsked(page, ENT.alice);
await expect(composer(page)).toBeVisible({ timeout: 30_000 });
@@ -205,7 +246,7 @@ test.describe('share target', () => {
await expect(composer(page).getByText(`shared-${RUN}.txt`)).toBeVisible();
// It reached the app through the worker's store, not the server's fallback.
await expect(page.getByText("The shared files couldn't be kept")).toHaveCount(0);
await expect.poll(() => new URL(page.url()).search).toBe('');
await expect.poll(() => new URL(page.url()).hash).toBe('');
await expect.poll(() => storedShareEntries(page)).toBe(0);
await shot(page, 'pwa-launch-share');
await page.context().close();
@@ -218,8 +259,9 @@ test.describe('share target', () => {
await workerReady(page);
await postShare(page, { url: 'https://example.org/waited' }, [{ name: `waited-${RUN}.txt`, type: 'text/plain', body: 'x' }]);
await startMessage(page).click({ timeout: 60_000 });
await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 });
await expect.poll(() => storedShareEntries(page)).toBe(2);
await expect.poll(() => storedShareEntries(page)).toBe(3);
// What SSO does: away and back, in the same tab.
await page.reload();
await signIn(page, ENT.alice);
@@ -234,6 +276,7 @@ test.describe('share target', () => {
const page = await signedIn(browser, { serviceWorkers: 'block' });
await postShare(page, { url: 'https://example.org/no-worker' }, [{ name: 'lost.txt', type: 'text/plain', body: 'x' }]);
await page.waitForLoadState('domcontentloaded');
await startMessage(page).click({ timeout: 60_000 });
await signInIfAsked(page, ENT.alice);
await expect(composer(page)).toBeVisible({ timeout: 30_000 });
@@ -252,10 +295,181 @@ test.describe('share target', () => {
});
expect(res.status()).toBe(303);
const location = new URL(res.headers().location, 'http://x.invalid');
expect(location.searchParams.get('share')).toBe('unavailable');
expect(location.searchParams.get('share_url')).toBe('https://example.org/direct');
// In the fragment: shared text stays out of access logs.
expect(location.search).toBe('');
const fragment = new URLSearchParams(location.hash.slice(1));
expect(fragment.get('share')).toBe('unavailable');
expect(fragment.get('share_url')).toBe('https://example.org/direct');
await context.close();
});
test('long non-Latin text fits the address the fallback sends on', async ({ browser }) => {
const context = await browser.newContext(contextOptions());
const res = await context.request.post('/protocol/share', {
multipart: { text: 'あ'.repeat(3000) },
maxRedirects: 0,
});
expect(res.status()).toBe(303);
const location = res.headers().location;
expect(location.length).toBeLessThan(4000);
expect((await context.request.get(location)).ok()).toBe(true);
await context.close();
});
});
test.describe('a share nobody asked for', () => {
test('a POST from another site leaves no draft on the server and asks first', async ({ browser }) => {
const signedInPage = await signedIn(browser);
await workerReady(signedInPage);
const subject = `hostile-${RUN}-a`;
const tab = await hostileTab(signedInPage.context(), { subject, fileName: `invoice-${RUN}.exe` });
await tab.waitForURL((url) => url.href.startsWith(WEBMAIL_URL));
// The question comes before anything else, also before a sign-in.
await expect(startMessage(tab)).toBeVisible({ timeout: 30_000 }).catch(() => {});
if (await startMessage(tab).isVisible()) {
await expect(tab.getByTestId('share-confirm-details')).toContainText(`invoice-${RUN}.exe`);
} else {
await signInIfAsked(tab, ENT.alice);
}
await tab.waitForTimeout(6000);
const drafts = await draftsWithSubject(subject);
await deleteDrafts(drafts);
expect(drafts).toEqual([]);
await expect(composer(tab)).toHaveCount(0);
await expect(startMessage(tab)).toBeVisible();
await shot(tab, 'pwa-launch-share-confirm');
// Refused: what it stored goes too.
await dontStart(tab).click();
await expect.poll(() => storedShareEntries(tab)).toBe(0);
await expect(composer(tab)).toHaveCount(0);
await signedInPage.context().close();
});
test('agreed, it is only the starting state: no draft until the first real edit', async ({ browser }) => {
const signedInPage = await signedIn(browser);
await workerReady(signedInPage);
const subject = `hostile-${RUN}-b`;
const tab = await hostileTab(signedInPage.context(), { subject, fileName: `kept-${RUN}.txt` });
await tab.waitForURL((url) => url.href.startsWith(WEBMAIL_URL));
await startMessage(tab).click({ timeout: 60_000 });
await signInIfAsked(tab, ENT.alice);
await expect(composer(tab).getByText(`kept-${RUN}.txt`)).toBeVisible({ timeout: 30_000 });
await expect(composer(tab).getByPlaceholder(/subject/i)).toHaveValue(subject);
await tab.waitForTimeout(6000);
expect(await draftsWithSubject(subject)).toEqual([]);
// Leaving doesn't save it either.
await tab.evaluate(() => { window.dispatchEvent(new Event('beforeunload')); });
await tab.waitForTimeout(3000);
expect(await draftsWithSubject(subject)).toEqual([]);
// The user's own edit: from here it is a draft like any other.
await composer(tab).getByPlaceholder(/subject/i).fill(`${subject} edited`);
await expect(composer(tab).getByPlaceholder(/subject/i)).toHaveValue(`${subject} edited`);
await expect.poll(() => draftsWithSubject(subject), { timeout: 20_000 }).toHaveLength(1);
await deleteDrafts(await draftsWithSubject(subject));
await signedInPage.context().close();
});
test('a hidden frame stores nothing', async ({ browser }) => {
const page = await (await launchContext(browser)).newPage();
await page.goto('/');
await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 });
await workerReady(page);
// From the app's own origin: another site's frame doesn't reach this worker
// at all in Chromium (partitioned storage), so only this shows the check.
await page.evaluate(() => {
const frame = document.createElement('iframe');
frame.name = 'hidden-share';
frame.style.display = 'none';
document.body.append(frame);
const form = document.createElement('form');
form.method = 'POST';
form.enctype = 'multipart/form-data';
form.action = '/protocol/share';
form.target = 'hidden-share';
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'title';
input.value = 'frame';
form.append(input);
document.body.append(form);
form.submit();
});
await page.waitForTimeout(3000);
expect(await storedShareEntries(page)).toBe(0);
await page.context().close();
});
test('text in an address anyone can link to asks first too', async ({ browser }) => {
const page = await signedIn(browser);
await page.goto(`/#share=unavailable&share_title=${encodeURIComponent(`linked-${RUN}`)}&share_text=hello`);
await expect(startMessage(page)).toBeVisible({ timeout: 30_000 });
await expect(composer(page)).toHaveCount(0);
await dontStart(page).click();
await expect(composer(page)).toHaveCount(0);
await page.context().close();
});
});
test.describe('a handoff while a composer is open', () => {
test('one set aside by reading a message is not replaced by the next', async ({ browser }) => {
const page = await signedIn(browser);
await page.locator('[data-testid="email-list-item"]').first().waitFor({ timeout: 30_000 });
const subject = `setaside${RUN}`;
await launch(page, { target: '/?compose=1' });
await expect(composer(page)).toBeVisible({ timeout: 30_000 });
await composer(page).getByPlaceholder(/subject/i).fill(subject);
await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue(subject);
// Reading a message suspends the composer before its autosave ran.
await page.locator('[data-testid="email-list-item"]').first().click();
await expect(composer(page)).toHaveCount(0);
await page.waitForTimeout(2100);
await launch(page, { target: '/?compose=1' });
await expect(page.getByText('Close this message to start the next one.').first()).toBeVisible({ timeout: 10_000 });
await expect(composer(page)).toHaveCount(0);
await expect(page.getByText(subject).first()).toBeVisible();
await page.context().close();
});
test('two at once: the second waits until the first composer closes', async ({ browser }) => {
const page = await signedIn(browser);
// Let the page settle after the sign-in: the composer shouldn't race the first mail load.
await page.waitForTimeout(3000);
await launch(page, { target: '/?compose=1' });
await expect(composer(page)).toBeVisible({ timeout: 30_000 });
const subject = `kept-open-${RUN}`;
await composer(page).getByPlaceholder(/subject/i).fill(subject);
await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue(subject);
await page.waitForTimeout(2100);
await launch(page, { target: '/?compose=1' });
await expect(page.getByText('Close this message to start the next one.').first()).toBeVisible({ timeout: 10_000 });
await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue(subject);
await page.locator('[data-testid="composer-close"]').first().click();
await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue('', { timeout: 15_000 });
await deleteDrafts(await draftsWithSubject(subject));
await page.context().close();
});
test('a .vcf launch saves what was just typed before leaving the composer', async ({ browser }) => {
const page = await signedIn(browser);
await launch(page, { target: '/?compose=1' });
await expect(composer(page)).toBeVisible({ timeout: 30_000 });
const subject = `typedfirst${RUN}`;
await composer(page).getByPlaceholder(/subject/i).fill(subject);
// Well inside the autosave's 2 seconds.
await launch(page, { target: '/contacts', files: [VCF] });
await page.waitForURL(/\/contacts/);
await expect(page.getByText(`Launched Person ${RUN}`).first()).toBeVisible({ timeout: 30_000 });
await expect.poll(() => draftsWithSubject(subject), { timeout: 10_000 }).toHaveLength(1);
await deleteDrafts(await draftsWithSubject(subject));
await page.context().close();
});
});
test.describe('file launches', () => {
@@ -365,7 +579,7 @@ test.describe('file launches', () => {
await page.goto('/');
await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 });
await launch(page, { target: '/calendar', files: [ICS] });
await expect.poll(() => storedShareEntries(page)).toBe(2);
await expect.poll(() => storedShareEntries(page)).toBe(3);
await page.reload();
await signIn(page, ENT.alice);
await page.waitForURL(/\/calendar/);
@@ -432,6 +646,7 @@ test.describe('in the Pro shell', () => {
await workerReady(page);
await postShare(page, { url: 'https://example.org/pro' }, [{ name: `pro-${RUN}.txt`, type: 'text/plain', body: 'x' }]);
await startMessage(page).click({ timeout: 60_000 });
await signInIfAsked(page, ENT.alice);
await page.waitForURL(/\/pro/);
await expect(page.getByText(`pro-${RUN}.txt`).first()).toBeVisible({ timeout: 30_000 });
+209 -29
View File
@@ -3,21 +3,27 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FakeCaches, loadWorker } from './sw-harness';
import {
HANDOFF_CACHE,
HANDOFF_MAX_BYTES,
HANDOFF_MAX_ENTRIES,
HANDOFF_TTL_MS,
adoptStoredHandoff,
clearHandoffs,
discardStoredHandoff,
expireHandoffs,
handOffDraft,
handOffFiles,
handOffLaunchFiles,
handOffNewMessage,
kindForFile,
parseLaunchLanding,
peekStoredHandoff,
pendingHandoffs,
restoreStoredHandoffs,
shareDraft,
shortcutFromTargetUrl,
stripLandingParams,
subscribeHandoff,
subscribeHandoffLost,
takeHandoff,
} from '../launch-handoff';
@@ -25,7 +31,9 @@ import {
const file = (name: string, type = '', body = 'x') => new File([body], name, { type });
const handle = (f: File) => ({ kind: 'file', getFile: async () => f });
const settle = () => new Promise((r) => setTimeout(r, 0));
// Sealing runs on Web Crypto's thread pool.
const settle = () => new Promise((r) => setTimeout(r, 60));
const whenStored = () => vi.waitFor(() => expect(sessionStorage.getItem('bulwark:launch-handoffs')).not.toBeNull(), { timeout: 5000 });
function memoryStorage() {
const backing = new Map<string, string>();
@@ -163,7 +171,7 @@ describe('across a page load', () => {
it('brings back a file that waited for a sign-in, with its name and type', async () => {
handOffFiles([file('a.ics', 'text/calendar', 'BEGIN:VCALENDAR')], 'calendar');
await settle();
await whenStored();
await newPage();
expect(takeHandoff('calendar')).toBeNull();
@@ -176,9 +184,9 @@ describe('across a page load', () => {
it('removes the stored copy once it is taken', async () => {
handOffFiles([file('a.ics')], 'calendar');
await settle();
await whenStored();
const store = await caches.open(HANDOFF_CACHE);
expect((await store.keys()).length).toBe(2);
expect((await store.keys()).length).toBe(3);
takeHandoff('calendar');
await settle();
@@ -195,29 +203,104 @@ describe('across a page load', () => {
it("leaves another window's handoff alone", async () => {
handOffFiles([file('a.ics')], 'calendar');
await settle();
await whenStored();
await newPage();
// Another window: its own sessionStorage, the same cache.
vi.stubGlobal('sessionStorage', memoryStorage());
await restoreStoredHandoffs();
expect(takeHandoff('calendar')).toBeNull();
expect((await (await caches.open(HANDOFF_CACHE)).keys()).length).toBe(2);
expect((await (await caches.open(HANDOFF_CACHE)).keys()).length).toBe(3);
});
it('keeps no readable bytes, name or text at rest', async () => {
handOffDraft({ subject: 'Secret subject', body: 'Secret text', files: [file('secret-name.txt', 'text/plain', 'secret bytes')] });
await whenStored();
const store = await caches.open(HANDOFF_CACHE);
const keys = await store.keys();
expect(keys.length).toBeGreaterThanOrEqual(3);
for (const key of keys) {
const stored = await (await store.match(key))!.text();
for (const secret of ['Secret subject', 'Secret text', 'secret-name', 'secret bytes']) expect(stored).not.toContain(secret);
}
});
it('cannot read it once the tab with the key is gone', async () => {
handOffFiles([file('a.ics')], 'calendar');
await whenStored();
await newPage();
vi.stubGlobal('sessionStorage', memoryStorage());
const store = await caches.open(HANDOFF_CACHE);
const metaKey = (await store.keys()).find((key) => key.url.endsWith('/meta'))!.url;
const id = metaKey.split('/').slice(-2)[0];
expect(await adoptStoredHandoff(id)).toBe(false);
expect(await adoptStoredHandoff(id, 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')).toBe(false);
});
it('says so when a stored handoff of this tab is gone after the reload', async () => {
const losses: string[] = [];
const off = subscribeHandoffLost((loss) => losses.push(loss));
handOffFiles([file('a.ics')], 'calendar');
await whenStored();
await newPage();
await caches.delete(HANDOFF_CACHE);
await restoreStoredHandoffs();
expect(losses).toEqual(['expired']);
expect(sessionStorage.getItem('bulwark:launch-handoffs')).toBeNull();
off();
});
it('says so when a stored file is missing, and delivers the rest', async () => {
const losses: string[] = [];
const off = subscribeHandoffLost((loss) => losses.push(loss));
handOffDraft({ subject: 'S', files: [file('one.txt'), file('two.txt')] });
await whenStored();
await newPage();
const store = await caches.open(HANDOFF_CACHE);
const first = (await store.keys()).find((key) => key.url.endsWith('/0'))!;
await store.delete(first);
await restoreStoredHandoffs();
expect(losses).toEqual(['missing']);
expect(takeHandoff('compose')?.files.map((f) => f.name)).toEqual(['two.txt']);
off();
});
it('keeps no more than the limit, oldest out first', async () => {
const created: string[] = [];
for (let n = 0; n < HANDOFF_MAX_ENTRIES + 2; n += 1) {
const name = `f${n}.ics`;
handOffFiles([file(name)], 'calendar');
created.push(name);
await settle();
await settle();
}
const store = await caches.open(HANDOFF_CACHE);
const metas = (await store.keys()).filter((key) => key.url.endsWith('/meta'));
expect(metas.length).toBeLessThanOrEqual(HANDOFF_MAX_ENTRIES);
});
it('does not store what is over the byte limit, and keeps it in memory', async () => {
const big = file('big.ics');
Object.defineProperty(big, 'size', { value: HANDOFF_MAX_BYTES + 1 });
handOffFiles([big], 'calendar');
await settle();
expect(await caches.has(HANDOFF_CACHE)).toBe(false);
expect(takeHandoff('calendar')?.files).toEqual([big]);
});
it('sweeps what nobody took, and keeps what is recent', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
handOffFiles([file('old.ics')], 'calendar');
await settle();
await whenStored();
await newPage();
vi.stubGlobal('sessionStorage', memoryStorage());
vi.advanceTimersByTime(HANDOFF_TTL_MS + 1);
handOffFiles([file('new.vcf')], 'contacts');
await settle();
await whenStored();
await restoreStoredHandoffs();
const store = await caches.open(HANDOFF_CACHE);
const urls = (await store.keys()).map((key) => key.url);
expect(urls).toHaveLength(2);
expect(urls).toHaveLength(3);
const meta = await store.match(urls.find((url) => url.endsWith('/meta'))!);
expect(((await meta!.json()) as { kind: string }).kind).toBe('contacts');
});
@@ -245,10 +328,47 @@ describe('across a page load', () => {
});
});
describe('while the page stays open', () => {
it('drops what waited too long on a timer, and says so', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] });
const losses: string[] = [];
const off = subscribeHandoffLost((loss) => losses.push(loss));
handOffFiles([file('a.eml')], 'eml');
await vi.advanceTimersByTimeAsync(HANDOFF_TTL_MS + 2000);
expect(pendingHandoffs()).toEqual([]);
expect(losses).toEqual(['expired']);
off();
});
it('says so when a taker finds only an expired handoff', () => {
vi.useFakeTimers({ toFake: ['Date'] });
const losses: string[] = [];
const off = subscribeHandoffLost((loss) => losses.push(loss));
handOffFiles([file('a.eml')], 'eml');
vi.advanceTimersByTime(HANDOFF_TTL_MS + 1);
expect(takeHandoff('eml')).toBeNull();
expect(losses).toEqual(['expired']);
off();
});
it('keeps what is still in time', async () => {
handOffFiles([file('a.eml')], 'eml');
await expireHandoffs();
expect(pendingHandoffs()).toHaveLength(1);
});
it('leaves the second of two drafts queued until it is taken', () => {
handOffDraft({ subject: 'first' });
handOffDraft({ subject: 'second' });
expect(takeHandoff('compose')?.subject).toBe('first');
expect(pendingHandoffs().map((h) => h.subject)).toEqual(['second']);
});
});
describe('sign-out', () => {
it('forgets what is waiting, in memory and stored', async () => {
handOffFiles([file('a.ics')], 'calendar');
await settle();
await whenStored();
clearHandoffs();
await settle();
expect(pendingHandoffs()).toEqual([]);
@@ -266,12 +386,18 @@ describe('what the service worker stored', () => {
form.set('text', 'Look at this');
form.set('url', 'https://example.org/page');
form.append('files', file('photo.png', 'image/png', 'bytes'));
const answer = await worker.request({ url: 'https://mail.example.com/protocol/share', method: 'POST', mode: 'navigate', formData: async () => form } as never);
const landing = parseLaunchLanding(new URL(answer!.headers.get('location')!).search);
const answer = await worker.request({ url: 'https://mail.example.com/protocol/share', method: 'POST', mode: 'navigate', destination: 'document', formData: async () => form } as never);
const location = new URL(answer!.headers.get('location')!);
const landing = parseLaunchLanding(location.search, location.hash);
expect(landing?.type).toBe('stored');
const { id } = landing as { id: string };
const { id, key } = landing as { id: string; key: string };
expect(await adoptStoredHandoff(id)).toBe(true);
// Without the key from the address it can't be read; asking shows what it is.
expect(await peekStoredHandoff(id, '')).toBeNull();
expect(await peekStoredHandoff(id, key)).toEqual({ subject: 'A link', body: 'Look at this\n\nhttps://example.org/page', files: ['photo.png'] });
expect(pendingHandoffs()).toEqual([]);
expect(await adoptStoredHandoff(id, key)).toBe(true);
const handoff = takeHandoff('compose');
expect(handoff).toMatchObject({ subject: 'A link', body: 'Look at this\n\nhttps://example.org/page' });
expect(handoff?.files[0].name).toBe('photo.png');
@@ -283,13 +409,37 @@ describe('what the service worker stored', () => {
});
it('is gone when the id names nothing', async () => {
expect(await adoptStoredHandoff('11111111-2222')).toBe(false);
expect(await adoptStoredHandoff('11111111-2222', 'AAAA')).toBe(false);
expect(await peekStoredHandoff('11111111-2222', 'AAAA')).toBeNull();
});
it('goes when the user refuses it', async () => {
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
const form = new FormData();
form.append('files', file('photo.png'));
const answer = await worker.request({ url: 'https://mail.example.com/protocol/share', method: 'POST', mode: 'navigate', destination: 'document', formData: async () => form } as never);
const location = new URL(answer!.headers.get('location')!);
const { id, key } = parseLaunchLanding(location.search, location.hash) as { id: string; key: string };
await discardStoredHandoff(id);
expect((await (await caches.open(HANDOFF_CACHE)).keys()).length).toBe(0);
expect(await peekStoredHandoff(id, key)).toBeNull();
});
it('cleans control characters out of names and text, as for a mailto: link', async () => {
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
const form = new FormData();
form.set('title', 'Hi\r\nBcc: evil@example.org');
form.append('files', file('a\u202Egnp.exe'));
const answer = await worker.request({ url: 'https://mail.example.com/protocol/share', method: 'POST', mode: 'navigate', destination: 'document', formData: async () => form } as never);
const location = new URL(answer!.headers.get('location')!);
const { id, key } = parseLaunchLanding(location.search, location.hash) as { id: string; key: string };
expect(await peekStoredHandoff(id, key)).toMatchObject({ subject: 'HiBcc: evil@example.org', files: ['agnp.exe'] });
});
it('is gone when it is too old', async () => {
const store = await caches.open(HANDOFF_CACHE);
await store.put('/__bulwark/handoff/old/meta', new Response(JSON.stringify({ kind: 'compose', createdAt: Date.now() - HANDOFF_TTL_MS - 1, files: [] })));
expect(await adoptStoredHandoff('old')).toBe(false);
await store.put('/__bulwark/handoff/old/meta', new Response(JSON.stringify({ kind: 'compose', createdAt: Date.now() - HANDOFF_TTL_MS - 1, bytes: 0 })));
expect(await adoptStoredHandoff('old', 'AAAA')).toBe(false);
expect((await store.keys()).length).toBe(0);
});
});
@@ -306,25 +456,55 @@ describe('text of a share', () => {
it('copes with only a link', () => {
expect(shareDraft({ url: 'https://a.example' })).toEqual({ subject: '', body: 'https://a.example' });
});
it('strips control characters from the subject, so no header can be planted', () => {
expect(shareDraft({ title: 'Hi\r\nBcc: evil@example.org' }).subject).toBe('HiBcc: evil@example.org');
});
it('keeps line breaks in the body and drops other control characters', () => {
expect(shareDraft({ text: 'one\r\ntwo\u0000\u0007' }).body).toBe('one\ntwo');
});
it('applies the limits of a mailto: link', () => {
const draft = shareDraft({ title: 'x'.repeat(2000), text: 'y'.repeat(100_000), url: 'https://a.example' });
expect(draft.subject).toHaveLength(998);
expect(draft.body).toHaveLength(64 * 1024);
});
it('applies them to a draft handed in directly as well', () => {
handOffDraft({ subject: 'a\r\nb', body: 'y'.repeat(100_000) });
const handoff = takeHandoff('compose');
expect(handoff?.subject).toBe('ab');
expect(handoff?.body).toHaveLength(64 * 1024);
});
});
describe('the address a launch lands on', () => {
it.each([
['?compose=1', { type: 'new-message' }],
['?share=abc-123', { type: 'stored', id: 'abc-123' }],
['?share=unavailable&share_title=T&share_text=B&share_files=1', { type: 'inline', subject: 'T', body: 'B', filesLost: true }],
['?share=unavailable&share_url=https%3A%2F%2Fa.example', { type: 'inline', subject: '', body: 'https://a.example', filesLost: false }],
['?share=..%2F..%2Fetc', null],
['?compose=0', null],
['?openLatestUnread=1', null],
['', null],
])('%s', (search, expected) => {
expect(parseLaunchLanding(search)).toEqual(expected);
['?compose=1', '', { type: 'new-message' }],
['', '#share=abc-123&key=k1', { type: 'stored', id: 'abc-123', key: 'k1' }],
['', '#share=unavailable&share_title=T&share_text=B&share_files=1', { type: 'inline', subject: 'T', body: 'B', filesLost: true, cut: false }],
['', '#share=unavailable&share_url=https%3A%2F%2Fa.example&share_cut=1', { type: 'inline', subject: '', body: 'https://a.example', filesLost: false, cut: true }],
['', '#share=..%2F..%2Fetc', null],
// Shared text stays out of the query, which reaches logs.
['?share=unavailable&share_text=x', '', null],
['?compose=0', '', null],
['?openLatestUnread=1', '#x', null],
['', '', null],
])('%s %s', (search, hash, expected) => {
expect(parseLaunchLanding(search, hash)).toEqual(expected);
});
it('cleans text that came in the address', () => {
const landing = parseLaunchLanding('', '#share=unavailable&share_title=Hi%0D%0ABcc%3A%20evil');
expect(landing).toMatchObject({ subject: 'HiBcc: evil' });
});
it('leaves only what is not about the launch', () => {
expect(stripLandingParams('?compose=1')).toBe('');
expect(stripLandingParams('?share=unavailable&share_text=x&slot=2')).toBe('?slot=2');
expect(stripLandingParams('?compose=1', '')).toEqual({ search: '', hash: '' });
expect(stripLandingParams('?slot=2', '#share=unavailable&share_text=x')).toEqual({ search: '?slot=2', hash: '' });
expect(stripLandingParams('?compose=1&slot=2', '#section')).toEqual({ search: '?slot=2', hash: '#section' });
expect(stripLandingParams('', '#share=a&key=b')).toEqual({ search: '', hash: '' });
});
});
+27 -6
View File
@@ -160,7 +160,9 @@ describe('share route without a service worker', () => {
expect(res.status).toBe(303);
const landing = new URL(res.headers.get('location')!, 'https://x.example');
expect(landing.pathname).toBe('/');
expect(Object.fromEntries(landing.searchParams)).toEqual({ share: 'unavailable', share_title: 'T', share_text: 'B', share_url: 'https://example.org/' });
// In the fragment: it never reaches an access log.
expect(landing.search).toBe('');
expect(Object.fromEntries(new URLSearchParams(landing.hash.slice(1)))).toEqual({ share: 'unavailable', share_title: 'T', share_text: 'B', share_url: 'https://example.org/' });
});
it('says files were lost', async () => {
@@ -169,7 +171,7 @@ describe('share route without a service worker', () => {
form.set('text', 'B');
form.append('files', new File(['x'], 'a.png'));
const res = await POST(post(form, '500'));
expect(new URL(res.headers.get('location')!, 'https://x.example').searchParams.get('share_files')).toBe('1');
expect(res.headers.get('location')).toContain('share_files=1');
});
it('does not read a large upload into memory', async () => {
@@ -180,15 +182,34 @@ describe('share route without a service worker', () => {
const request = { headers: new Headers({ 'content-length': String(50 * 1024 * 1024) }), formData: spy } as unknown as Request;
const res = await POST(request);
expect(spy).not.toHaveBeenCalled();
expect(new URL(res.headers.get('location')!, 'https://x.example').searchParams.get('share_files')).toBe('1');
expect(res.headers.get('location')).toContain('share_files=1');
});
it('keeps the address short', async () => {
it.each([
['Latin', 'x'],
['Japanese, 9 encoded bytes each', 'あ'],
['emoji, whole characters only', '😀'],
])('keeps the address short and says it cut: %s', async (_name, character) => {
const { POST } = await route();
const form = new FormData();
form.set('text', 'x'.repeat(100_000));
form.set('text', character.repeat(3000));
form.set('title', character.repeat(3000));
form.set('url', character.repeat(3000));
const res = await POST(post(form, '100000'));
expect(res.headers.get('location')!.length).toBeLessThan(4200);
const location = res.headers.get('location')!;
expect(location.length).toBeLessThan(4000);
const fragment = new URLSearchParams(new URL(location, 'https://x.example').hash.slice(1));
expect(fragment.get('share_cut')).toBe('1');
// Decodes cleanly: no half a character at the end.
expect(fragment.get('share_text')!.endsWith(character)).toBe(true);
});
it('does not say it cut what fits', async () => {
const { POST } = await route();
const form = new FormData();
form.set('text', 'あ'.repeat(100));
const res = await POST(post(form, '1000'));
expect(res.headers.get('location')).not.toContain('share_cut');
});
it('honours the subpath', async () => {
+1 -1
View File
@@ -39,7 +39,7 @@ export function loadWorker(opts: { buildId: string; caches: FakeCaches; clients:
clients: { claim: async () => {}, matchAll: async () => opts.clients },
registration: { showNotification: async () => {}, getNotifications: async () => [] },
};
const context = vm.createContext({ self, caches: opts.caches, fetch: opts.fetch ?? (async () => { throw new TypeError('offline'); }), URL, URLSearchParams, Request, Response, Headers, Date, console, AbortController, setTimeout, clearTimeout, crypto: globalThis.crypto });
const context = vm.createContext({ self, caches: opts.caches, fetch: opts.fetch ?? (async () => { throw new TypeError('offline'); }), URL, URLSearchParams, Request, Response, Headers, Date, console, AbortController, setTimeout, clearTimeout, crypto: globalThis.crypto, TextEncoder, btoa });
vm.runInContext(SW_SOURCE, context);
async function dispatch(type: string, event: Record<string, unknown> = {}) {
const waits: Promise<unknown>[] = [];
+159 -28
View File
@@ -3,13 +3,17 @@ import { describe, expect, it, vi } from 'vitest';
import { FakeCaches, loadWorker } from './sw-harness';
// The manifest's share_target POST, in the service worker, and a mailto:
// launch while offline. Before: a POST went to the server when offline mode was off.
// launch while offline. Any site can POST here, signed in or not, so what the
// worker keeps is limited and sealed. Before: a POST went to the server when
// offline mode was off, and files sat readable in the cache with no limit.
const ORIGIN = 'https://mail.example.com';
const CACHE = 'bulwark-launch-handoff-v1';
const TTL = 15 * 60 * 1000;
const settle = () => new Promise((r) => setTimeout(r, 0));
function shareRequest(form: FormData, overrides: Record<string, unknown> = {}) {
return { url: `${ORIGIN}/protocol/share`, method: 'POST', mode: 'navigate', formData: async () => form, ...overrides } as never;
return { url: `${ORIGIN}/protocol/share`, method: 'POST', mode: 'navigate', destination: 'document', formData: async () => form, ...overrides } as never;
}
function sharedForm(): FormData {
@@ -17,15 +21,31 @@ function sharedForm(): FormData {
form.set('title', 'A link');
form.set('text', 'Look');
form.set('url', 'https://example.org/');
form.append('files', new File(['bytes'], 'photo.png', { type: 'image/png' }));
form.append('files', new File(['secret bytes'], 'photo.png', { type: 'image/png' }));
return form;
}
const landingOf = (answer: Response | null) => {
const url = new URL(answer!.headers.get('location')!);
return { url, fragment: new URLSearchParams(url.hash.slice(1)) };
};
async function shellOn(caches: FakeCaches, on: boolean) {
const cache = await caches.open('bulwark-sw-state-v1');
await cache.put('/__bulwark/offline-shell', new Response(JSON.stringify({ on })));
}
async function plantEntry(caches: FakeCaches, id: string, createdAt: number, bytes = 0) {
const store = await caches.open(CACHE);
await store.put(`/__bulwark/handoff/${id}/meta`, new Response(JSON.stringify({ kind: 'compose', createdAt, bytes })));
await store.put(`/__bulwark/handoff/${id}/sealed`, new Response('x'));
}
async function entryIds(caches: FakeCaches): Promise<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' });
@@ -33,7 +53,7 @@ function htmlPage(body: string) {
}
describe('share target', () => {
it.each([[false], [true]])('takes the POST with offline mode on=%s, keeps the files and goes to the app', async (on) => {
it.each([[false], [true]])('takes the POST with offline mode on=%s, keeps the files sealed and goes to the app', async (on) => {
const caches = new FakeCaches();
await shellOn(caches, on);
const fetchMock = vi.fn();
@@ -44,15 +64,23 @@ describe('share target', () => {
expect(fetchMock).not.toHaveBeenCalled();
expect(answer?.status).toBe(303);
const landing = new URL(answer!.headers.get('location')!);
expect(`${landing.origin}${landing.pathname}`).toBe(`${ORIGIN}/`);
const id = landing.searchParams.get('share')!;
const { url, fragment } = landingOf(answer);
expect(`${url.origin}${url.pathname}`).toBe(`${ORIGIN}/`);
// Id and key in the fragment: neither ever reaches the server.
expect(url.search).toBe('');
const id = fragment.get('share')!;
expect(id).toMatch(/^[\w-]+$/);
expect(fragment.get('key')).toMatch(/^[\w-]{43}$/);
const store = await caches.open('bulwark-launch-handoff-v1');
const store = await caches.open(CACHE);
const meta = (await (await store.match(`/__bulwark/handoff/${id}/meta`))!.json()) as Record<string, unknown>;
expect(meta).toMatchObject({ kind: 'compose', title: 'A link', text: 'Look', url: 'https://example.org/', files: [{ name: 'photo.png', type: 'image/png' }] });
expect(await (await store.match(`/__bulwark/handoff/${id}/0`))!.text()).toBe('bytes');
expect(meta).toMatchObject({ kind: 'compose', bytes: 12 });
// Only what a sweep needs is readable.
expect(Object.keys(meta).sort()).toEqual(['bytes', 'createdAt', 'kind']);
for (const part of ['meta', 'sealed', '0']) {
const text = await (await store.match(`/__bulwark/handoff/${id}/${part}`))!.text();
for (const secret of ['secret bytes', 'photo.png', 'A link', 'example.org']) expect(text).not.toContain(secret);
}
});
it('answers while the worker has only just started and has not read its state', async () => {
@@ -65,7 +93,7 @@ describe('share target', () => {
const worker = loadWorker({ buildId: 'a', caches: new FakeCaches(), clients: [], location: `${ORIGIN}/webmail/sw.js?v=a` });
expect(await worker.request(shareRequest(sharedForm()))).toBeNull();
const answer = await worker.request(shareRequest(sharedForm(), { url: `${ORIGIN}/webmail/protocol/share` }));
expect(answer!.headers.get('location')).toContain(`${ORIGIN}/webmail/?share=`);
expect(answer!.headers.get('location')).toContain(`${ORIGIN}/webmail/#share=`);
});
it('skips empty file fields', async () => {
@@ -75,37 +103,54 @@ describe('share target', () => {
form.set('url', 'https://example.org/');
form.append('files', new File([], ''));
const answer = await worker.request(shareRequest(form));
const id = new URL(answer!.headers.get('location')!).searchParams.get('share')!;
const store = await caches.open('bulwark-launch-handoff-v1');
const meta = (await (await store.match(`/__bulwark/handoff/${id}/meta`))!.json()) as { files: unknown[] };
expect(meta.files).toEqual([]);
const id = landingOf(answer).fragment.get('share')!;
const store = await caches.open(CACHE);
const meta = (await (await store.match(`/__bulwark/handoff/${id}/meta`))!.json()) as { bytes: number };
expect(meta.bytes).toBe(0);
expect(await store.match(`/__bulwark/handoff/${id}/0`)).toBeUndefined();
});
it('keeps the text and says the files are lost when there is no room', async () => {
const caches = new FakeCaches();
const original = caches.open.bind(caches);
const store = await original('bulwark-launch-handoff-v1');
vi.spyOn(caches, 'open').mockImplementation(async (name: string) => (name === 'bulwark-launch-handoff-v1'
const store = await original(CACHE);
vi.spyOn(caches, 'open').mockImplementation(async (name: string) => (name === CACHE
? { ...store, put: async () => { throw new Error('QuotaExceededError'); } }
: original(name)));
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
const answer = await worker.request(shareRequest(sharedForm()));
const { fragment } = landingOf(await worker.request(shareRequest(sharedForm())));
const landing = new URL(answer!.headers.get('location')!).searchParams;
expect(landing.get('share')).toBe('unavailable');
expect(landing.get('share_files')).toBe('1');
expect(landing.get('share_title')).toBe('A link');
expect(landing.get('share_url')).toBe('https://example.org/');
expect(fragment.get('share')).toBe('unavailable');
expect(fragment.get('share_files')).toBe('1');
expect(fragment.get('share_title')).toBe('A link');
expect(fragment.get('share_url')).toBe('https://example.org/');
expect((await store.keys()).length).toBe(0);
});
it('keeps that address short too, and says it cut', async () => {
const caches = new FakeCaches();
const original = caches.open.bind(caches);
const store = await original(CACHE);
vi.spyOn(caches, 'open').mockImplementation(async (name: string) => (name === CACHE
? { ...store, put: async () => { throw new Error('QuotaExceededError'); } }
: original(name)));
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
const form = new FormData();
form.set('text', 'あ'.repeat(3000));
const answer = await worker.request(shareRequest(form));
expect(answer!.headers.get('location')!.length).toBeLessThan(4000);
expect(landingOf(answer).fragment.get('share_cut')).toBe('1');
});
it('says so when the form cannot be read', async () => {
const worker = loadWorker({ buildId: 'a', caches: new FakeCaches(), clients: [] });
const answer = await worker.request(shareRequest(new FormData(), { formData: async () => { throw new TypeError('bad body'); } }));
const landing = new URL(answer!.headers.get('location')!).searchParams;
expect(landing.get('share')).toBe('unavailable');
expect(landing.get('share_files')).toBe('1');
const { fragment } = landingOf(answer);
expect(fragment.get('share')).toBe('unavailable');
expect(fragment.get('share_files')).toBe('1');
});
it('leaves other requests alone', async () => {
@@ -117,8 +162,94 @@ describe('share target', () => {
expect(await worker.request(shareRequest(sharedForm(), { mode: 'cors' }))).toBeNull();
expect(await worker.request(shareRequest(sharedForm(), { url: `${ORIGIN}/api/share` }))).toBeNull();
expect(await worker.request(shareRequest(sharedForm(), { url: 'https://other.example/protocol/share' }))).toBeNull();
expect(await worker.request({ url: `${ORIGIN}/protocol/share`, mode: 'navigate' })).toBeNull();
expect(await caches.has('bulwark-launch-handoff-v1')).toBe(false);
expect(await worker.request({ url: `${ORIGIN}/protocol/share`, mode: 'navigate', destination: 'document' } as never)).toBeNull();
expect(await caches.has(CACHE)).toBe(false);
});
it('leaves a POST into a hidden frame to the network: it stores nothing', async () => {
const caches = new FakeCaches();
await shellOn(caches, false);
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
await settle();
expect(await worker.request(shareRequest(sharedForm(), { destination: 'iframe' }))).toBeNull();
expect(await worker.request(shareRequest(sharedForm(), { destination: 'embed' }))).toBeNull();
expect(await caches.has(CACHE)).toBe(false);
});
});
describe('limits on what the worker keeps', () => {
it('drops the oldest entries over the entry limit, on every share', async () => {
const caches = new FakeCaches();
const now = Date.now();
for (let n = 0; n < 5; n += 1) await plantEntry(caches, `old-${n}`, now - 1000 * (5 - n));
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
const answer = await worker.request(shareRequest(sharedForm()));
const ids = await entryIds(caches);
expect(ids).toHaveLength(5);
expect(ids).not.toContain('old-0');
expect(ids).toContain('old-1');
expect(ids).toContain(landingOf(answer).fragment.get('share'));
// Everything of the dropped one goes, not only its list.
expect(await (await caches.open(CACHE)).match('/__bulwark/handoff/old-0/sealed')).toBeUndefined();
});
it('drops the oldest entries over the byte limit', async () => {
const caches = new FakeCaches();
const now = Date.now();
await plantEntry(caches, 'big', now - 2000, 100 * 1024 * 1024 - 5);
await plantEntry(caches, 'small', now - 1000, 2);
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
await worker.request(shareRequest(sharedForm()));
const ids = await entryIds(caches);
expect(ids).not.toContain('big');
expect(ids).toContain('small');
});
it('stores nothing from a share over the byte limit, says the files are lost, and keeps the rest', async () => {
const caches = new FakeCaches();
await plantEntry(caches, 'kept', Date.now() - 1000, 10);
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
const big = new File(['x'], 'huge.bin');
Object.defineProperty(big, 'size', { value: 100 * 1024 * 1024 + 1 });
const fields = new Map<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);
});
});
+324 -112
View File
@@ -14,12 +14,24 @@
* sessionStorage, which can't hold a File. So a handoff lives in this module's
* memory, and everything except a .eml also goes into the Cache API: signing
* in with SSO, or after a session ended, leaves the page, and the file has to
* still be there afterwards. sessionStorage only holds which stored handoffs
* belong to this tab, so a second window doesn't take them. What is stored
* goes when it is taken, when it is too old, or at sign-out.
* still be there afterwards.
*
* What is stored is sealed with a key of its own, and the key stays in this
* tab's sessionStorage, so a second window can't take it. The worker passes
* its key in the landing address's fragment. What is stored goes when it is
* taken, after HANDOFF_TTL_MS, when newer handoffs need the room, at sign-out
* and when offline mode wipes.
*
* Anyone can POST to the share target, or link to a landing address, so what
* arrives that way is untrusted: the provider asks the user before it gets
* here (peekStoredHandoff, then adoptStoredHandoff or discardStoredHandoff).
* Chromium's link capturing can bring a cross-site form POST into an installed
* app's window, so that goes for app windows too. Only a bridge skips it.
*/
import { stripBidiControls } from '@/lib/bidi';
import { matchSurface } from '@/lib/deep-links';
import { cleanMailtoBody, cleanMailtoSubject } from '@/lib/protocol-handlers/mailto';
export type HandoffKind = 'compose' | 'eml' | 'calendar' | 'contacts';
@@ -33,21 +45,28 @@ export interface Handoff {
createdAt: number;
}
/** The service worker writes this cache too, in the same layout. */
/** The service worker writes this cache too, in the same layout and with the same limits. */
export const HANDOFF_CACHE = 'bulwark-launch-handoff-v1';
const STORE_PREFIX = '/__bulwark/handoff/';
const TAB_IDS_KEY = 'bulwark:launch-handoffs';
export const HANDOFF_TTL_MS = 15 * 60 * 1000;
export const HANDOFF_MAX_ENTRIES = 5;
export const HANDOFF_MAX_BYTES = 100 * 1024 * 1024;
const STORE_PREFIX = '/__bulwark/handoff/';
const TAB_KEYS_KEY = 'bulwark:launch-handoffs';
/** A launch can arrive twice: as launchQueue's target and as the page's own address. */
const NEW_MESSAGE_REPEAT_MS = 2000;
type Listener = (kind: HandoffKind) => void;
export type HandoffLoss = 'expired' | 'missing';
const queue: Handoff[] = [];
const stored = new Map<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'
@@ -59,41 +78,137 @@ function cacheApi(): CacheStorage | null {
return typeof caches !== 'undefined' && caches && typeof caches.open === 'function' ? caches : null;
}
function readTabIds(): string[] {
function readTabKeys(): Record<string, string> {
try {
const parsed: unknown = JSON.parse(sessionStorage.getItem(TAB_IDS_KEY) ?? '[]');
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [];
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 [];
return {};
}
}
function writeTabIds(ids: string[]): void {
function writeTabKeys(keys: Record<string, string>): void {
try {
if (ids.length > 0) sessionStorage.setItem(TAB_IDS_KEY, JSON.stringify(ids));
else sessionStorage.removeItem(TAB_IDS_KEY);
if (Object.keys(keys).length > 0) sessionStorage.setItem(TAB_KEYS_KEY, JSON.stringify(keys));
else sessionStorage.removeItem(TAB_KEYS_KEY);
} catch {
// Hardened or private modes: the handoff stays in memory only.
}
}
function setTabKey(id: string, key: string | null): void {
const keys = readTabKeys();
if (key) keys[id] = key;
else delete keys[id];
writeTabKeys(keys);
}
function toBase64Url(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function fromBase64Url(text: string): Uint8Array<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) return;
const prefix = STORE_PREFIX + handoff.id + '/';
if (!api || !canSeal()) return;
const bytes = handoff.files.reduce((sum, file) => sum + file.size, 0);
if (bytes > HANDOFF_MAX_BYTES) return;
const prefix = entryPrefix(handoff.id);
const startedAt = clearCount;
try {
const cache = await api.open(HANDOFF_CACHE);
await sweep(cache, { bytes });
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
const rawKey = new Uint8Array(await crypto.subtle.exportKey('raw', key));
// The list first: the sweep goes by it, so no file can stay behind unlisted.
await cache.put(prefix + 'meta', new Response(JSON.stringify({
kind: handoff.kind,
createdAt: handoff.createdAt,
await cache.put(`${prefix}meta`, new Response(JSON.stringify({ kind: handoff.kind, createdAt: handoff.createdAt, bytes })));
const contents = JSON.stringify({
title: handoff.subject ?? '',
text: handoff.body ?? '',
url: '',
files: handoff.files.map((file) => ({ name: file.name, type: file.type })),
})));
await Promise.all(handoff.files.map((file, index) => cache.put(prefix + index, new Response(file))));
writeTabIds([...readTabIds().filter((id) => id !== handoff.id), handoff.id]);
});
await cache.put(`${prefix}sealed`, new Response(await seal(key, new TextEncoder().encode(contents))));
for (const [index, file] of handoff.files.entries()) {
await cache.put(`${prefix}${index}`, new Response(await seal(key, await file.arrayBuffer())));
}
if (startedAt !== clearCount) {
await deleteEntry(cache, prefix);
return;
}
setTabKey(handoff.id, toBase64Url(rawKey));
} catch {
// Quota, or no Cache API here: the handoff stays in memory only.
await deleteStored(handoff.id);
@@ -101,18 +216,12 @@ async function writeStored(handoff: Handoff): Promise<void> {
}
async function deleteStored(id: string): Promise<void> {
writeTabIds(readTabIds().filter((other) => other !== id));
setTabKey(id, null);
const api = cacheApi();
if (!api) return;
try {
if (!(await api.has(HANDOFF_CACHE))) return;
const cache = await api.open(HANDOFF_CACHE);
const prefix = STORE_PREFIX + id + '/';
const keys = await cache.keys();
const own = keys.filter((key) => new URL(key.url, 'https://x.invalid').pathname.startsWith(prefix));
// The list last, for the sweep.
await Promise.all(own.filter((key) => !key.url.endsWith('/meta')).map((key) => cache.delete(key)));
await cache.delete(prefix + 'meta');
await deleteEntry(await api.open(HANDOFF_CACHE), entryPrefix(id));
} catch {
// The sweep gets it.
}
@@ -125,8 +234,53 @@ function notify(kind: HandoffKind): void {
for (const listener of [...(kindListeners.get(kind) ?? [])]) listener(kind);
}
function reportLoss(loss: HandoffLoss): void {
for (const listener of [...lossListeners]) listener(loss);
}
function dropFromQueue(index: number): Handoff {
const [handoff] = queue.splice(index, 1);
void (stored.get(handoff.id) ?? Promise.resolve()).then(() => deleteStored(handoff.id));
stored.delete(handoff.id);
return handoff;
}
function armExpiry(oldestStored: number | null): void {
if (expiryTimer !== null) clearTimeout(expiryTimer);
expiryTimer = null;
const times = [...queue.map((handoff) => handoff.createdAt), ...(oldestStored === null ? [] : [oldestStored])];
if (times.length === 0) return;
const delay = Math.max(0, Math.min(...times) + HANDOFF_TTL_MS - Date.now()) + 1000;
expiryTimer = setTimeout(() => { void expireHandoffs(); }, delay);
}
/** What waited too long goes, in memory and stored. Also runs on a timer while this page is open. */
export async function expireHandoffs(): Promise<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);
}
@@ -152,14 +306,16 @@ export function handOffFiles(files: File[], kind: HandoffKind): void {
enqueue({ id: newId(), kind, files, createdAt: Date.now() }, kind !== 'eml');
}
/** Opens a new draft with this text and these files. */
/** Opens a new draft with this text and these files. Trusted: what came from outside was asked about first. */
export function handOffDraft(draft: { subject?: string; body?: string; files?: File[] }): void {
const subject = cleanMailtoSubject(draft.subject ?? '');
const body = cleanMailtoBody(draft.body ?? '');
enqueue({
id: newId(),
kind: 'compose',
files: draft.files ?? [],
...(draft.subject ? { subject: draft.subject } : {}),
...(draft.body ? { body: draft.body } : {}),
...(subject ? { subject } : {}),
...(body ? { body } : {}),
createdAt: Date.now(),
}, true);
}
@@ -203,15 +359,17 @@ export async function handOffLaunchFiles(
/** The oldest handoff of this kind; it is gone from here after this. */
export function takeHandoff(kind: HandoffKind): Handoff | null {
const now = Date.now();
for (let index = 0; index < queue.length; index += 1) {
let lost = false;
let found: Handoff | null = null;
for (let index = 0; index < queue.length && !found; index += 1) {
if (queue[index].kind !== kind) continue;
const [handoff] = queue.splice(index, 1);
const handoff = dropFromQueue(index);
index -= 1;
void (stored.get(handoff.id) ?? Promise.resolve()).then(() => deleteStored(handoff.id));
stored.delete(handoff.id);
if (now - handoff.createdAt <= HANDOFF_TTL_MS) return handoff;
if (now - handoff.createdAt <= HANDOFF_TTL_MS) found = handoff;
else lost = true;
}
return null;
if (lost) reportLoss('expired');
return found;
}
/** What is waiting, oldest first. Nothing is taken. */
@@ -231,88 +389,134 @@ export function subscribeHandoff(kind: HandoffKind | 'any', listener: Listener):
return () => { set.delete(listener); };
}
/** Subject and body of a draft from what the OS shared. */
export function shareDraft(shared: { title?: string; text?: string; url?: string }): { subject: string; body: string } {
const text = (shared.text ?? '').trim();
const url = (shared.url ?? '').trim();
// Android often puts the link in the text as well.
const body = url && !text.includes(url) ? [text, url].filter(Boolean).join('\n\n') : text;
return { subject: (shared.title ?? '').trim(), body };
/** Hears about a handoff that went without arriving, so the user can be told. */
export function subscribeHandoffLost(listener: (loss: HandoffLoss) => void): () => void {
lossListeners.add(listener);
return () => { lossListeners.delete(listener); };
}
/** Brings a stored handoff into this tab. False when it is gone. */
export async function adoptStoredHandoff(id: string): Promise<boolean> {
if (queue.some((handoff) => handoff.id === id)) return true;
/** Subject and body of a draft from what the OS shared, under the same rules as a mailto: link. */
export function shareDraft(shared: { title?: string; text?: string; url?: string }): { subject: string; body: string } {
const text = cleanMailtoBody(shared.text ?? '').trim();
const url = cleanMailtoSubject(shared.url ?? '').trim();
// Android often puts the link in the text as well.
const body = url && !text.includes(url) ? [text, url].filter(Boolean).join('\n\n') : text;
return { subject: cleanMailtoSubject(shared.title ?? '').trim(), body: cleanMailtoBody(body) };
}
/** A name from outside, as it may be shown and attached. */
function cleanFileName(name: string): string {
// eslint-disable-next-line no-control-regex
return stripBidiControls(name.replace(/[\u0000-\u001F\u007F]/g, '')).slice(0, 255) || 'file';
}
interface StoredContents {
meta: { kind: HandoffKind; createdAt: number };
subject: string;
body: string;
files: Array<{ name: string; type: string }>;
key: CryptoKey;
cache: Cache;
}
async function openStored(id: string, encodedKey: string): Promise<StoredContents | null> {
const api = cacheApi();
if (!api) return false;
const prefix = STORE_PREFIX + id + '/';
if (!api || !canSeal() || !encodedKey) return null;
const prefix = entryPrefix(id);
try {
if (!(await api.has(HANDOFF_CACHE))) return false;
if (!(await api.has(HANDOFF_CACHE))) return null;
const cache = await api.open(HANDOFF_CACHE);
const metaResponse = await cache.match(prefix + 'meta');
if (!metaResponse) return false;
const meta = (await metaResponse.json()) as {
kind: HandoffKind; createdAt: number; title?: string; text?: string; url?: string;
files?: Array<{ name: string; type?: string }>;
};
const metaResponse = await cache.match(`${prefix}meta`);
if (!metaResponse) return null;
const meta = (await metaResponse.json()) as StoredContents['meta'];
if (Date.now() - meta.createdAt > HANDOFF_TTL_MS) {
await deleteStored(id);
return false;
return null;
}
const files: File[] = [];
for (const [index, entry] of (meta.files ?? []).entries()) {
const response = await cache.match(prefix + index);
if (!response) continue;
files.push(new File([await response.blob()], entry.name, { type: entry.type ?? '' }));
}
const { subject, body } = shareDraft(meta);
// A second await above: another caller may have got here first.
if (queue.some((handoff) => handoff.id === id)) return true;
if (!readTabIds().includes(id)) writeTabIds([...readTabIds(), id]);
enqueue({
id,
kind: meta.kind,
files,
...(subject ? { subject } : {}),
...(body ? { body } : {}),
createdAt: meta.createdAt,
}, false);
return true;
const sealedResponse = await cache.match(`${prefix}sealed`);
if (!sealedResponse) return null;
const key = await crypto.subtle.importKey('raw', fromBase64Url(encodedKey), 'AES-GCM', false, ['decrypt']);
const contents = JSON.parse(new TextDecoder().decode(await unseal(key, await sealedResponse.arrayBuffer()))) as {
title?: string; text?: string; url?: string; files?: Array<{ name: string; type?: string }>;
};
return {
meta,
...shareDraft(contents),
files: (contents.files ?? []).map((file) => ({ name: cleanFileName(file.name), type: file.type ?? '' })),
key,
cache,
};
} catch {
return false;
return null;
}
}
/** What a stored handoff holds, to ask the user about. Null when it is gone or can't be read. */
export async function peekStoredHandoff(id: string, encodedKey: string): Promise<{ subject: string; body: string; files: string[] } | null> {
const contents = await openStored(id, encodedKey);
return contents && { subject: contents.subject, body: contents.body, files: contents.files.map((file) => file.name) };
}
/** The user refused it. */
export function discardStoredHandoff(id: string): Promise<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> {
await Promise.all(readTabIds().map(async (id) => {
if (!(await adoptStoredHandoff(id))) writeTabIds(readTabIds().filter((other) => other !== id));
let lost = false;
await Promise.all(Object.keys(readTabKeys()).map(async (id) => {
if (await adoptStoredHandoff(id)) return;
setTabKey(id, null);
lost = true;
}));
const api = cacheApi();
if (!api) return;
try {
if (!(await api.has(HANDOFF_CACHE))) return;
const cache = await api.open(HANDOFF_CACHE);
for (const key of await cache.keys()) {
const match = /\/__bulwark\/handoff\/([^/]+)\/meta$/.exec(key.url);
if (!match) continue;
const response = await cache.match(key);
const meta = response ? ((await response.json()) as { createdAt?: number }) : null;
if (!meta || typeof meta.createdAt !== 'number' || Date.now() - meta.createdAt > HANDOFF_TTL_MS) {
await deleteStored(match[1]);
}
}
} catch {
// Tried again at the next page load.
}
if (lost) reportLoss('expired');
await sweepStoredHandoffs();
}
/** Sign-out: nothing that was on its way into an account stays. */
/** Sign-out and offline wipes: nothing that was on its way into an account stays. */
export function clearHandoffs(): void {
clearCount += 1;
queue.length = 0;
stored.clear();
writeTabIds([]);
writeTabKeys({});
lastNewMessageAt = 0;
if (expiryTimer !== null) clearTimeout(expiryTimer);
expiryTimer = null;
const api = cacheApi();
if (api) void api.delete(HANDOFF_CACHE).catch(() => {});
}
@@ -320,16 +524,16 @@ export function clearHandoffs(): void {
export type LaunchLanding =
| { type: 'new-message' }
/** The service worker stored what was shared. */
| { type: 'stored'; id: string }
| { type: 'stored'; id: string; key: string }
/** It couldn't be stored, or no worker ran: the text came along in the address. */
| { type: 'inline'; subject: string; body: string; filesLost: boolean };
| { type: 'inline'; subject: string; body: string; filesLost: boolean; cut: boolean };
const LANDING_PARAMS = ['compose', 'share', 'share_title', 'share_text', 'share_url', 'share_files'];
const LANDING_HASH_PARAMS = ['share', 'key', 'share_title', 'share_text', 'share_url', 'share_files', 'share_cut'];
/** What the page's own address asks for, once. See stripLandingParams. */
export function parseLaunchLanding(search: string): LaunchLanding | null {
const params = new URLSearchParams(search);
if (params.get('compose') === '1') return { type: 'new-message' };
export function parseLaunchLanding(search: string, hash: string): LaunchLanding | null {
if (new URLSearchParams(search).get('compose') === '1') return { type: 'new-message' };
const params = new URLSearchParams(hash.replace(/^#/, ''));
const share = params.get('share');
if (!share) return null;
if (share === 'unavailable') {
@@ -341,17 +545,25 @@ export function parseLaunchLanding(search: string): LaunchLanding | null {
url: params.get('share_url') ?? '',
}),
filesLost: params.get('share_files') === '1',
cut: params.get('share_cut') === '1',
};
}
return /^[\w-]{1,64}$/.test(share) ? { type: 'stored', id: share } : null;
return /^[\w-]{1,64}$/.test(share) ? { type: 'stored', id: share, key: params.get('key') ?? '' } : null;
}
/** The query without what parseLaunchLanding read, so a reload doesn't do it again. */
export function stripLandingParams(search: string): string {
const params = new URLSearchParams(search);
for (const name of LANDING_PARAMS) params.delete(name);
const rest = params.toString();
return rest ? `?${rest}` : '';
/** The address without what parseLaunchLanding read, so a reload doesn't do it again. */
export function stripLandingParams(search: string, hash: string): { search: string; hash: string } {
const query = new URLSearchParams(search);
query.delete('compose');
const rest = query.toString();
let leftHash = hash;
const fragment = new URLSearchParams(hash.replace(/^#/, ''));
if (fragment.has('share')) {
for (const name of LANDING_HASH_PARAMS) fragment.delete(name);
const left = fragment.toString();
leftHash = left ? `#${left}` : '';
}
return { search: rest ? `?${rest}` : '', hash: leftHash };
}
/**
@@ -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);
});
});
+4
View File
@@ -66,6 +66,7 @@ import {
type OutboxSummary,
type PolicyFingerprint,
} from './queue';
import { clearHandoffs } from '@/lib/launch-handoff';
import { isOfflineNow, OFFLINE_ACCOUNTS_KEY, readEnabledFrom, serverKey, setServerDown, useOfflineStore, writeEnabled, type OutboxRow } from './state';
import { flushQueue, settleStaleSends } from './flush';
import { syncAccount } from './sync';
@@ -696,6 +697,7 @@ export async function enableOffline(account: string): Promise<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);
}
@@ -779,6 +781,7 @@ export async function wipeEverything(): Promise<void> {
await deleteOfflineDb();
await tellServiceWorker(false);
await deleteShellCaches();
clearHandoffs();
confirmed.clear();
named.clear();
useOfflineStore.setState({ outbox: [], pendingChanges: 0, usageBytes: 0, syncing: [], lastSyncedAt: {} });
@@ -801,6 +804,7 @@ export function enforcePolicy(): void {
for (const account of useOfflineStore.getState().enabled) {
if (!policyDecided(account)) continue;
if (!offlineAllowed(account)) {
clearHandoffs();
void wipeAccount(account);
notify({ kind: 'forbidden' });
}
+12 -2
View File
@@ -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")),
};
}
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "ما شاركته لم يعد موجودًا. يرجى مشاركته مرة أخرى.",
"share_files_lost": "تعذر الاحتفاظ بالملفات المشتركة. أضفها إلى الرسالة مرة أخرى.",
"ics_needs_server": "يحتاج استيراد ملف تقويم إلى اتصال بالخادم. يبدأ الاستيراد عند عودتك إلى الاتصال بالإنترنت.",
"contacts_need_server": "يحتاج استيراد جهات الاتصال إلى اتصال بالخادم. حاول مرة أخرى عند عودتك إلى الاتصال بالإنترنت."
"contacts_need_server": "يحتاج استيراد جهات الاتصال إلى اتصال بالخادم. حاول مرة أخرى عند عودتك إلى الاتصال بالإنترنت.",
"share_confirm_title": "هل تريد بدء رسالة جديدة؟",
"share_confirm_message": "يريد موقع أو تطبيق آخر بدء رسالة. لن يُرفع أو يُحفظ أي شيء إلا إذا وافقت.",
"share_confirm_subject": "الموضوع:",
"share_confirm_text": "النص:",
"share_confirm_files": "{count, plural, one {ملف:} other {ملفات:}}",
"share_confirm_accept": "بدء الرسالة",
"share_confirm_decline": "عدم البدء",
"share_cut": "كان النص المشترك طويلًا جدًا وتم اختصاره.",
"handoff_expired": "انتظر شيء فتحته أو شاركته مدة طويلة ولم يعد موجودًا. يرجى فتحه أو مشاركته مرة أخرى.",
"compose_waiting": "أغلق هذه الرسالة لبدء الرسالة التالية.",
"save_first_failed": "تعذر حفظ رسالتك، لذلك لم يُفتح الملف. احفظ الرسالة ثم افتح التقويم أو جهات الاتصال."
},
"sidebar_apps": {
"modal_title": "تطبيقات الشريط الجانبي",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "El que heu compartit ja no hi és. Torneu a compartir-ho.",
"share_files_lost": "No s'han pogut conservar els fitxers compartits. Torneu-los a afegir al missatge.",
"ics_needs_server": "Per importar un fitxer de calendari cal connexió amb el servidor. La importació començarà quan torneu a estar en línia.",
"contacts_need_server": "Per importar contactes cal connexió amb el servidor. Torneu-ho a provar quan torneu a estar en línia."
"contacts_need_server": "Per importar contactes cal connexió amb el servidor. Torneu-ho a provar quan torneu a estar en línia.",
"share_confirm_title": "Voleu començar un missatge nou?",
"share_confirm_message": "Un altre lloc o aplicació vol començar un missatge. No es puja ni es desa res si no hi esteu d'acord.",
"share_confirm_subject": "Assumpte:",
"share_confirm_text": "Text:",
"share_confirm_files": "{count, plural, one {Fitxer:} other {Fitxers:}}",
"share_confirm_accept": "Comença el missatge",
"share_confirm_decline": "No el comencis",
"share_cut": "El text compartit era massa llarg i s'ha escurçat.",
"handoff_expired": "Una cosa que heu obert o compartit ha esperat massa i ja no hi és. Torneu-la a obrir o compartir.",
"compose_waiting": "Tanqueu aquest missatge per començar el següent.",
"save_first_failed": "No s'ha pogut desar el missatge, per això no s'ha obert el fitxer. Deseu el missatge i obriu el calendari o els contactes."
},
"sidebar_apps": {
"modal_title": "Aplicacions de la barra lateral",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "To, co jste sdíleli, už tu není. Sdílejte to prosím znovu.",
"share_files_lost": "Sdílené soubory se nepodařilo uchovat. Přidejte je do zprávy znovu.",
"ics_needs_server": "Import souboru kalendáře vyžaduje připojení k serveru. Import začne, až budete znovu online.",
"contacts_need_server": "Import kontaktů vyžaduje připojení k serveru. Zkuste to znovu, až budete online."
"contacts_need_server": "Import kontaktů vyžaduje připojení k serveru. Zkuste to znovu, až budete online.",
"share_confirm_title": "Začít novou zprávu?",
"share_confirm_message": "Jiný web nebo aplikace chce začít zprávu. Bez vašeho souhlasu se nic nenahraje ani neuloží.",
"share_confirm_subject": "Předmět:",
"share_confirm_text": "Text:",
"share_confirm_files": "{count, plural, one {Soubor:} few {Soubory:} many {Souborů:} other {Soubory:}}",
"share_confirm_accept": "Začít zprávu",
"share_confirm_decline": "Nezačínat",
"share_cut": "Sdílený text byl příliš dlouhý a byl zkrácen.",
"handoff_expired": "To, co jste otevřeli nebo sdíleli, čekalo příliš dlouho a už tu není. Otevřete to nebo sdílejte znovu.",
"compose_waiting": "Zavřete tuto zprávu, abyste mohli začít další.",
"save_first_failed": "Zprávu se nepodařilo uložit, proto se soubor neotevřel. Uložte zprávu a pak otevřete kalendář nebo kontakty."
},
"sidebar_apps": {
"modal_title": "Aplikace postranního panelu",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "Det, du delte, er her ikke længere. Del det venligst igen.",
"share_files_lost": "De delte filer kunne ikke gemmes. Tilføj dem til beskeden igen.",
"ics_needs_server": "Import af en kalenderfil kræver forbindelse til serveren. Importen starter, når du er online igen.",
"contacts_need_server": "Import af kontakter kræver forbindelse til serveren. Prøv igen, når du er online igen."
"contacts_need_server": "Import af kontakter kræver forbindelse til serveren. Prøv igen, når du er online igen.",
"share_confirm_title": "Vil du starte en ny besked?",
"share_confirm_message": "Et andet websted eller en anden app vil starte en besked. Intet uploades eller gemmes, medmindre du accepterer.",
"share_confirm_subject": "Emne:",
"share_confirm_text": "Tekst:",
"share_confirm_files": "{count, plural, one {Fil:} other {Filer:}}",
"share_confirm_accept": "Start besked",
"share_confirm_decline": "Start ikke",
"share_cut": "Den delte tekst var for lang og er blevet forkortet.",
"handoff_expired": "Noget, du åbnede eller delte, ventede for længe og er væk. Åbn eller del det igen.",
"compose_waiting": "Luk denne besked for at starte den næste.",
"save_first_failed": "Din besked kunne ikke gemmes, så filen er ikke åbnet. Gem beskeden, og åbn derefter kalenderen eller kontakterne."
},
"sidebar_apps": {
"modal_title": "Sidepanel-apps",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "Das Geteilte ist nicht mehr vorhanden. Bitte teilen Sie es noch einmal.",
"share_files_lost": "Die geteilten Dateien konnten nicht aufbewahrt werden. Fügen Sie sie der Nachricht bitte noch einmal hinzu.",
"ics_needs_server": "Zum Importieren einer Kalenderdatei wird eine Verbindung zum Server gebraucht. Der Import beginnt, sobald Sie wieder online sind.",
"contacts_need_server": "Zum Importieren von Kontakten wird eine Verbindung zum Server gebraucht. Versuchen Sie es noch einmal, wenn Sie wieder online sind."
"contacts_need_server": "Zum Importieren von Kontakten wird eine Verbindung zum Server gebraucht. Versuchen Sie es noch einmal, wenn Sie wieder online sind.",
"share_confirm_title": "Neue Nachricht beginnen?",
"share_confirm_message": "Eine andere Website oder App möchte eine Nachricht beginnen. Es wird nichts hochgeladen oder gespeichert, wenn Sie nicht zustimmen.",
"share_confirm_subject": "Betreff:",
"share_confirm_text": "Text:",
"share_confirm_files": "{count, plural, one {Datei:} other {Dateien:}}",
"share_confirm_accept": "Nachricht beginnen",
"share_confirm_decline": "Nicht beginnen",
"share_cut": "Der geteilte Text war zu lang und wurde gekürzt.",
"handoff_expired": "Etwas, das Sie geöffnet oder geteilt haben, hat zu lange gewartet und ist nicht mehr da. Bitte öffnen oder teilen Sie es noch einmal.",
"compose_waiting": "Schließen Sie diese Nachricht, um die nächste zu beginnen.",
"save_first_failed": "Ihre Nachricht konnte nicht gespeichert werden, deshalb wurde die Datei nicht geöffnet. Speichern Sie die Nachricht und öffnen Sie dann den Kalender oder die Kontakte."
},
"sidebar_apps": {
"modal_title": "Sidebar-Apps",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "What you shared is no longer here. Please share it again.",
"share_files_lost": "The shared files couldn't be kept. Add them to the message again.",
"ics_needs_server": "Importing a calendar file needs a connection to the server. The import starts when you're back online.",
"contacts_need_server": "Importing contacts needs a connection to the server. Try again when you're back online."
"contacts_need_server": "Importing contacts needs a connection to the server. Try again when you're back online.",
"share_confirm_title": "Start a new message?",
"share_confirm_message": "Another site or app wants to start a message. Nothing is uploaded or saved unless you agree.",
"share_confirm_subject": "Subject:",
"share_confirm_text": "Text:",
"share_confirm_files": "{count, plural, one {File:} other {Files:}}",
"share_confirm_accept": "Start message",
"share_confirm_decline": "Don't start",
"share_cut": "The shared text was too long and has been shortened.",
"handoff_expired": "Something you opened or shared waited too long and is gone. Please open or share it again.",
"compose_waiting": "Close this message to start the next one.",
"save_first_failed": "Your message couldn't be saved, so the file hasn't been opened. Save the message, then open the calendar or contacts."
},
"sidebar_apps": {
"modal_title": "Sidebar apps",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "Lo que compartiste ya no está aquí. Compártelo de nuevo.",
"share_files_lost": "No se pudieron conservar los archivos compartidos. Añádelos de nuevo al mensaje.",
"ics_needs_server": "Importar un archivo de calendario requiere conexión con el servidor. La importación empezará cuando vuelvas a estar en línea.",
"contacts_need_server": "Importar contactos requiere conexión con el servidor. Inténtalo de nuevo cuando vuelvas a estar en línea."
"contacts_need_server": "Importar contactos requiere conexión con el servidor. Inténtalo de nuevo cuando vuelvas a estar en línea.",
"share_confirm_title": "¿Empezar un mensaje nuevo?",
"share_confirm_message": "Otro sitio o aplicación quiere empezar un mensaje. No se sube ni se guarda nada si no aceptas.",
"share_confirm_subject": "Asunto:",
"share_confirm_text": "Texto:",
"share_confirm_files": "{count, plural, one {Archivo:} other {Archivos:}}",
"share_confirm_accept": "Empezar mensaje",
"share_confirm_decline": "No empezar",
"share_cut": "El texto compartido era demasiado largo y se ha acortado.",
"handoff_expired": "Algo que abriste o compartiste esperó demasiado y ya no está. Ábrelo o compártelo de nuevo.",
"compose_waiting": "Cierra este mensaje para empezar el siguiente.",
"save_first_failed": "No se pudo guardar tu mensaje, así que el archivo no se ha abierto. Guarda el mensaje y luego abre el calendario o los contactos."
},
"sidebar_apps": {
"modal_title": "Aplicaciones de la barra lateral",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "See, mida jagasid, pole enam siin. Jaga seda palun uuesti.",
"share_files_lost": "Jagatud faile ei saanud alles hoida. Lisa need kirjale uuesti.",
"ics_needs_server": "Kalendrifaili importimiseks on vaja ühendust serveriga. Import algab, kui oled jälle võrgus.",
"contacts_need_server": "Kontaktide importimiseks on vaja ühendust serveriga. Proovi uuesti, kui oled jälle võrgus."
"contacts_need_server": "Kontaktide importimiseks on vaja ühendust serveriga. Proovi uuesti, kui oled jälle võrgus.",
"share_confirm_title": "Kas alustada uut kirja?",
"share_confirm_message": "Teine sait või rakendus soovib kirja alustada. Ilma sinu nõusolekuta ei laadita ega salvestata midagi.",
"share_confirm_subject": "Teema:",
"share_confirm_text": "Tekst:",
"share_confirm_files": "{count, plural, one {Fail:} other {Failid:}}",
"share_confirm_accept": "Alusta kirja",
"share_confirm_decline": "Ära alusta",
"share_cut": "Jagatud tekst oli liiga pikk ja seda lühendati.",
"handoff_expired": "Midagi, mille avasid või jagasid, ootas liiga kaua ja seda pole enam. Ava või jaga see uuesti.",
"compose_waiting": "Järgmise alustamiseks sulge see kiri.",
"save_first_failed": "Kirja ei saanud salvestada, seega faili ei avatud. Salvesta kiri ja ava siis kalender või kontaktid."
},
"sidebar_apps": {
"modal_title": "Külgriba rakendused",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "چیزی که هم‌رسانی کردید دیگر اینجا نیست. لطفاً دوباره آن را هم‌رسانی کنید.",
"share_files_lost": "فایل‌های هم‌رسانی‌شده نگه داشته نشدند. دوباره آن‌ها را به پیام اضافه کنید.",
"ics_needs_server": "وارد کردن فایل تقویم به اتصال به سرور نیاز دارد. وقتی دوباره آنلاین شوید، وارد کردن شروع می‌شود.",
"contacts_need_server": "وارد کردن مخاطبان به اتصال به سرور نیاز دارد. وقتی دوباره آنلاین شدید، دوباره امتحان کنید."
"contacts_need_server": "وارد کردن مخاطبان به اتصال به سرور نیاز دارد. وقتی دوباره آنلاین شدید، دوباره امتحان کنید.",
"share_confirm_title": "پیام جدیدی شروع شود؟",
"share_confirm_message": "سایت یا برنامه دیگری می‌خواهد پیامی را شروع کند. تا موافقت نکنید چیزی بارگذاری یا ذخیره نمی‌شود.",
"share_confirm_subject": "موضوع:",
"share_confirm_text": "متن:",
"share_confirm_files": "{count, plural, one {فایل:} other {فایل‌ها:}}",
"share_confirm_accept": "شروع پیام",
"share_confirm_decline": "شروع نشود",
"share_cut": "متن هم‌رسانی‌شده خیلی طولانی بود و کوتاه شد.",
"handoff_expired": "چیزی که باز یا هم‌رسانی کردید بیش از حد منتظر ماند و دیگر نیست. لطفاً دوباره آن را باز یا هم‌رسانی کنید.",
"compose_waiting": "برای شروع پیام بعدی این پیام را ببندید.",
"save_first_failed": "پیام شما ذخیره نشد، بنابراین فایل باز نشد. پیام را ذخیره کنید و سپس تقویم یا مخاطبان را باز کنید."
},
"sidebar_apps": {
"modal_title": "برنامه‌های نوار کناری",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "Ce que vous avez partagé n'est plus disponible. Partagez-le à nouveau.",
"share_files_lost": "Les fichiers partagés n'ont pas pu être conservés. Ajoutez-les de nouveau au message.",
"ics_needs_server": "L'importation d'un fichier de calendrier nécessite une connexion au serveur. Elle démarrera dès que vous serez de nouveau en ligne.",
"contacts_need_server": "L'importation de contacts nécessite une connexion au serveur. Réessayez quand vous serez de nouveau en ligne."
"contacts_need_server": "L'importation de contacts nécessite une connexion au serveur. Réessayez quand vous serez de nouveau en ligne.",
"share_confirm_title": "Commencer un nouveau message ?",
"share_confirm_message": "Un autre site ou une autre application veut commencer un message. Rien n'est envoyé ni enregistré sans votre accord.",
"share_confirm_subject": "Objet :",
"share_confirm_text": "Texte :",
"share_confirm_files": "{count, plural, one {Fichier :} other {Fichiers :}}",
"share_confirm_accept": "Commencer le message",
"share_confirm_decline": "Ne pas commencer",
"share_cut": "Le texte partagé était trop long et a été raccourci.",
"handoff_expired": "Un élément que vous avez ouvert ou partagé a attendu trop longtemps et n'est plus disponible. Ouvrez-le ou partagez-le à nouveau.",
"compose_waiting": "Fermez ce message pour commencer le suivant.",
"save_first_failed": "Votre message n'a pas pu être enregistré, le fichier n'a donc pas été ouvert. Enregistrez le message, puis ouvrez le calendrier ou les contacts."
},
"sidebar_apps": {
"modal_title": "Applications de la barre latérale",
+12 -1
View File
@@ -5281,7 +5281,18 @@
"share_gone": "מה ששיתפתם כבר לא נמצא כאן. שתפו אותו שוב.",
"share_files_lost": "לא ניתן היה לשמור את הקבצים ששותפו. הוסיפו אותם שוב להודעה.",
"ics_needs_server": "ייבוא קובץ לוח שנה דורש חיבור לשרת. הייבוא יתחיל כשתחזרו להיות מחוברים.",
"contacts_need_server": "ייבוא אנשי קשר דורש חיבור לשרת. נסו שוב כשתחזרו להיות מחוברים."
"contacts_need_server": "ייבוא אנשי קשר דורש חיבור לשרת. נסו שוב כשתחזרו להיות מחוברים.",
"share_confirm_title": "להתחיל הודעה חדשה?",
"share_confirm_message": "אתר או אפליקציה אחרים רוצים להתחיל הודעה. שום דבר לא יועלה או יישמר בלי הסכמתכם.",
"share_confirm_subject": "נושא:",
"share_confirm_text": "טקסט:",
"share_confirm_files": "{count, plural, one {קובץ:} other {קבצים:}}",
"share_confirm_accept": "התחלת הודעה",
"share_confirm_decline": "לא להתחיל",
"share_cut": "הטקסט ששותף היה ארוך מדי וקוצר.",
"handoff_expired": "משהו שפתחתם או שיתפתם המתין יותר מדי זמן וכבר אינו כאן. פתחו או שתפו אותו שוב.",
"compose_waiting": "סגרו הודעה זו כדי להתחיל את הבאה.",
"save_first_failed": "לא ניתן היה לשמור את ההודעה, לכן הקובץ לא נפתח. שמרו את ההודעה ואז פתחו את לוח השנה או את אנשי הקשר."
},
"mailbox_context_menu": {
"mark_folder_read": "סמן תיקייה כקרויה",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "Amit megosztott, már nincs itt. Kérjük, ossza meg újra.",
"share_files_lost": "A megosztott fájlokat nem sikerült megőrizni. Adja hozzá őket újra az üzenethez.",
"ics_needs_server": "A naptárfájl importálásához kapcsolat kell a szerverrel. Az importálás akkor indul el, amikor újra online lesz.",
"contacts_need_server": "A névjegyek importálásához kapcsolat kell a szerverrel. Próbálja újra, amikor ismét online lesz."
"contacts_need_server": "A névjegyek importálásához kapcsolat kell a szerverrel. Próbálja újra, amikor ismét online lesz.",
"share_confirm_title": "Új üzenetet kezd?",
"share_confirm_message": "Egy másik webhely vagy alkalmazás üzenetet szeretne kezdeni. Az Ön beleegyezése nélkül semmi sem kerül feltöltésre vagy mentésre.",
"share_confirm_subject": "Tárgy:",
"share_confirm_text": "Szöveg:",
"share_confirm_files": "{count, plural, one {Fájl:} other {Fájlok:}}",
"share_confirm_accept": "Üzenet kezdése",
"share_confirm_decline": "Ne kezdje el",
"share_cut": "A megosztott szöveg túl hosszú volt, ezért rövidítettük.",
"handoff_expired": "Valami, amit megnyitott vagy megosztott, túl sokáig várakozott, és már nincs meg. Kérjük, nyissa meg vagy ossza meg újra.",
"compose_waiting": "Zárja be ezt az üzenetet a következő kezdéséhez.",
"save_first_failed": "Az üzenetet nem sikerült menteni, ezért a fájl nem nyílt meg. Mentse az üzenetet, majd nyissa meg a naptárat vagy a névjegyeket."
},
"sidebar_apps": {
"modal_title": "Oldalsáv alkalmazások",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "Ciò che hai condiviso non è più disponibile. Condividilo di nuovo.",
"share_files_lost": "Non è stato possibile conservare i file condivisi. Aggiungili di nuovo al messaggio.",
"ics_needs_server": "Per importare un file di calendario serve una connessione al server. L'importazione inizierà quando tornerai online.",
"contacts_need_server": "Per importare i contatti serve una connessione al server. Riprova quando tornerai online."
"contacts_need_server": "Per importare i contatti serve una connessione al server. Riprova quando tornerai online.",
"share_confirm_title": "Iniziare un nuovo messaggio?",
"share_confirm_message": "Un altro sito o app vuole iniziare un messaggio. Non viene caricato o salvato nulla senza il tuo consenso.",
"share_confirm_subject": "Oggetto:",
"share_confirm_text": "Testo:",
"share_confirm_files": "{count, plural, one {File:} other {File:}}",
"share_confirm_accept": "Inizia messaggio",
"share_confirm_decline": "Non iniziare",
"share_cut": "Il testo condiviso era troppo lungo ed è stato accorciato.",
"handoff_expired": "Qualcosa che hai aperto o condiviso ha atteso troppo a lungo e non c'è più. Aprilo o condividilo di nuovo.",
"compose_waiting": "Chiudi questo messaggio per iniziare il successivo.",
"save_first_failed": "Non è stato possibile salvare il messaggio, quindi il file non è stato aperto. Salva il messaggio, poi apri il calendario o i contatti."
},
"sidebar_apps": {
"modal_title": "App della barra laterale",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "共有した内容はもうありません。もう一度共有してください。",
"share_files_lost": "共有したファイルを保持できませんでした。メッセージにもう一度追加してください。",
"ics_needs_server": "カレンダーファイルのインポートにはサーバーへの接続が必要です。オンラインに戻るとインポートが始まります。",
"contacts_need_server": "連絡先のインポートにはサーバーへの接続が必要です。オンラインに戻ってからもう一度お試しください。"
"contacts_need_server": "連絡先のインポートにはサーバーへの接続が必要です。オンラインに戻ってからもう一度お試しください。",
"share_confirm_title": "新しいメッセージを開始しますか?",
"share_confirm_message": "別のサイトまたはアプリがメッセージを開始しようとしています。同意しない限り、何もアップロードも保存もされません。",
"share_confirm_subject": "件名:",
"share_confirm_text": "本文:",
"share_confirm_files": "{count, plural, other {ファイル:}}",
"share_confirm_accept": "メッセージを開始",
"share_confirm_decline": "開始しない",
"share_cut": "共有されたテキストが長すぎるため短縮されました。",
"handoff_expired": "開いた、または共有した内容は待機時間が長すぎたためなくなりました。もう一度開くか共有してください。",
"compose_waiting": "次のメッセージを開始するには、このメッセージを閉じてください。",
"save_first_failed": "メッセージを保存できなかったため、ファイルを開いていません。メッセージを保存してから、カレンダーまたは連絡先を開いてください。"
},
"sidebar_apps": {
"modal_title": "サイドバーアプリ",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "공유한 내용이 더 이상 없습니다. 다시 공유해 주세요.",
"share_files_lost": "공유한 파일을 보관할 수 없습니다. 메시지에 다시 추가해 주세요.",
"ics_needs_server": "캘린더 파일을 가져오려면 서버에 연결되어 있어야 합니다. 다시 온라인 상태가 되면 가져오기가 시작됩니다.",
"contacts_need_server": "연락처를 가져오려면 서버에 연결되어 있어야 합니다. 다시 온라인 상태가 되면 다시 시도해 주세요."
"contacts_need_server": "연락처를 가져오려면 서버에 연결되어 있어야 합니다. 다시 온라인 상태가 되면 다시 시도해 주세요.",
"share_confirm_title": "새 메시지를 시작할까요?",
"share_confirm_message": "다른 사이트나 앱에서 메시지를 시작하려고 합니다. 동의하지 않으면 아무것도 업로드하거나 저장하지 않습니다.",
"share_confirm_subject": "제목:",
"share_confirm_text": "내용:",
"share_confirm_files": "{count, plural, other {파일:}}",
"share_confirm_accept": "메시지 시작",
"share_confirm_decline": "시작 안 함",
"share_cut": "공유한 텍스트가 너무 길어서 줄였습니다.",
"handoff_expired": "열었거나 공유한 항목이 너무 오래 대기하여 사라졌습니다. 다시 열거나 공유해 주세요.",
"compose_waiting": "다음 메시지를 시작하려면 이 메시지를 닫으세요.",
"save_first_failed": "메시지를 저장할 수 없어 파일을 열지 않았습니다. 메시지를 저장한 다음 캘린더나 연락처를 여세요."
},
"sidebar_apps": {
"modal_title": "사이드바 앱",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "Tas, ko kopīgojāt, šeit vairs nav. Lūdzu, kopīgojiet to vēlreiz.",
"share_files_lost": "Kopīgotos failus neizdevās saglabāt. Pievienojiet tos ziņai vēlreiz.",
"ics_needs_server": "Kalendāra faila importēšanai vajadzīgs savienojums ar serveri. Importēšana sāksies, kad atkal būsiet tiešsaistē.",
"contacts_need_server": "Kontaktu importēšanai vajadzīgs savienojums ar serveri. Mēģiniet vēlreiz, kad atkal būsiet tiešsaistē."
"contacts_need_server": "Kontaktu importēšanai vajadzīgs savienojums ar serveri. Mēģiniet vēlreiz, kad atkal būsiet tiešsaistē.",
"share_confirm_title": "Vai sākt jaunu ziņu?",
"share_confirm_message": "Cita vietne vai lietotne vēlas sākt ziņu. Bez jūsu piekrišanas nekas netiek augšupielādēts vai saglabāts.",
"share_confirm_subject": "Tēma:",
"share_confirm_text": "Teksts:",
"share_confirm_files": "{count, plural, zero {Faili:} one {Fails:} other {Faili:}}",
"share_confirm_accept": "Sākt ziņu",
"share_confirm_decline": "Nesākt",
"share_cut": "Kopīgotais teksts bija pārāk garš un tika saīsināts.",
"handoff_expired": "Tas, ko atvērāt vai kopīgojāt, gaidīja pārāk ilgi un vairs nav pieejams. Atveriet vai kopīgojiet to vēlreiz.",
"compose_waiting": "Aizveriet šo ziņu, lai sāktu nākamo.",
"save_first_failed": "Ziņu neizdevās saglabāt, tāpēc fails netika atvērts. Saglabājiet ziņu un pēc tam atveriet kalendāru vai kontaktus."
},
"sidebar_apps": {
"modal_title": "Sānu joslas lietotnes",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "Таны хуваалцсан зүйл энд байхгүй болсон. Дахин хуваалцана уу.",
"share_files_lost": "Хуваалцсан файлуудыг хадгалж чадсангүй. Зурваст дахин нэмнэ үү.",
"ics_needs_server": "Хуанлийн файл импортлоход сервертэй холболт хэрэгтэй. Та дахин онлайн болоход импорт эхэлнэ.",
"contacts_need_server": "Харилцагчдыг импортлоход сервертэй холболт хэрэгтэй. Дахин онлайн болсон үедээ оролдоно уу."
"contacts_need_server": "Харилцагчдыг импортлоход сервертэй холболт хэрэгтэй. Дахин онлайн болсон үедээ оролдоно уу.",
"share_confirm_title": "Шинэ зурвас эхлүүлэх үү?",
"share_confirm_message": "Өөр сайт эсвэл апп зурвас эхлүүлэхийг хүсэж байна. Та зөвшөөрөхгүй бол юу ч байршуулахгүй, хадгалахгүй.",
"share_confirm_subject": "Гарчиг:",
"share_confirm_text": "Текст:",
"share_confirm_files": "{count, plural, one {Файл:} other {Файлууд:}}",
"share_confirm_accept": "Зурвас эхлүүлэх",
"share_confirm_decline": "Эхлүүлэхгүй",
"share_cut": "Хуваалцсан текст хэт урт байсан тул богиносгосон.",
"handoff_expired": "Таны нээсэн эсвэл хуваалцсан зүйл хэт удаан хүлээсэн тул байхгүй болсон. Дахин нээх эсвэл хуваалцана уу.",
"compose_waiting": "Дараагийнхыг эхлүүлэхийн тулд энэ зурвасыг хаана уу.",
"save_first_failed": "Зурвасыг хадгалж чадаагүй тул файлыг нээгээгүй. Зурвасаа хадгалаад хуанли эсвэл харилцагчдыг нээнэ үү."
},
"sidebar_apps": {
"modal_title": "Sidebar програмууд",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "Det du delte, er ikke her lenger. Del det på nytt.",
"share_files_lost": "De delte filene kunne ikke tas vare på. Legg dem til i meldingen på nytt.",
"ics_needs_server": "Import av en kalenderfil krever tilkobling til serveren. Importen starter når du er på nett igjen.",
"contacts_need_server": "Import av kontakter krever tilkobling til serveren. Prøv igjen når du er på nett igjen."
"contacts_need_server": "Import av kontakter krever tilkobling til serveren. Prøv igjen når du er på nett igjen.",
"share_confirm_title": "Vil du starte en ny melding?",
"share_confirm_message": "Et annet nettsted eller en annen app vil starte en melding. Ingenting lastes opp eller lagres uten at du godtar det.",
"share_confirm_subject": "Emne:",
"share_confirm_text": "Tekst:",
"share_confirm_files": "{count, plural, one {Fil:} other {Filer:}}",
"share_confirm_accept": "Start melding",
"share_confirm_decline": "Ikke start",
"share_cut": "Den delte teksten var for lang og er forkortet.",
"handoff_expired": "Noe du åpnet eller delte, ventet for lenge og er borte. Åpne eller del det på nytt.",
"compose_waiting": "Lukk denne meldingen for å starte den neste.",
"save_first_failed": "Meldingen kunne ikke lagres, så filen er ikke åpnet. Lagre meldingen, og åpne deretter kalenderen eller kontaktene."
},
"sidebar_apps": {
"modal_title": "Apper i sidefeltet",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "Wat je hebt gedeeld, is er niet meer. Deel het opnieuw.",
"share_files_lost": "De gedeelde bestanden konden niet worden bewaard. Voeg ze opnieuw aan het bericht toe.",
"ics_needs_server": "Voor het importeren van een agendabestand is een verbinding met de server nodig. Het importeren begint zodra je weer online bent.",
"contacts_need_server": "Voor het importeren van contacten is een verbinding met de server nodig. Probeer het opnieuw als je weer online bent."
"contacts_need_server": "Voor het importeren van contacten is een verbinding met de server nodig. Probeer het opnieuw als je weer online bent.",
"share_confirm_title": "Een nieuw bericht beginnen?",
"share_confirm_message": "Een andere site of app wil een bericht beginnen. Er wordt niets geüpload of opgeslagen zonder je toestemming.",
"share_confirm_subject": "Onderwerp:",
"share_confirm_text": "Tekst:",
"share_confirm_files": "{count, plural, one {Bestand:} other {Bestanden:}}",
"share_confirm_accept": "Bericht beginnen",
"share_confirm_decline": "Niet beginnen",
"share_cut": "De gedeelde tekst was te lang en is ingekort.",
"handoff_expired": "Iets wat je hebt geopend of gedeeld, heeft te lang gewacht en is weg. Open of deel het opnieuw.",
"compose_waiting": "Sluit dit bericht om het volgende te beginnen.",
"save_first_failed": "Je bericht kon niet worden opgeslagen, dus het bestand is niet geopend. Sla het bericht op en open daarna de agenda of de contacten."
},
"sidebar_apps": {
"modal_title": "Zijbalk-apps",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "To, co udostępniono, już tu nie istnieje. Udostępnij to ponownie.",
"share_files_lost": "Nie udało się zachować udostępnionych plików. Dodaj je ponownie do wiadomości.",
"ics_needs_server": "Import pliku kalendarza wymaga połączenia z serwerem. Import zacznie się, gdy znów będziesz online.",
"contacts_need_server": "Import kontaktów wymaga połączenia z serwerem. Spróbuj ponownie, gdy znów będziesz online."
"contacts_need_server": "Import kontaktów wymaga połączenia z serwerem. Spróbuj ponownie, gdy znów będziesz online.",
"share_confirm_title": "Rozpocząć nową wiadomość?",
"share_confirm_message": "Inna witryna lub aplikacja chce rozpocząć wiadomość. Bez Twojej zgody nic nie zostanie wysłane ani zapisane.",
"share_confirm_subject": "Temat:",
"share_confirm_text": "Tekst:",
"share_confirm_files": "{count, plural, one {Plik:} few {Pliki:} many {Pliki:} other {Pliki:}}",
"share_confirm_accept": "Rozpocznij wiadomość",
"share_confirm_decline": "Nie rozpoczynaj",
"share_cut": "Udostępniony tekst był za długi i został skrócony.",
"handoff_expired": "To, co otwarto lub udostępniono, czekało zbyt długo i już go nie ma. Otwórz lub udostępnij to ponownie.",
"compose_waiting": "Zamknij tę wiadomość, aby rozpocząć następną.",
"save_first_failed": "Nie udało się zapisać wiadomości, więc plik nie został otwarty. Zapisz wiadomość, a potem otwórz kalendarz lub kontakty."
},
"sidebar_apps": {
"modal_title": "Aplikacje paska bocznego",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "O que partilhou já não está aqui. Partilhe novamente.",
"share_files_lost": "Não foi possível guardar os ficheiros partilhados. Adicione-os novamente à mensagem.",
"ics_needs_server": "Importar um ficheiro de calendário precisa de uma ligação ao servidor. A importação começa quando voltar a estar online.",
"contacts_need_server": "Importar contactos precisa de uma ligação ao servidor. Tente novamente quando voltar a estar online."
"contacts_need_server": "Importar contactos precisa de uma ligação ao servidor. Tente novamente quando voltar a estar online.",
"share_confirm_title": "Começar uma nova mensagem?",
"share_confirm_message": "Outro site ou aplicação quer começar uma mensagem. Nada é carregado ou guardado sem o seu acordo.",
"share_confirm_subject": "Assunto:",
"share_confirm_text": "Texto:",
"share_confirm_files": "{count, plural, one {Ficheiro:} other {Ficheiros:}}",
"share_confirm_accept": "Começar mensagem",
"share_confirm_decline": "Não começar",
"share_cut": "O texto partilhado era demasiado longo e foi encurtado.",
"handoff_expired": "Algo que abriu ou partilhou esperou demasiado tempo e já não está disponível. Abra ou partilhe novamente.",
"compose_waiting": "Feche esta mensagem para começar a seguinte.",
"save_first_failed": "Não foi possível guardar a mensagem, por isso o ficheiro não foi aberto. Guarde a mensagem e depois abra o calendário ou os contactos."
},
"sidebar_apps": {
"modal_title": "Apps da barra lateral",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "Ce ați partajat nu mai este aici. Partajați din nou.",
"share_files_lost": "Fișierele partajate nu au putut fi păstrate. Adăugați-le din nou la mesaj.",
"ics_needs_server": "Importul unui fișier de calendar are nevoie de conexiune la server. Importul începe când reveniți online.",
"contacts_need_server": "Importul contactelor are nevoie de conexiune la server. Încercați din nou când reveniți online."
"contacts_need_server": "Importul contactelor are nevoie de conexiune la server. Încercați din nou când reveniți online.",
"share_confirm_title": "Începeți un mesaj nou?",
"share_confirm_message": "Alt site sau altă aplicație vrea să înceapă un mesaj. Nu se încarcă și nu se salvează nimic fără acordul dvs.",
"share_confirm_subject": "Subiect:",
"share_confirm_text": "Text:",
"share_confirm_files": "{count, plural, one {Fișier:} few {Fișiere:} other {Fișiere:}}",
"share_confirm_accept": "Începeți mesajul",
"share_confirm_decline": "Nu începeți",
"share_cut": "Textul partajat era prea lung și a fost scurtat.",
"handoff_expired": "Ceva ce ați deschis sau partajat a așteptat prea mult și nu mai este disponibil. Deschideți sau partajați din nou.",
"compose_waiting": "Închideți acest mesaj pentru a-l începe pe următorul.",
"save_first_failed": "Mesajul nu a putut fi salvat, așa că fișierul nu a fost deschis. Salvați mesajul, apoi deschideți calendarul sau contactele."
},
"sidebar_apps": {
"modal_title": "Aplicații din bara laterală",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "То, чем вы поделились, больше недоступно. Поделитесь ещё раз.",
"share_files_lost": "Не удалось сохранить полученные файлы. Добавьте их в письмо ещё раз.",
"ics_needs_server": "Для импорта файла календаря нужно подключение к серверу. Импорт начнётся, когда вы снова будете в сети.",
"contacts_need_server": "Для импорта контактов нужно подключение к серверу. Попробуйте ещё раз, когда снова будете в сети."
"contacts_need_server": "Для импорта контактов нужно подключение к серверу. Попробуйте ещё раз, когда снова будете в сети.",
"share_confirm_title": "Начать новое письмо?",
"share_confirm_message": "Другой сайт или приложение хочет начать письмо. Без вашего согласия ничего не будет загружено или сохранено.",
"share_confirm_subject": "Тема:",
"share_confirm_text": "Текст:",
"share_confirm_files": "{count, plural, one {Файл:} few {Файлы:} many {Файлы:} other {Файлы:}}",
"share_confirm_accept": "Начать письмо",
"share_confirm_decline": "Не начинать",
"share_cut": "Полученный текст был слишком длинным и был сокращён.",
"handoff_expired": "То, что вы открыли или чем поделились, ждало слишком долго и больше недоступно. Откройте или поделитесь ещё раз.",
"compose_waiting": "Закройте это письмо, чтобы начать следующее.",
"save_first_failed": "Не удалось сохранить письмо, поэтому файл не открыт. Сохраните письмо, затем откройте календарь или контакты."
},
"sidebar_apps": {
"modal_title": "Приложения боковой панели",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "To, čo ste zdieľali, tu už nie je. Zdieľajte to prosím znova.",
"share_files_lost": "Zdieľané súbory sa nepodarilo uchovať. Pridajte ich do správy znova.",
"ics_needs_server": "Import súboru kalendára vyžaduje pripojenie k serveru. Import sa začne, keď budete znova online.",
"contacts_need_server": "Import kontaktov vyžaduje pripojenie k serveru. Skúste to znova, keď budete online."
"contacts_need_server": "Import kontaktov vyžaduje pripojenie k serveru. Skúste to znova, keď budete online.",
"share_confirm_title": "Začať novú správu?",
"share_confirm_message": "Iný web alebo aplikácia chce začať správu. Bez vášho súhlasu sa nič nenahrá ani neuloží.",
"share_confirm_subject": "Predmet:",
"share_confirm_text": "Text:",
"share_confirm_files": "{count, plural, one {Súbor:} few {Súbory:} many {Súborov:} other {Súbory:}}",
"share_confirm_accept": "Začať správu",
"share_confirm_decline": "Nezačínať",
"share_cut": "Zdieľaný text bol príliš dlhý a bol skrátený.",
"handoff_expired": "To, čo ste otvorili alebo zdieľali, čakalo príliš dlho a už tu nie je. Otvorte to alebo zdieľajte znova.",
"compose_waiting": "Zatvorte túto správu, aby ste mohli začať ďalšiu.",
"save_first_failed": "Správu sa nepodarilo uložiť, preto sa súbor neotvoril. Uložte správu a potom otvorte kalendár alebo kontakty."
},
"sidebar_apps": {
"modal_title": "Aplikácie postranného panela",
+12 -1
View File
@@ -2391,7 +2391,18 @@
"share_gone": "สิ่งที่คุณแชร์ไม่อยู่ที่นี่แล้ว โปรดแชร์อีกครั้ง",
"share_files_lost": "เก็บไฟล์ที่แชร์ไว้ไม่ได้ โปรดเพิ่มไฟล์ลงในข้อความอีกครั้ง",
"ics_needs_server": "การนำเข้าไฟล์ปฏิทินต้องเชื่อมต่อกับเซิร์ฟเวอร์ การนำเข้าจะเริ่มเมื่อคุณกลับมาออนไลน์",
"contacts_need_server": "การนำเข้ารายชื่อติดต่อต้องเชื่อมต่อกับเซิร์ฟเวอร์ โปรดลองอีกครั้งเมื่อคุณกลับมาออนไลน์"
"contacts_need_server": "การนำเข้ารายชื่อติดต่อต้องเชื่อมต่อกับเซิร์ฟเวอร์ โปรดลองอีกครั้งเมื่อคุณกลับมาออนไลน์",
"share_confirm_title": "เริ่มข้อความใหม่หรือไม่",
"share_confirm_message": "เว็บไซต์หรือแอปอื่นต้องการเริ่มข้อความ จะไม่มีการอัปโหลดหรือบันทึกสิ่งใดหากคุณไม่ยินยอม",
"share_confirm_subject": "หัวเรื่อง:",
"share_confirm_text": "ข้อความ:",
"share_confirm_files": "{count, plural, other {ไฟล์:}}",
"share_confirm_accept": "เริ่มข้อความ",
"share_confirm_decline": "ไม่เริ่ม",
"share_cut": "ข้อความที่แชร์ยาวเกินไปจึงถูกตัดให้สั้นลง",
"handoff_expired": "สิ่งที่คุณเปิดหรือแชร์รอนานเกินไปและไม่อยู่แล้ว โปรดเปิดหรือแชร์อีกครั้ง",
"compose_waiting": "ปิดข้อความนี้เพื่อเริ่มข้อความถัดไป",
"save_first_failed": "บันทึกข้อความไม่ได้ จึงยังไม่เปิดไฟล์ โปรดบันทึกข้อความแล้วเปิดปฏิทินหรือรายชื่อติดต่อ"
},
"sidebar_apps": {
"modal_title": "แอปพลิเคชันแถบด้านข้าง",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "Paylaştığınız içerik artık burada değil. Lütfen yeniden paylaşın.",
"share_files_lost": "Paylaşılan dosyalar saklanamadı. Onları iletiye yeniden ekleyin.",
"ics_needs_server": "Takvim dosyasını içe aktarmak için sunucu bağlantısı gerekir. İçe aktarma, yeniden çevrimiçi olduğunuzda başlar.",
"contacts_need_server": "Kişileri içe aktarmak için sunucu bağlantısı gerekir. Yeniden çevrimiçi olduğunuzda tekrar deneyin."
"contacts_need_server": "Kişileri içe aktarmak için sunucu bağlantısı gerekir. Yeniden çevrimiçi olduğunuzda tekrar deneyin.",
"share_confirm_title": "Yeni bir ileti başlatılsın mı?",
"share_confirm_message": "Başka bir site veya uygulama ileti başlatmak istiyor. Siz kabul etmedikçe hiçbir şey yüklenmez veya kaydedilmez.",
"share_confirm_subject": "Konu:",
"share_confirm_text": "Metin:",
"share_confirm_files": "{count, plural, one {Dosya:} other {Dosyalar:}}",
"share_confirm_accept": "İletiyi başlat",
"share_confirm_decline": "Başlatma",
"share_cut": "Paylaşılan metin çok uzundu ve kısaltıldı.",
"handoff_expired": "Açtığınız veya paylaştığınız bir şey çok uzun süre bekledi ve artık yok. Lütfen yeniden açın veya paylaşın.",
"compose_waiting": "Sonrakini başlatmak için bu iletiyi kapatın.",
"save_first_failed": "İletiniz kaydedilemedi, bu yüzden dosya açılmadı. İletiyi kaydedin, ardından takvimi veya kişileri açın."
},
"sidebar_apps": {
"modal_title": "Kenar çubuğu uygulamaları",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "Те, чим ви поділилися, більше недоступне. Поділіться ще раз.",
"share_files_lost": "Не вдалося зберегти отримані файли. Додайте їх до листа ще раз.",
"ics_needs_server": "Для імпорту файлу календаря потрібне підключення до сервера. Імпорт почнеться, коли ви знову будете в мережі.",
"contacts_need_server": "Для імпорту контактів потрібне підключення до сервера. Спробуйте ще раз, коли знову будете в мережі."
"contacts_need_server": "Для імпорту контактів потрібне підключення до сервера. Спробуйте ще раз, коли знову будете в мережі.",
"share_confirm_title": "Почати новий лист?",
"share_confirm_message": "Інший сайт або застосунок хоче почати лист. Без вашої згоди нічого не буде завантажено чи збережено.",
"share_confirm_subject": "Тема:",
"share_confirm_text": "Текст:",
"share_confirm_files": "{count, plural, one {Файл:} few {Файли:} many {Файли:} other {Файли:}}",
"share_confirm_accept": "Почати лист",
"share_confirm_decline": "Не починати",
"share_cut": "Отриманий текст був задовгий, тому його скорочено.",
"handoff_expired": "Те, що ви відкрили або чим поділилися, чекало надто довго і більше недоступне. Відкрийте або поділіться ще раз.",
"compose_waiting": "Закрийте цей лист, щоб почати наступний.",
"save_first_failed": "Не вдалося зберегти лист, тому файл не відкрито. Збережіть лист, а потім відкрийте календар або контакти."
},
"sidebar_apps": {
"modal_title": "Програми бічної панелі",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "Nội dung bạn đã chia sẻ không còn ở đây. Vui lòng chia sẻ lại.",
"share_files_lost": "Không thể giữ các tệp đã chia sẻ. Hãy thêm lại chúng vào thư.",
"ics_needs_server": "Nhập tệp lịch cần kết nối với máy chủ. Quá trình nhập sẽ bắt đầu khi bạn trực tuyến trở lại.",
"contacts_need_server": "Nhập danh bạ cần kết nối với máy chủ. Hãy thử lại khi bạn trực tuyến trở lại."
"contacts_need_server": "Nhập danh bạ cần kết nối với máy chủ. Hãy thử lại khi bạn trực tuyến trở lại.",
"share_confirm_title": "Bắt đầu thư mới?",
"share_confirm_message": "Một trang web hoặc ứng dụng khác muốn bắt đầu một thư. Sẽ không có gì được tải lên hoặc lưu nếu bạn không đồng ý.",
"share_confirm_subject": "Chủ đề:",
"share_confirm_text": "Nội dung:",
"share_confirm_files": "{count, plural, other {Tệp:}}",
"share_confirm_accept": "Bắt đầu thư",
"share_confirm_decline": "Không bắt đầu",
"share_cut": "Văn bản được chia sẻ quá dài và đã được rút ngắn.",
"handoff_expired": "Nội dung bạn đã mở hoặc chia sẻ đã chờ quá lâu và không còn nữa. Vui lòng mở hoặc chia sẻ lại.",
"compose_waiting": "Đóng thư này để bắt đầu thư tiếp theo.",
"save_first_failed": "Không thể lưu thư của bạn nên tệp chưa được mở. Hãy lưu thư, rồi mở lịch hoặc danh bạ."
},
"sidebar_apps": {
"modal_title": "Ứng dụng thanh bên",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "您分享的內容已不在此處。請重新分享。",
"share_files_lost": "無法保留分享的檔案。請將它們重新加入郵件。",
"ics_needs_server": "匯入行事曆檔案需要連線到伺服器。您恢復連線後將開始匯入。",
"contacts_need_server": "匯入聯絡人需要連線到伺服器。請在恢復連線後再試一次。"
"contacts_need_server": "匯入聯絡人需要連線到伺服器。請在恢復連線後再試一次。",
"share_confirm_title": "要開始撰寫新郵件嗎?",
"share_confirm_message": "另一個網站或應用程式想要開始撰寫郵件。除非您同意,否則不會上傳或儲存任何內容。",
"share_confirm_subject": "主旨:",
"share_confirm_text": "內文:",
"share_confirm_files": "{count, plural, other {檔案:}}",
"share_confirm_accept": "開始撰寫郵件",
"share_confirm_decline": "不要開始",
"share_cut": "分享的文字太長,已被縮短。",
"handoff_expired": "您開啟或分享的內容等待太久,已不存在。請重新開啟或分享。",
"compose_waiting": "請關閉此郵件以開始下一封。",
"save_first_failed": "無法儲存您的郵件,因此未開啟檔案。請儲存郵件,然後開啟行事曆或聯絡人。"
},
"sidebar_apps": {
"modal_title": "側邊欄應用程式",
+12 -1
View File
@@ -258,7 +258,18 @@
"share_gone": "您分享的内容已不在此处。请重新分享。",
"share_files_lost": "无法保留分享的文件。请将它们重新添加到邮件中。",
"ics_needs_server": "导入日历文件需要连接到服务器。您恢复联网后将开始导入。",
"contacts_need_server": "导入联系人需要连接到服务器。请在恢复联网后重试。"
"contacts_need_server": "导入联系人需要连接到服务器。请在恢复联网后重试。",
"share_confirm_title": "要开始写新邮件吗?",
"share_confirm_message": "另一个网站或应用想要开始写一封邮件。除非您同意,否则不会上传或保存任何内容。",
"share_confirm_subject": "主题:",
"share_confirm_text": "正文:",
"share_confirm_files": "{count, plural, other {文件:}}",
"share_confirm_accept": "开始写邮件",
"share_confirm_decline": "不开始",
"share_cut": "分享的文本太长,已被缩短。",
"handoff_expired": "您打开或分享的内容等待时间过长,已不存在。请重新打开或分享。",
"compose_waiting": "请关闭此邮件以开始下一封。",
"save_first_failed": "无法保存您的邮件,因此未打开文件。请保存邮件,然后打开日历或联系人。"
},
"sidebar_apps": {
"modal_title": "侧边栏应用",
+102 -13
View File
@@ -62,7 +62,7 @@ self.addEventListener("install", (event) => {
});
self.addEventListener("activate", (event) => {
event.waitUntil(Promise.all([self.clients.claim(), deleteStaleShellCaches()]));
event.waitUntil(Promise.all([self.clients.claim(), deleteStaleShellCaches(), sweepHandoffsOnActivate()]));
});
// ---- Offline mode (#523): the app shell ----
@@ -315,18 +315,92 @@ async function deleteStaleShellCaches() {
//
// The manifest's share_target POSTs here. Files can't travel through a
// redirect, so they wait in the Cache API until the page takes them (same
// layout as lib/launch-handoff.ts, which also removes them). Without a worker
// the POST reaches app/(main)/protocol/share/route.ts.
// layout and limits as lib/launch-handoff.ts, which also removes them). Any
// site can POST here, signed in or not, and the worker can't tell who did: the
// page asks the user. So what is kept is limited, and sealed with a key that
// only the landing address's fragment knows. A browser without share_target
// gets the same question. Without a worker the POST reaches
// app/(main)/protocol/share/route.ts.
const HANDOFF_CACHE = "bulwark-launch-handoff-v1";
const HANDOFF_TTL_MS = 15 * 60 * 1000;
const HANDOFF_MAX_ENTRIES = 5;
const HANDOFF_MAX_BYTES = 100 * 1024 * 1024;
function isShareTarget(request) {
if (request.method !== "POST" || request.mode !== "navigate") return false;
// A document only: a hidden frame doesn't get to store anything.
if (request.method !== "POST" || request.mode !== "navigate" || request.destination !== "document") return false;
const url = new URL(request.url);
return url.origin === self.location.origin && url.pathname.replace(/\/+$/, "") === BASE_PATH + "/protocol/share";
}
/** Drops what is too old and, when `incomingBytes` wants in, the oldest entries in the way. */
async function sweepHandoffs(cache, incomingBytes) {
const keys = await cache.keys();
const pathOf = (key) => new URL(key.url, self.location.origin).pathname;
const entries = [];
for (const key of keys) {
const path = pathOf(key);
if (!path.endsWith("/meta")) continue;
const response = await cache.match(key);
const meta = response ? await response.json().catch(() => null) : null;
entries.push({ prefix: path.slice(0, -"meta".length), createdAt: (meta && meta.createdAt) || 0, bytes: (meta && meta.bytes) || 0 });
}
entries.sort((a, b) => a.createdAt - b.createdAt);
let total = entries.reduce((sum, entry) => sum + entry.bytes, 0);
while (entries.length > 0) {
const tooOld = Date.now() - entries[0].createdAt > HANDOFF_TTL_MS;
const inTheWay = incomingBytes !== null && (entries.length >= HANDOFF_MAX_ENTRIES || total + incomingBytes > HANDOFF_MAX_BYTES);
if (!tooOld && !inTheWay) break;
const oldest = entries.shift();
total -= oldest.bytes;
const own = keys.filter((key) => pathOf(key).startsWith(oldest.prefix));
// The list last: this sweep goes by it.
await Promise.all(own.filter((key) => !pathOf(key).endsWith("/meta")).map((key) => cache.delete(key)));
await cache.delete(oldest.prefix + "meta");
}
}
async function sweepHandoffsOnActivate() {
try {
if (typeof caches === "undefined" || !(await caches.has(HANDOFF_CACHE))) return;
await sweepHandoffs(await caches.open(HANDOFF_CACHE), null);
} catch (_) {
// Tried again at the next share.
}
}
/** A 12-byte IV, then the AES-GCM ciphertext. */
async function sealHandoff(key, data) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const body = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, data));
const sealed = new Uint8Array(iv.length + body.length);
sealed.set(iv);
sealed.set(body, iv.length);
return sealed;
}
/** As much of `value` as takes up to `budget` bytes once encoded, as in the server's fallback. */
function fitEncoded(value, budget, flags) {
let used = 0;
let out = "";
for (const character of value) {
try {
used += encodeURIComponent(character).length;
} catch (_) {
continue;
}
if (used > budget) {
flags.cut = true;
return out;
}
out += character;
}
return out;
}
async function receiveShare(request) {
const land = (params) => Response.redirect(self.location.origin + BASE_PATH + "/?" + new URLSearchParams(params), 303);
// In the fragment: it stays out of access logs, and the key out of the server's sight.
const land = (params) => Response.redirect(self.location.origin + BASE_PATH + "/#" + new URLSearchParams(params), 303);
let form;
try {
form = await request.formData();
@@ -341,22 +415,37 @@ async function receiveShare(request) {
const files = form.getAll("files").filter((file) => typeof file !== "string" && file.size > 0);
const id = crypto.randomUUID();
const prefix = "/__bulwark/handoff/" + id + "/";
const bytes = files.reduce((sum, file) => sum + file.size, 0);
let cache = null;
try {
if (bytes > HANDOFF_MAX_BYTES) throw new Error("too large");
cache = await caches.open(HANDOFF_CACHE);
const list = files.map((file) => ({ name: file.name, type: file.type }));
await cache.put(prefix + "meta", new Response(JSON.stringify({ kind: "compose", createdAt: Date.now(), ...shared, files: list })));
await Promise.all(files.map((file, index) => cache.put(prefix + index, new Response(file))));
return land({ share: id });
await sweepHandoffs(cache, bytes);
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt"]);
const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key));
const contents = JSON.stringify({ ...shared, files: files.map((file) => ({ name: file.name, type: file.type })) });
// The list first: the sweep goes by it.
await cache.put(prefix + "meta", new Response(JSON.stringify({ kind: "compose", createdAt: Date.now(), bytes })));
await cache.put(prefix + "sealed", new Response(await sealHandoff(key, new TextEncoder().encode(contents))));
for (let index = 0; index < files.length; index += 1) {
await cache.put(prefix + index, new Response(await sealHandoff(key, await files[index].arrayBuffer())));
}
const encodedKey = btoa(String.fromCharCode(...rawKey)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
return land({ share: id, key: encodedKey });
} catch (_) {
// No room for the files. The text can still come along in the address.
if (cache) await Promise.all([...files.map((_file, index) => cache.delete(prefix + index)), cache.delete(prefix + "meta")]).catch(() => {});
if (cache) await Promise.all([...files.map((_file, index) => cache.delete(prefix + index)), cache.delete(prefix + "sealed"), cache.delete(prefix + "meta")]).catch(() => {});
const flags = { cut: false };
const title = fitEncoded(shared.title, 400, flags);
const text = fitEncoded(shared.text, 1800, flags);
const link = fitEncoded(shared.url, 1000, flags);
return land({
share: "unavailable",
share_title: shared.title.slice(0, 300),
share_text: shared.text.slice(0, 4000),
share_url: shared.url.slice(0, 2000),
share_title: title,
share_text: text,
share_url: link,
...(files.length > 0 ? { share_files: "1" } : {}),
...(flags.cut ? { share_cut: "1" } : {}),
});
}
}