Be smarter about web push not being available on snakeoil certs for mobile

This commit is contained in:
Jack Kingsman
2026-04-19 21:10:17 -07:00
parent f5a2a21f11
commit 330007e120
5 changed files with 76 additions and 1133 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -733,9 +733,9 @@ export function SettingsRadioSection({
placeholder="MyRegion"
/>
<p className="text-[0.8125rem] text-muted-foreground">
Tag outgoing flood messages with a region name (e.g. MyRegion). Repeaters configured for
that region can forward the traffic, while repeaters configured to deny other regions may
drop it. Leave empty to disable.
Tag outgoing messages with a region name (e.g. MyRegion). Repeaters configured for that
region can forward the traffic, while repeaters configured to deny other regions may drop
it. Leave empty to disable.
</p>
</div>

View File

@@ -37,6 +37,33 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array {
return arr;
}
/** Race a promise against a timeout; rejects with a descriptive error on expiry. */
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() =>
reject(
new Error(
`${label} timed out — the service worker may have failed to install. ` +
'Mobile browsers require a trusted TLS certificate for service workers, ' +
'even if the page itself loads with a self-signed cert.'
)
),
ms
);
promise.then(
(v) => {
clearTimeout(timer);
resolve(v);
},
(e) => {
clearTimeout(timer);
reject(e);
}
);
});
}
function uint8ArraysEqual(a: Uint8Array | null, b: Uint8Array): boolean {
if (!a || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
@@ -109,8 +136,9 @@ export function usePushSubscription(): PushSubscriptionState {
const subsPromise = api.getPushSubscriptions().catch(() => [] as PushSubscriptionInfo[]);
// Check if THIS browser has an active push subscription and match it
// to a backend record.
navigator.serviceWorker.ready
// to a backend record. Use a timeout so we don't hang forever when the
// service worker failed to install (e.g. mobile + self-signed cert).
withTimeout(navigator.serviceWorker.ready, 1_000, 'Service worker activation')
.then((reg) => reg.pushManager.getSubscription())
.then(async (sub) => {
const existing = await subsPromise;
@@ -129,7 +157,11 @@ export function usePushSubscription(): PushSubscriptionState {
const refreshSubscriptions = useCallback(async () => {
try {
const subs = await api.getPushSubscriptions();
const reg = await navigator.serviceWorker.ready;
const reg = await withTimeout(
navigator.serviceWorker.ready,
10_000,
'Service worker activation'
);
const sub = await reg.pushManager.getSubscription();
reconcileCurrentSubscription(subs, sub?.endpoint ?? null);
return subs;
@@ -155,7 +187,11 @@ export function usePushSubscription(): PushSubscriptionState {
vapidKeyRef.current = resp.public_key;
const vapidKeyBytes = urlBase64ToUint8Array(resp.public_key);
const reg = await navigator.serviceWorker.ready;
const reg = await withTimeout(
navigator.serviceWorker.ready,
3_000,
'Service worker activation'
);
let pushSub = await reg.pushManager.getSubscription();
const existingKeyBytes = getApplicationServerKeyBytes(pushSub?.options?.applicationServerKey);
const requiresRecreate =

View File

@@ -24,5 +24,7 @@ createRoot(document.getElementById('root')!).render(
// Register service worker for Web Push (requires secure context)
if ('serviceWorker' in navigator && window.isSecureContext) {
navigator.serviceWorker.register('./sw.js').catch(() => {});
navigator.serviceWorker.register('./sw.js').catch((err) => {
console.warn('Service worker registration failed:', err);
});
}

View File

@@ -150,6 +150,36 @@ describe('usePushSubscription', () => {
expect(result.current.allSubscriptions).toEqual([]);
});
it(
'times out and shows a toast when service worker never activates',
async () => {
// Replace serviceWorker.ready with a promise that never resolves
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: {
ready: new Promise(() => {}),
},
});
const { result } = renderHook(() => usePushSubscription());
await waitFor(() => {
expect(result.current.isSupported).toBe(true);
});
// subscribe() will hang on serviceWorker.ready, then the 1s timeout fires
await act(async () => {
await result.current.subscribe();
});
expect(result.current.loading).toBe(false);
expect(mocks.toast.error).toHaveBeenCalledWith('Failed to enable push notifications', {
description: expect.stringContaining('trusted TLS certificate for service workers'),
});
},
5_000
);
it('recreates a stale browser subscription when the server VAPID key changed', async () => {
const oldSubscription = activeSubscription;
mocks.api.getPushSubscriptions