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
+157
@@ -23,6 +23,7 @@ function getBasePath() {
|
||||
}
|
||||
|
||||
const BASE_PATH = getBasePath();
|
||||
const MAILTO_CLIENTS = new Map();
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
@@ -43,6 +44,48 @@ self.addEventListener("notificationclick", (event) => {
|
||||
event.waitUntil(handleNotificationClick(event));
|
||||
});
|
||||
|
||||
self.addEventListener("message", (event) => {
|
||||
const data = event.data || {};
|
||||
if (data.type === "mailto-client-ready") {
|
||||
if (event.source && event.source.id) {
|
||||
MAILTO_CLIENTS.set(event.source.id, {
|
||||
path: typeof data.path === "string" ? data.path : "",
|
||||
standalone: data.standalone === true,
|
||||
clientId: typeof data.clientId === "string" ? data.clientId : "",
|
||||
focusNotificationTitle: typeof data.focusNotificationTitle === "string" ? data.focusNotificationTitle : "",
|
||||
focusNotificationBody: typeof data.focusNotificationBody === "string" ? data.focusNotificationBody : "",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === "mailto-client-gone") {
|
||||
if (event.source && event.source.id) {
|
||||
const current = MAILTO_CLIENTS.get(event.source.id);
|
||||
if (!current
|
||||
|| (typeof data.clientId === "string" && current.clientId === data.clientId)
|
||||
|| (typeof data.clientId !== "string" && typeof data.path === "string" && current.path === data.path)) {
|
||||
MAILTO_CLIENTS.delete(event.source.id);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === "open-mailto-in-client") {
|
||||
event.waitUntil(handleOpenMailtoInClient(event));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === "focus-existing-mailto-client") {
|
||||
event.waitUntil(focusExistingWindowClient(event.source && event.source.id, true));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type !== "focus-existing-client") return;
|
||||
|
||||
event.waitUntil(focusExistingWindowClient(event.source && event.source.id));
|
||||
});
|
||||
|
||||
async function handlePush(event) {
|
||||
let payload = null;
|
||||
try {
|
||||
@@ -123,6 +166,11 @@ async function handlePush(event) {
|
||||
async function handleNotificationClick(event) {
|
||||
const data = event.notification.data || {};
|
||||
const tag = event.notification.tag || "";
|
||||
|
||||
if (data.kind === "protocol-mailto-focus") {
|
||||
return handleMailtoFocusNotificationClick();
|
||||
}
|
||||
|
||||
const targetUrl = buildClickUrl(data);
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
@@ -160,6 +208,115 @@ async function handleNotificationClick(event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function focusExistingWindowClient(sourceClientId, requireMailtoReady) {
|
||||
const entry = await findReusableWindowClientEntry(sourceClientId, requireMailtoReady);
|
||||
const client = entry && entry.client;
|
||||
if (client && "focus" in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMailtoFocusNotificationClick() {
|
||||
const entry = await findReusableWindowClientEntry(null, true);
|
||||
const client = entry && entry.client;
|
||||
if (client && "focus" in client) {
|
||||
try {
|
||||
return await client.focus();
|
||||
} catch (_) {
|
||||
// Fall through to opening a new app window if activation is still blocked.
|
||||
}
|
||||
}
|
||||
|
||||
if (self.clients.openWindow) {
|
||||
return self.clients.openWindow(`${BASE_PATH}/`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOpenMailtoInClient(event) {
|
||||
const data = event.data || {};
|
||||
const responsePort = event.ports && event.ports[0];
|
||||
const entry = await findReusableWindowClientEntry(event.source && event.source.id, true);
|
||||
const client = entry && entry.client;
|
||||
const state = entry && entry.state;
|
||||
|
||||
if (!client || !state || !state.clientId) {
|
||||
responsePort && responsePort.postMessage({ delivered: false });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
client.postMessage({ type: "mailto-request", id: data.id, clientId: state.clientId, value: data.value });
|
||||
} catch (_) {
|
||||
responsePort && responsePort.postMessage({ delivered: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if ("focus" in client) {
|
||||
try {
|
||||
await client.focus();
|
||||
} catch (_) {
|
||||
// Delivery succeeded; focusing can still be blocked by browser policy.
|
||||
await showMailtoFocusNotification(state);
|
||||
}
|
||||
}
|
||||
|
||||
responsePort && responsePort.postMessage({ delivered: true });
|
||||
}
|
||||
|
||||
async function showMailtoFocusNotification(state) {
|
||||
try {
|
||||
await self.registration.showNotification(state.focusNotificationTitle || "Bulwark", {
|
||||
body: state.focusNotificationBody || "The request was opened in Bulwark. Click to bring it to the front.",
|
||||
tag: "bulwark-mailto-focus",
|
||||
icon: `${BASE_PATH}/icon-192x192.png`,
|
||||
badge: `${BASE_PATH}/icon-192x192.png`,
|
||||
data: { kind: "protocol-mailto-focus" },
|
||||
renotify: true,
|
||||
});
|
||||
} catch (_) {
|
||||
// Notification permission may be missing; the mailto request was still delivered.
|
||||
}
|
||||
}
|
||||
|
||||
async function findReusableWindowClientEntry(sourceClientId, requireMailtoReady) {
|
||||
const scopedPath = BASE_PATH ? `${BASE_PATH}/` : "/";
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: "window",
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
const candidates = [];
|
||||
|
||||
for (const client of allClients) {
|
||||
if (client.id === sourceClientId) continue;
|
||||
const state = MAILTO_CLIENTS.get(client.id);
|
||||
if (requireMailtoReady && !state) continue;
|
||||
|
||||
try {
|
||||
const url = new URL(client.url);
|
||||
if (url.origin !== self.location.origin) continue;
|
||||
if (!url.pathname.startsWith(scopedPath)) continue;
|
||||
if (url.pathname.includes("/protocol/")) continue;
|
||||
|
||||
candidates.push({ client, state, score: getReusableClientScore(state) });
|
||||
} catch (_) {
|
||||
// Detached clients can disappear while iterating.
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => a.score - b.score);
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function getReusableClientScore(state) {
|
||||
if (!state) return 4;
|
||||
|
||||
const isMailSection = state.path === "/" || state.path === "";
|
||||
if (state.standalone && isMailSection) return 0;
|
||||
if (isMailSection) return 1;
|
||||
if (state.standalone) return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
function buildClickUrl(data) {
|
||||
if (!data) return `${BASE_PATH}/`;
|
||||
if (data.kind === "email" && data.emailId) {
|
||||
|
||||
Reference in New Issue
Block a user