Feature/protocol handlers
* Added account selection for protocol links when multiple connected accounts are available, including mailto: links * Added support for handling mailto: links in an already-open PWA/session instead of always opening a new tab * Added webcal: protocol handling for calendar links * Added account selection for webcal: links when multiple calendar-capable accounts are connected * Added an import-or-subscribe choice for detected webcal calendars * Added protocol handler settings for registering mail and calendar handlers and choosing the open mode * Added service worker/session coordination for passing protocol requests between browser/PWA contexts * Added tests and translations for the new protocol handler flows
This commit is contained in:
committed by
Linus Rath
parent
8b0e2052cf
commit
3f444a8912
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { plainTextToComposerBody } from "../email-composer-utils";
|
||||
|
||||
describe("plainTextToComposerBody", () => {
|
||||
it("returns an empty string for empty input", () => {
|
||||
expect(plainTextToComposerBody("")).toBe("");
|
||||
});
|
||||
|
||||
it("escapes HTML before building composer paragraphs", () => {
|
||||
expect(plainTextToComposerBody("<script>alert('x') & \"q\"</script>")).toBe(
|
||||
"<p><script>alert('x') & "q"</script></p>"
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes line endings and preserves single line breaks", () => {
|
||||
expect(plainTextToComposerBody("line1\r\nline2\rline3")).toBe(
|
||||
"<p>line1<br>line2<br>line3</p>"
|
||||
);
|
||||
});
|
||||
|
||||
it("splits paragraphs on blank lines", () => {
|
||||
expect(plainTextToComposerBody("first\n\nsecond\nthird")).toBe(
|
||||
"<p>first</p><p>second<br>third</p>"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { parseMailto } from "../protocol-handlers/mailto";
|
||||
import { listenForMailtoRequests } from "../protocol-handlers/session";
|
||||
import { parseWebcal } from "../protocol-handlers/webcal";
|
||||
|
||||
const originalServiceWorkerDescriptor = Object.getOwnPropertyDescriptor(navigator, "serviceWorker");
|
||||
|
||||
function installServiceWorkerMock() {
|
||||
const listeners = new Set<(event: MessageEvent) => void>();
|
||||
const worker = { postMessage: vi.fn() };
|
||||
const serviceWorker = {
|
||||
ready: Promise.resolve({ active: worker }),
|
||||
controller: worker,
|
||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
if (type === "message") listeners.add(listener as (event: MessageEvent) => void);
|
||||
}),
|
||||
removeEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
if (type === "message") listeners.delete(listener as (event: MessageEvent) => void);
|
||||
}),
|
||||
};
|
||||
|
||||
Object.defineProperty(navigator, "serviceWorker", {
|
||||
configurable: true,
|
||||
value: serviceWorker,
|
||||
});
|
||||
|
||||
return {
|
||||
dispatch(data: unknown) {
|
||||
listeners.forEach((listener) => listener(new MessageEvent("message", { data })));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (originalServiceWorkerDescriptor) {
|
||||
Object.defineProperty(navigator, "serviceWorker", originalServiceWorkerDescriptor);
|
||||
return;
|
||||
}
|
||||
Reflect.deleteProperty(navigator, "serviceWorker");
|
||||
});
|
||||
|
||||
describe("protocol handlers", () => {
|
||||
describe("parseMailto", () => {
|
||||
it("parses a single path recipient", () => {
|
||||
expect(parseMailto("mailto:alice@example.com")).toEqual({
|
||||
to: ["alice@example.com"],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: "",
|
||||
body: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses multiple recipients with subject and body", () => {
|
||||
expect(parseMailto("mailto:alice@example.com,bob@example.com?subject=Hello&body=Hi")).toMatchObject({
|
||||
to: ["alice@example.com", "bob@example.com"],
|
||||
subject: "Hello",
|
||||
body: "Hi",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses to, cc, and bcc query recipients", () => {
|
||||
expect(parseMailto("mailto:?to=alice@example.com&cc=bob@example.com&bcc=eve@example.com")).toMatchObject({
|
||||
to: ["alice@example.com"],
|
||||
cc: ["bob@example.com"],
|
||||
bcc: ["eve@example.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes subject and body values", () => {
|
||||
expect(parseMailto("mailto:alice@example.com?subject=Hello%20World&body=line1%0Aline2")).toMatchObject({
|
||||
subject: "Hello World",
|
||||
body: "line1\nline2",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves literal plus signs in query values", () => {
|
||||
expect(parseMailto("mailto:?to=user+tag@example.com&subject=C++&body=a+b")).toMatchObject({
|
||||
to: ["user+tag@example.com"],
|
||||
subject: "C++",
|
||||
body: "a+b",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-mailto URLs", () => {
|
||||
expect(parseMailto("https://example.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("allows an empty mailto URL", () => {
|
||||
expect(parseMailto("mailto:")).toEqual({
|
||||
to: [],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: "",
|
||||
body: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes control characters and caps recipients", () => {
|
||||
const recipients = Array.from({ length: 250 }, (_, index) => `user${index}@example.com`).join(",");
|
||||
const parsed = parseMailto(`mailto:${recipients}?subject=Hi%0ABcc:evil@example.com`);
|
||||
expect(parsed?.to).toHaveLength(200);
|
||||
expect(parsed?.subject).toBe("HiBcc:evil@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseWebcal", () => {
|
||||
it("normalizes webcal to https", () => {
|
||||
expect(parseWebcal("webcal://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
|
||||
});
|
||||
|
||||
it("normalizes webcals to https", () => {
|
||||
expect(parseWebcal("webcals://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
|
||||
});
|
||||
|
||||
it("accepts https URLs", () => {
|
||||
expect(parseWebcal("https://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
|
||||
});
|
||||
|
||||
it("rejects unsupported protocols", () => {
|
||||
expect(parseWebcal("ftp://example.com/calendar.ics")).toBeNull();
|
||||
});
|
||||
|
||||
it("suggests a name from the path", () => {
|
||||
expect(parseWebcal("webcal://example.com/team.ics")?.suggestedName).toBe("team");
|
||||
});
|
||||
|
||||
it("falls back to hostname for suggested name", () => {
|
||||
expect(parseWebcal("webcal://example.com/")?.suggestedName).toBe("example.com");
|
||||
});
|
||||
|
||||
it("prefers a name query parameter", () => {
|
||||
expect(parseWebcal("webcal://example.com/team.ics?name=Team%20Calendar")?.suggestedName).toBe("Team Calendar");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listenForMailtoRequests", () => {
|
||||
const mailtoValue = {
|
||||
to: ["alice@example.com"],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: "Hello",
|
||||
body: "Hi",
|
||||
};
|
||||
|
||||
it("accepts legacy service-worker mailto messages without a client id", () => {
|
||||
const serviceWorker = installServiceWorkerMock();
|
||||
const onMailto = vi.fn();
|
||||
vi.spyOn(window, "focus").mockImplementation(() => undefined);
|
||||
|
||||
const cleanup = listenForMailtoRequests(onMailto, () => ({ path: "/", standalone: false }));
|
||||
serviceWorker.dispatch({ type: "mailto-request", id: "legacy", value: mailtoValue });
|
||||
|
||||
expect(onMailto).toHaveBeenCalledWith(mailtoValue);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("ignores service-worker mailto messages for another client", () => {
|
||||
const serviceWorker = installServiceWorkerMock();
|
||||
const onMailto = vi.fn();
|
||||
|
||||
const cleanup = listenForMailtoRequests(onMailto, () => ({ path: "/", standalone: false }));
|
||||
serviceWorker.dispatch({ type: "mailto-request", id: "targeted", clientId: "other-client", value: mailtoValue });
|
||||
|
||||
expect(onMailto).not.toHaveBeenCalled();
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
const HTML_ESCAPE_MAP = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
} as const;
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (char) =>
|
||||
HTML_ESCAPE_MAP[char as keyof typeof HTML_ESCAPE_MAP]
|
||||
);
|
||||
}
|
||||
|
||||
export function plainTextToComposerBody(text: string): string {
|
||||
if (!text) return "";
|
||||
|
||||
return text
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.split(/\n{2,}/)
|
||||
.map((paragraph) => `<p>${escapeHtml(paragraph).replace(/\n/g, "<br>")}</p>`)
|
||||
.join("");
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
export interface ParsedMailto {
|
||||
to: string[];
|
||||
cc: string[];
|
||||
bcc: string[];
|
||||
subject: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const MAX_RECIPIENTS = 200;
|
||||
const MAX_SUBJECT_LENGTH = 998;
|
||||
const MAX_BODY_LENGTH = 64 * 1024;
|
||||
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/g;
|
||||
const CONTROL_CHARS_EXCEPT_LINE_BREAKS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
|
||||
|
||||
function stripControlChars(value: string): string {
|
||||
return value.replace(CONTROL_CHARS, "");
|
||||
}
|
||||
|
||||
function stripBodyControlChars(value: string): string {
|
||||
return value
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.replace(CONTROL_CHARS_EXCEPT_LINE_BREAKS, "");
|
||||
}
|
||||
|
||||
function splitRecipients(value: string): string[] {
|
||||
return stripControlChars(value)
|
||||
.split(",")
|
||||
.map((recipient) => recipient.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
type QueryParam = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
function getQueryValue(searchParams: QueryParam[], key: string): string {
|
||||
const values: string[] = [];
|
||||
const lowerKey = key.toLowerCase();
|
||||
|
||||
for (const { key: paramKey, value } of searchParams) {
|
||||
if (paramKey.toLowerCase() === lowerKey) {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
return values.join(",");
|
||||
}
|
||||
|
||||
function decodePathname(pathname: string): string | null {
|
||||
try {
|
||||
return decodeURIComponent(pathname || "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeQueryPart(value: string): string | null {
|
||||
try {
|
||||
// RFC 6068 uses percent-encoding for mailto query fields; unlike form
|
||||
// encoding, a literal '+' is part of the value and must not become space.
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseQuery(query: string): QueryParam[] | null {
|
||||
if (!query) return [];
|
||||
|
||||
const params: QueryParam[] = [];
|
||||
for (const part of query.split("&")) {
|
||||
if (!part) continue;
|
||||
const separatorIndex = part.indexOf("=");
|
||||
const rawKey = separatorIndex >= 0 ? part.slice(0, separatorIndex) : part;
|
||||
const rawValue = separatorIndex >= 0 ? part.slice(separatorIndex + 1) : "";
|
||||
const key = decodeQueryPart(rawKey);
|
||||
const value = decodeQueryPart(rawValue);
|
||||
if (key === null || value === null) return null;
|
||||
params.push({ key, value });
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export function parseMailto(raw: string): ParsedMailto | null {
|
||||
if (!raw.toLowerCase().startsWith("mailto:")) return null;
|
||||
|
||||
const addressAndQuery = raw.slice("mailto:".length);
|
||||
const queryIndex = addressAndQuery.indexOf("?");
|
||||
const rawPathname = queryIndex >= 0 ? addressAndQuery.slice(0, queryIndex) : addressAndQuery;
|
||||
const rawQuery = queryIndex >= 0 ? addressAndQuery.slice(queryIndex + 1) : "";
|
||||
|
||||
const decodedPathname = decodePathname(rawPathname);
|
||||
if (decodedPathname === null) return null;
|
||||
const searchParams = parseQuery(rawQuery);
|
||||
if (searchParams === null) return null;
|
||||
|
||||
const to = [
|
||||
...splitRecipients(decodedPathname),
|
||||
...splitRecipients(getQueryValue(searchParams, "to")),
|
||||
].slice(0, MAX_RECIPIENTS);
|
||||
const remainingAfterTo = Math.max(0, MAX_RECIPIENTS - to.length);
|
||||
const cc = splitRecipients(getQueryValue(searchParams, "cc")).slice(0, remainingAfterTo);
|
||||
const remainingAfterCc = Math.max(0, MAX_RECIPIENTS - to.length - cc.length);
|
||||
const bcc = splitRecipients(getQueryValue(searchParams, "bcc")).slice(0, remainingAfterCc);
|
||||
|
||||
return {
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
subject: stripControlChars(getQueryValue(searchParams, "subject")).slice(0, MAX_SUBJECT_LENGTH),
|
||||
body: stripBodyControlChars(getQueryValue(searchParams, "body")).slice(0, MAX_BODY_LENGTH),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import type { ParsedMailto } from "./mailto";
|
||||
import type { ParsedWebcal } from "./webcal";
|
||||
|
||||
const MAILTO_KEY = "bulwark:pending-mailto";
|
||||
const WEBCAL_KEY = "bulwark:pending-webcal";
|
||||
const PROTOCOL_CHANNEL = "bulwark:protocol-handlers";
|
||||
const PENDING_TTL_MS = 5 * 60 * 1000;
|
||||
const MAILTO_REQUEST = "mailto-request";
|
||||
const MAILTO_CANDIDATE = "mailto-candidate";
|
||||
const MAILTO_ACK = "mailto-ack";
|
||||
const OPEN_MAILTO_IN_CLIENT = "open-mailto-in-client";
|
||||
const MAILTO_CLIENT_READY = "mailto-client-ready";
|
||||
const MAILTO_CLIENT_GONE = "mailto-client-gone";
|
||||
const PENDING_MAILTO_EVENT = "bulwark:pending-mailto";
|
||||
const PENDING_WEBCAL_EVENT = "bulwark:pending-webcal";
|
||||
|
||||
type PendingValue<T> = T & { createdAt: number };
|
||||
type PendingMailtoRequest = { type: typeof MAILTO_REQUEST; id: string; value: ParsedMailto; clientId?: string };
|
||||
type PendingMailtoCandidate = { type: typeof MAILTO_CANDIDATE; id: string; clientId: string; priority: number };
|
||||
type PendingMailtoAck = { type: typeof MAILTO_ACK; id: string };
|
||||
type OpenMailtoInClientRequest = {
|
||||
type: typeof OPEN_MAILTO_IN_CLIENT;
|
||||
id: string;
|
||||
value: ParsedMailto;
|
||||
clientId?: string;
|
||||
};
|
||||
type ProtocolClientInfo = {
|
||||
path: string;
|
||||
standalone: boolean;
|
||||
clientId?: string;
|
||||
focusNotificationTitle?: string;
|
||||
focusNotificationBody?: string;
|
||||
};
|
||||
|
||||
function savePending<T>(key: string, value: T) {
|
||||
try {
|
||||
sessionStorage.setItem(key, JSON.stringify({ ...value, createdAt: Date.now() }));
|
||||
} catch {
|
||||
// Storage can be unavailable in hardened/private browser modes.
|
||||
}
|
||||
}
|
||||
|
||||
function consumePending<T>(key: string, validate: (value: unknown) => value is T): T | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
sessionStorage.removeItem(key);
|
||||
if (!raw) return null;
|
||||
|
||||
const parsed = JSON.parse(raw) as PendingValue<unknown>;
|
||||
if (typeof parsed.createdAt !== "number" || Date.now() - parsed.createdAt > PENDING_TTL_MS) {
|
||||
return null;
|
||||
}
|
||||
return validate(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hasPending<T>(key: string, validate: (value: unknown) => value is T): boolean {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
if (!raw) return false;
|
||||
|
||||
const parsed = JSON.parse(raw) as PendingValue<unknown>;
|
||||
if (typeof parsed.createdAt !== "number" || Date.now() - parsed.createdAt > PENDING_TTL_MS) {
|
||||
sessionStorage.removeItem(key);
|
||||
return false;
|
||||
}
|
||||
return validate(parsed);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isParsedMailto(value: unknown): value is ParsedMailto {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Partial<ParsedMailto>;
|
||||
return Array.isArray(candidate.to)
|
||||
&& Array.isArray(candidate.cc)
|
||||
&& Array.isArray(candidate.bcc)
|
||||
&& typeof candidate.subject === "string"
|
||||
&& typeof candidate.body === "string";
|
||||
}
|
||||
|
||||
function isPendingMailtoRequest(value: unknown): value is PendingMailtoRequest {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Partial<PendingMailtoRequest>;
|
||||
return candidate.type === MAILTO_REQUEST
|
||||
&& typeof candidate.id === "string"
|
||||
&& isParsedMailto(candidate.value)
|
||||
&& (candidate.clientId === undefined || typeof candidate.clientId === "string");
|
||||
}
|
||||
|
||||
function isPendingMailtoAck(value: unknown, id: string): value is PendingMailtoAck {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Partial<PendingMailtoAck>;
|
||||
return candidate.type === MAILTO_ACK && candidate.id === id;
|
||||
}
|
||||
|
||||
function isPendingMailtoCandidate(value: unknown, id: string): value is PendingMailtoCandidate {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Partial<PendingMailtoCandidate>;
|
||||
return candidate.type === MAILTO_CANDIDATE
|
||||
&& candidate.id === id
|
||||
&& typeof candidate.clientId === "string"
|
||||
&& typeof candidate.priority === "number";
|
||||
}
|
||||
|
||||
function isOpenMailtoInClientRequest(value: unknown): value is OpenMailtoInClientRequest {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Partial<OpenMailtoInClientRequest>;
|
||||
return candidate.type === OPEN_MAILTO_IN_CLIENT
|
||||
&& typeof candidate.id === "string"
|
||||
&& isParsedMailto(candidate.value)
|
||||
&& (candidate.clientId === undefined || typeof candidate.clientId === "string");
|
||||
}
|
||||
|
||||
function createRequestId(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
const BROWSER_CLIENT_ID = createRequestId();
|
||||
|
||||
function getMailtoClientPriority(info: ProtocolClientInfo): number {
|
||||
const isMailSection = info.path === "/" || info.path === "";
|
||||
if (info.standalone && isMailSection) return 0;
|
||||
if (isMailSection) return 1;
|
||||
if (info.standalone) return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
function getDefaultProtocolClientInfo(): ProtocolClientInfo {
|
||||
const nav = navigator as Navigator & { standalone?: boolean };
|
||||
const standalone = window.matchMedia?.("(display-mode: standalone)").matches || nav.standalone === true;
|
||||
return { path: window.location.pathname, standalone, clientId: BROWSER_CLIENT_ID };
|
||||
}
|
||||
|
||||
async function requestMailtoViaServiceWorker(value: ParsedMailto, timeoutMs: number): Promise<boolean> {
|
||||
if (typeof navigator === "undefined"
|
||||
|| !("serviceWorker" in navigator)
|
||||
|| typeof MessageChannel === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await Promise.race([
|
||||
navigator.serviceWorker.ready,
|
||||
new Promise<null>((resolve) => globalThis.setTimeout(() => resolve(null), timeoutMs)),
|
||||
]);
|
||||
if (!registration) return false;
|
||||
|
||||
const worker = navigator.serviceWorker.controller ?? registration.active;
|
||||
if (!worker) return false;
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const channel = new MessageChannel();
|
||||
const timeout = globalThis.setTimeout(() => {
|
||||
channel.port1.close();
|
||||
resolve(false);
|
||||
}, timeoutMs);
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
globalThis.clearTimeout(timeout);
|
||||
channel.port1.close();
|
||||
resolve(event.data?.delivered === true);
|
||||
};
|
||||
|
||||
worker.postMessage({
|
||||
type: OPEN_MAILTO_IN_CLIENT,
|
||||
id: createRequestId(),
|
||||
value,
|
||||
} satisfies OpenMailtoInClientRequest, [channel.port2]);
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function notifyServiceWorker(
|
||||
type: typeof MAILTO_CLIENT_READY | typeof MAILTO_CLIENT_GONE,
|
||||
info?: ProtocolClientInfo,
|
||||
) {
|
||||
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return;
|
||||
|
||||
navigator.serviceWorker.ready
|
||||
.then((registration) => {
|
||||
const worker = navigator.serviceWorker.controller ?? registration.active;
|
||||
worker?.postMessage({ type, ...info });
|
||||
})
|
||||
.catch(() => {
|
||||
// Service worker registration is optional for local/dev environments.
|
||||
});
|
||||
}
|
||||
|
||||
function isParsedWebcal(value: unknown): value is ParsedWebcal {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Partial<ParsedWebcal>;
|
||||
return typeof candidate.originalUrl === "string"
|
||||
&& typeof candidate.subscriptionUrl === "string"
|
||||
&& typeof candidate.suggestedName === "string";
|
||||
}
|
||||
|
||||
export function savePendingMailto(value: ParsedMailto) {
|
||||
savePending(MAILTO_KEY, value);
|
||||
}
|
||||
|
||||
export function consumePendingMailto(): ParsedMailto | null {
|
||||
return consumePending(MAILTO_KEY, isParsedMailto);
|
||||
}
|
||||
|
||||
export function notifyPendingMailto() {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new Event(PENDING_MAILTO_EVENT));
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeToPendingMailto(callback: () => void): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
window.addEventListener(PENDING_MAILTO_EVENT, callback);
|
||||
return () => window.removeEventListener(PENDING_MAILTO_EVENT, callback);
|
||||
}
|
||||
|
||||
async function requestMailtoViaBroadcastChannel(value: ParsedMailto, timeoutMs: number): Promise<boolean> {
|
||||
if (typeof BroadcastChannel === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const id = createRequestId();
|
||||
const channel = new BroadcastChannel(PROTOCOL_CHANNEL);
|
||||
const candidates: PendingMailtoCandidate[] = [];
|
||||
let selected = false;
|
||||
let selectionTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
|
||||
const candidateWindowMs = Math.min(75, Math.max(25, Math.floor(timeoutMs / 3)));
|
||||
const timeout = globalThis.setTimeout(() => {
|
||||
if (selectionTimer) globalThis.clearTimeout(selectionTimer);
|
||||
channel.close();
|
||||
resolve(false);
|
||||
}, timeoutMs);
|
||||
|
||||
const selectCandidate = () => {
|
||||
if (selected) return;
|
||||
selected = true;
|
||||
|
||||
const best = candidates.sort((a, b) => a.priority - b.priority)[0];
|
||||
if (!best) {
|
||||
globalThis.clearTimeout(timeout);
|
||||
channel.close();
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
channel.postMessage({
|
||||
type: OPEN_MAILTO_IN_CLIENT,
|
||||
id,
|
||||
clientId: best.clientId,
|
||||
value,
|
||||
} satisfies OpenMailtoInClientRequest);
|
||||
};
|
||||
|
||||
channel.onmessage = (event) => {
|
||||
if (isPendingMailtoCandidate(event.data, id)) {
|
||||
candidates.push(event.data);
|
||||
selectionTimer ??= globalThis.setTimeout(selectCandidate, candidateWindowMs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPendingMailtoAck(event.data, id)) {
|
||||
if (selectionTimer) globalThis.clearTimeout(selectionTimer);
|
||||
globalThis.clearTimeout(timeout);
|
||||
channel.close();
|
||||
resolve(true);
|
||||
}
|
||||
};
|
||||
|
||||
channel.postMessage({ type: MAILTO_REQUEST, id, value } satisfies PendingMailtoRequest);
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestOpenMailtoInExistingClient(value: ParsedMailto, timeoutMs = 300): Promise<boolean> {
|
||||
if (await requestMailtoViaServiceWorker(value, timeoutMs)) return true;
|
||||
return requestMailtoViaBroadcastChannel(value, timeoutMs);
|
||||
}
|
||||
|
||||
export function listenForMailtoRequests(
|
||||
onMailto: (value: ParsedMailto) => void,
|
||||
getClientInfo: () => ProtocolClientInfo = getDefaultProtocolClientInfo,
|
||||
): () => void {
|
||||
const cleanup: Array<() => void> = [];
|
||||
const clientInfo = getClientInfo();
|
||||
|
||||
if (typeof navigator !== "undefined" && "serviceWorker" in navigator) {
|
||||
const handleServiceWorkerMessage = (event: MessageEvent) => {
|
||||
if (isPendingMailtoRequest(event.data)) {
|
||||
if (event.data.clientId !== undefined && event.data.clientId !== BROWSER_CLIENT_ID) return;
|
||||
if (typeof window !== "undefined") window.focus();
|
||||
onMailto(event.data.value);
|
||||
}
|
||||
};
|
||||
navigator.serviceWorker.addEventListener("message", handleServiceWorkerMessage);
|
||||
notifyServiceWorker(MAILTO_CLIENT_READY, { ...clientInfo, clientId: BROWSER_CLIENT_ID });
|
||||
cleanup.push(() => {
|
||||
notifyServiceWorker(MAILTO_CLIENT_GONE, { ...clientInfo, clientId: BROWSER_CLIENT_ID });
|
||||
navigator.serviceWorker.removeEventListener("message", handleServiceWorkerMessage);
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof BroadcastChannel !== "undefined") {
|
||||
const channel = new BroadcastChannel(PROTOCOL_CHANNEL);
|
||||
channel.onmessage = (event) => {
|
||||
if (isPendingMailtoRequest(event.data)) {
|
||||
channel.postMessage({
|
||||
type: MAILTO_CANDIDATE,
|
||||
id: event.data.id,
|
||||
clientId: BROWSER_CLIENT_ID,
|
||||
priority: getMailtoClientPriority(getClientInfo()),
|
||||
} satisfies PendingMailtoCandidate);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOpenMailtoInClientRequest(event.data) || event.data.clientId !== BROWSER_CLIENT_ID) return;
|
||||
if (typeof window !== "undefined") window.focus();
|
||||
onMailto(event.data.value);
|
||||
channel.postMessage({ type: MAILTO_ACK, id: event.data.id } satisfies PendingMailtoAck);
|
||||
};
|
||||
cleanup.push(() => channel.close());
|
||||
}
|
||||
|
||||
return () => cleanup.forEach((dispose) => dispose());
|
||||
}
|
||||
|
||||
export function savePendingWebcal(value: ParsedWebcal) {
|
||||
savePending(WEBCAL_KEY, value);
|
||||
}
|
||||
|
||||
export function consumePendingWebcal(): ParsedWebcal | null {
|
||||
return consumePending(WEBCAL_KEY, isParsedWebcal);
|
||||
}
|
||||
|
||||
export function notifyPendingWebcal() {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new Event(PENDING_WEBCAL_EVENT));
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeToPendingWebcal(callback: () => void): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
window.addEventListener(PENDING_WEBCAL_EVENT, callback);
|
||||
return () => window.removeEventListener(PENDING_WEBCAL_EVENT, callback);
|
||||
}
|
||||
|
||||
export function hasPendingWebcal(): boolean {
|
||||
return hasPending(WEBCAL_KEY, isParsedWebcal);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export interface ParsedWebcal {
|
||||
originalUrl: string;
|
||||
subscriptionUrl: string;
|
||||
suggestedName: string;
|
||||
}
|
||||
|
||||
function stripControlChars(value: string): string {
|
||||
return value.replace(/[\u0000-\u001F\u007F]/g, "").trim();
|
||||
}
|
||||
|
||||
function extensionlessName(value: string): string {
|
||||
return value.replace(/\.(ics|ical)$/i, "");
|
||||
}
|
||||
|
||||
function decodePathSegment(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseWebcal(raw: string): ParsedWebcal | null {
|
||||
let url: URL;
|
||||
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (url.protocol === "webcal:" || url.protocol === "webcals:") {
|
||||
url = new URL(raw.replace(/^webcals?:/i, "https:"));
|
||||
} else if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const subscriptionUrl = url.toString();
|
||||
const queryName = stripControlChars(url.searchParams.get("name") || "");
|
||||
const pathSegment = stripControlChars(decodePathSegment(url.pathname.split("/").filter(Boolean).pop() || ""));
|
||||
const suggestedName = queryName || extensionlessName(pathSegment) || url.hostname;
|
||||
|
||||
return {
|
||||
originalUrl: raw,
|
||||
subscriptionUrl,
|
||||
suggestedName,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user