CI / Build & Quality Checks (pull_request) Successful in 2m43s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m49s
The service worker posts inline notification replies in the clear (it cannot encrypt). The Reply action was only withheld for rooms that were encrypted when the notification was shown, so a reply typed hours later into a room that has since turned on encryption went out unencrypted. It also posted with the newest session the worker knew, not the notification's account. The worker now GETs the room's m.room.encryption state first and only sends on a definite 404; notifications carry the user id and the worker uses that account's session (or refuses). "Reply not sent" says why. Fixes #299 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
280 lines
9.1 KiB
TypeScript
280 lines
9.1 KiB
TypeScript
/// <reference lib="WebWorker" />
|
|
|
|
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
|
|
import { SHARE_TARGET_PATH, stashSharedForm } from './swShare';
|
|
import { pickReplySession, replyFailureText, sendNotificationReply } from './swReply';
|
|
|
|
export type {};
|
|
declare const self: ServiceWorkerGlobalScope & {
|
|
// Replaced at build time by vite-plugin-pwa (injectManifest) with the list of
|
|
// hashed build assets to precache. See vite.config.js VitePWA injectManifest.
|
|
__WB_MANIFEST: Array<string | PrecacheEntry>;
|
|
};
|
|
|
|
/**
|
|
* PRECACHE (workbox-precaching). `self.__WB_MANIFEST` is replaced at build time
|
|
* by vite-plugin-pwa with the list of hashed build assets
|
|
* (assets/**\/*.{js,css,wasm}; see vite.config.js injectManifest.globPatterns).
|
|
*
|
|
* DEPLOY-SAFETY INVARIANTS (do not break):
|
|
* 1. index.html / navigations are NEVER precached or precache-routed. The
|
|
* manifest globs only `assets/**` (content-hashed), so index.html (served
|
|
* from the app root) is absent from it and navigation requests fall through
|
|
* to the network — a new deploy is picked up immediately, no stale SPA
|
|
* shell. We deliberately do NOT register a navigation route /
|
|
* createHandlerBoundToURL fallback.
|
|
* 2. precacheAndRoute only matches its own manifest URLs (same-origin hashed
|
|
* assets). It never matches the media-auth paths handled by the fetch
|
|
* listener below — those are cross-origin homeserver URLs absent from the
|
|
* manifest — so the existing media fetch behaviour is fully preserved. It
|
|
* is registered before that listener; for a media request the precache
|
|
* route finds no match and does not call respondWith, so the media handler
|
|
* still runs.
|
|
* 3. Assets are content-hashed, so a changed asset ships under a new filename;
|
|
* PrecacheController drops entries no longer in the current manifest on
|
|
* activate, so the precache self-updates each deploy without unbounded
|
|
* growth.
|
|
*/
|
|
precacheAndRoute(self.__WB_MANIFEST);
|
|
|
|
type SessionInfo = {
|
|
accessToken: string;
|
|
baseUrl: string;
|
|
/** [Gitea #299] whose session this is, so a notification reply uses its own account */
|
|
userId?: string;
|
|
};
|
|
|
|
/**
|
|
* Store session per client (tab)
|
|
*/
|
|
const sessions = new Map<string, SessionInfo>();
|
|
|
|
const clientToResolve = new Map<string, (value: SessionInfo | undefined) => void>();
|
|
const clientToSessionPromise = new Map<string, Promise<SessionInfo | undefined>>();
|
|
|
|
async function cleanupDeadClients() {
|
|
const activeClients = await self.clients.matchAll();
|
|
const activeIds = new Set(activeClients.map((c) => c.id));
|
|
|
|
Array.from(sessions.keys()).forEach((id) => {
|
|
if (!activeIds.has(id)) {
|
|
sessions.delete(id);
|
|
clientToResolve.delete(id);
|
|
clientToSessionPromise.delete(id);
|
|
}
|
|
});
|
|
}
|
|
|
|
function setSession(clientId: string, accessToken: string, baseUrl: string, userId?: unknown) {
|
|
if (typeof accessToken === 'string' && typeof baseUrl === 'string') {
|
|
sessions.set(clientId, {
|
|
accessToken,
|
|
baseUrl,
|
|
userId: typeof userId === 'string' ? userId : undefined,
|
|
});
|
|
} else {
|
|
// Logout or invalid session
|
|
sessions.delete(clientId);
|
|
}
|
|
|
|
const resolveSession = clientToResolve.get(clientId);
|
|
if (resolveSession) {
|
|
resolveSession(sessions.get(clientId));
|
|
clientToResolve.delete(clientId);
|
|
clientToSessionPromise.delete(clientId);
|
|
}
|
|
}
|
|
|
|
function requestSession(client: Client): Promise<SessionInfo | undefined> {
|
|
const promise =
|
|
clientToSessionPromise.get(client.id) ??
|
|
new Promise((resolve) => {
|
|
clientToResolve.set(client.id, resolve);
|
|
client.postMessage({ type: 'requestSession' });
|
|
});
|
|
|
|
if (!clientToSessionPromise.has(client.id)) {
|
|
clientToSessionPromise.set(client.id, promise);
|
|
}
|
|
|
|
return promise;
|
|
}
|
|
|
|
async function requestSessionWithTimeout(
|
|
clientId: string,
|
|
timeoutMs = 3000,
|
|
): Promise<SessionInfo | undefined> {
|
|
const client = await self.clients.get(clientId);
|
|
if (!client) return undefined;
|
|
|
|
const sessionPromise = requestSession(client);
|
|
|
|
const timeout = new Promise<undefined>((resolve) => {
|
|
setTimeout(() => resolve(undefined), timeoutMs);
|
|
});
|
|
|
|
return Promise.race([sessionPromise, timeout]);
|
|
}
|
|
|
|
self.addEventListener('install', () => {
|
|
self.skipWaiting();
|
|
});
|
|
|
|
self.addEventListener('activate', (event: ExtendableEvent) => {
|
|
event.waitUntil(
|
|
(async () => {
|
|
await self.clients.claim();
|
|
await cleanupDeadClients();
|
|
})(),
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Receive session updates from clients
|
|
*/
|
|
self.addEventListener('message', (event: ExtendableMessageEvent) => {
|
|
const client = event.source as Client | null;
|
|
if (!client) return;
|
|
|
|
const { type, accessToken, baseUrl, userId } = event.data || {};
|
|
|
|
if (type === 'setSession') {
|
|
setSession(client.id, accessToken, baseUrl, userId);
|
|
cleanupDeadClients();
|
|
}
|
|
});
|
|
|
|
/**
|
|
* N105: handle clicks on service-worker-owned notifications. This fires even
|
|
* when the originating tab was closed (a page-level `Notification.onclick`
|
|
* would not). Focus an existing app window and forward the target path so it
|
|
* can route there; if no window is open, open the app.
|
|
*/
|
|
self.addEventListener('notificationclick', (event: NotificationEvent) => {
|
|
event.notification.close();
|
|
const data = event.notification.data ?? {};
|
|
const path = typeof data.path === 'string' ? data.path : undefined;
|
|
|
|
// [Gitea #203] Reply action: send it and stop — no need to raise the app.
|
|
if (event.action === 'reply' && typeof data.roomId === 'string') {
|
|
const text = (event as NotificationEvent & { reply?: string }).reply?.trim();
|
|
if (text) {
|
|
event.waitUntil(
|
|
sendNotificationReply(
|
|
// [Gitea #299] The notification's own account, not the newest session.
|
|
pickReplySession(
|
|
Array.from(sessions.values()),
|
|
typeof data.userId === 'string' ? data.userId : undefined,
|
|
),
|
|
data.roomId,
|
|
data.threadId,
|
|
text,
|
|
).then((result) => {
|
|
if (result !== 'sent') {
|
|
// Let the user know it didn't go out, and keep their text reachable.
|
|
return self.registration.showNotification('Reply not sent', {
|
|
body: replyFailureText(result, text),
|
|
tag: `swreply-fail-${data.roomId}`,
|
|
data: { path },
|
|
});
|
|
}
|
|
return undefined;
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
event.waitUntil(
|
|
(async () => {
|
|
const windowClients = (
|
|
await self.clients.matchAll({
|
|
type: 'window',
|
|
includeUncontrolled: true,
|
|
})
|
|
).filter((c): c is WindowClient => 'focus' in c);
|
|
// #78 — prefer a client that's actually focused, then one that's merely
|
|
// visible, before falling back to whatever matchAll() returned first (its
|
|
// ordering is unspecified across browsers and not "most recently used").
|
|
const client =
|
|
windowClients.find((c) => c.focused) ??
|
|
windowClients.find((c) => c.visibilityState === 'visible') ??
|
|
windowClients[0];
|
|
if (client) {
|
|
await client.focus();
|
|
if (path) client.postMessage({ type: 'notificationClick', path });
|
|
return;
|
|
}
|
|
// No app window open — open one at the app root (the client routes from
|
|
// there). Router config (hash vs browser) is page-owned, so we don't try
|
|
// to deep-link the URL here.
|
|
if (self.clients.openWindow) {
|
|
await self.clients.openWindow(self.registration.scope);
|
|
}
|
|
})(),
|
|
);
|
|
});
|
|
|
|
const MEDIA_PATHS = ['/_matrix/client/v1/media/download', '/_matrix/client/v1/media/thumbnail'];
|
|
|
|
function mediaPath(url: string): boolean {
|
|
try {
|
|
const { pathname } = new URL(url);
|
|
return MEDIA_PATHS.some((p) => pathname.startsWith(p));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function validMediaRequest(url: string, baseUrl: string): boolean {
|
|
return MEDIA_PATHS.some((p) => {
|
|
const validUrl = new URL(p, baseUrl);
|
|
return url.startsWith(validUrl.href);
|
|
});
|
|
}
|
|
|
|
function fetchConfig(token: string): RequestInit {
|
|
return {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
cache: 'default',
|
|
};
|
|
}
|
|
|
|
self.addEventListener('fetch', (event: FetchEvent) => {
|
|
const { url, method } = event.request;
|
|
|
|
// [Gitea #155] Android share sheet → POST /share-target (manifest
|
|
// share_target). Stash the form and bounce to the in-app /share page.
|
|
if (method === 'POST' && new URL(url).pathname.endsWith(SHARE_TARGET_PATH)) {
|
|
const base = new URL(url).pathname.slice(0, -SHARE_TARGET_PATH.length);
|
|
event.respondWith(
|
|
stashSharedForm(event.request, base).catch(() => Response.redirect(`${base}/`, 303)),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (method !== 'GET' || !mediaPath(url)) return;
|
|
|
|
const { clientId } = event;
|
|
if (!clientId) return;
|
|
|
|
const session = sessions.get(clientId);
|
|
if (session) {
|
|
if (validMediaRequest(url, session.baseUrl)) {
|
|
event.respondWith(fetch(url, fetchConfig(session.accessToken)));
|
|
}
|
|
return;
|
|
}
|
|
|
|
event.respondWith(
|
|
requestSessionWithTimeout(clientId).then((s) => {
|
|
if (s && validMediaRequest(url, s.baseUrl)) {
|
|
return fetch(url, fetchConfig(s.accessToken));
|
|
}
|
|
return fetch(event.request);
|
|
}),
|
|
);
|
|
});
|