mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
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:
@@ -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' })]} />);
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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: '' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>[] = [];
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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" } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user