feat(notify): web push as a notification channel
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m15s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m15s
- VAPID keys from VAPID_* or generated once (push_keys, migration 9); devices in push_subscriptions, forgotten on 404/410 from the push service - push column for every event (also due tasks) and the daily summary, bundles as one notification; /push/key, /devices, /subscribe, /unsubscribe, /test - settings: switch push on per device, test it, see the devices; the service worker shows notifications and focuses MindSift on click - tests decrypt a sent push like a browser (RFC 8291) and check VAPID Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1a49e6b304
commit
38b8c92899
27 files changed
+918
-27
No files matched your search
@@ -0,0 +1,14 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { deviceName, urlBase64ToUint8Array } from '../push';
|
||||
|
||||
describe('push helpers', () => {
|
||||
it('decodes the VAPID key (base64url without padding)', () => {
|
||||
expect(Array.from(urlBase64ToUint8Array('AQID_-8'))).toEqual([1, 2, 3, 255, 239]);
|
||||
});
|
||||
|
||||
it('names the device', () => {
|
||||
expect(deviceName('Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0')).toBe('Firefox auf Linux');
|
||||
expect(deviceName('Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/129.0 Mobile Safari/537.36')).toBe('Chrome auf Android');
|
||||
expect(deviceName('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1')).toBe('Safari auf iOS');
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Document,
|
||||
TrashedDocument,
|
||||
JournalDay,
|
||||
PushDevice,
|
||||
BackupFile,
|
||||
DataSection,
|
||||
ApiKeyScope,
|
||||
@@ -724,6 +725,27 @@ export const api = {
|
||||
return request<TrashedDocument[]>('/api/v1/documents/trash');
|
||||
},
|
||||
|
||||
async getPushKey(): Promise<{ enabled: boolean; publicKey?: string }> {
|
||||
return request('/api/v1/push/key');
|
||||
},
|
||||
|
||||
async getPushDevices(): Promise<PushDevice[]> {
|
||||
const res = await request<{ devices: PushDevice[] }>('/api/v1/push/devices');
|
||||
return res.devices || [];
|
||||
},
|
||||
|
||||
async subscribePush(sub: PushSubscriptionJSON, device: string): Promise<void> {
|
||||
await request('/api/v1/push/subscribe', { method: 'POST', body: JSON.stringify({ ...sub, device }) });
|
||||
},
|
||||
|
||||
async unsubscribePush(endpoint: string): Promise<void> {
|
||||
await request('/api/v1/push/unsubscribe', { method: 'POST', body: JSON.stringify({ endpoint }) });
|
||||
},
|
||||
|
||||
async testPush(): Promise<{ devices: number }> {
|
||||
return request('/api/v1/push/test', { method: 'POST' });
|
||||
},
|
||||
|
||||
/** Days of the own journal (newest first) with their notes. */
|
||||
async getJournal(): Promise<JournalDay[]> {
|
||||
const res = await request<{ days: JournalDay[] }>('/api/v1/journal');
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { api } from './api';
|
||||
|
||||
// Web push in this browser: the service worker shows the notifications, the server sends them over the
|
||||
// browser's push service (VAPID). iPhones and iPads need MindSift added to the home screen first.
|
||||
|
||||
export function pushSupported(): boolean {
|
||||
return typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
|
||||
}
|
||||
|
||||
/** iOS/iPadOS outside the installed home screen app: push is not offered there */
|
||||
export function needsHomeScreenInstall(): boolean {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const standalone =
|
||||
window.matchMedia?.('(display-mode: standalone)').matches || (navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||||
return ios && !standalone;
|
||||
}
|
||||
|
||||
export function deviceName(ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''): string {
|
||||
const browser = /Edg\//.test(ua) ? 'Edge' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Browser';
|
||||
const os = /Android/.test(ua) ? 'Android' : /iPhone|iPad|iPod/.test(ua) ? 'iOS' : /Windows/.test(ua) ? 'Windows' : /Mac OS X/.test(ua) ? 'macOS' : /Linux/.test(ua) ? 'Linux' : '';
|
||||
return os ? `${browser} auf ${os}` : browser;
|
||||
}
|
||||
|
||||
export function urlBase64ToUint8Array(base64: string): Uint8Array {
|
||||
const padded = (base64 + '='.repeat((4 - (base64.length % 4)) % 4)).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const raw = atob(padded);
|
||||
const out = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function registration(): Promise<ServiceWorkerRegistration> {
|
||||
return navigator.serviceWorker.ready;
|
||||
}
|
||||
|
||||
/** The subscription of this browser, if push is switched on here. */
|
||||
export async function currentPushSubscription(): Promise<PushSubscription | null> {
|
||||
if (!pushSupported()) return null;
|
||||
try {
|
||||
return await (await registration()).pushManager.getSubscription();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function enablePush(): Promise<void> {
|
||||
if (!pushSupported()) throw new Error('Dieser Browser unterstützt keine Push-Benachrichtigungen');
|
||||
if (needsHomeScreenInstall()) throw new Error('Auf iPhone und iPad zuerst MindSift „Zum Home-Bildschirm“ hinzufügen und von dort öffnen');
|
||||
const key = await api.getPushKey();
|
||||
if (!key.enabled || !key.publicKey) throw new Error('Push ist auf diesem Server nicht verfügbar');
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') throw new Error('Benachrichtigungen wurden im Browser nicht erlaubt');
|
||||
const reg = await registration();
|
||||
let sub = await reg.pushManager.getSubscription();
|
||||
if (!sub) {
|
||||
try {
|
||||
sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(key.publicKey) as BufferSource,
|
||||
});
|
||||
} catch {
|
||||
// e.g. "Registration failed – permission denied": blocked for the site or no push service reachable
|
||||
throw new Error('Der Browser hat die Anmeldung beim Push-Dienst abgelehnt (Berechtigung blockiert oder Push-Dienst nicht erreichbar)');
|
||||
}
|
||||
}
|
||||
await api.subscribePush(sub.toJSON(), deviceName());
|
||||
}
|
||||
|
||||
export async function disablePush(): Promise<void> {
|
||||
const sub = await currentPushSubscription();
|
||||
if (!sub) return;
|
||||
const endpoint = sub.endpoint;
|
||||
await sub.unsubscribe().catch(() => false);
|
||||
await api.unsubscribePush(endpoint).catch(() => {});
|
||||
}
|
||||
@@ -50,6 +50,7 @@ export interface NotificationChannelRouting {
|
||||
email: boolean;
|
||||
discord: boolean;
|
||||
webhook: boolean;
|
||||
push: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationEvents {
|
||||
@@ -65,6 +66,15 @@ export interface DailySummarySettings {
|
||||
email: boolean;
|
||||
discord: boolean;
|
||||
webhook: boolean;
|
||||
push: boolean;
|
||||
}
|
||||
|
||||
/** A browser or installed app that receives web push */
|
||||
export interface PushDevice {
|
||||
id: number;
|
||||
endpoint: string;
|
||||
device: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface NotificationSettings {
|
||||
|
||||
Reference in new issue
Block a user