diff --git a/lib/core/auth/auth_notifier.dart b/lib/core/auth/auth_notifier.dart index b0318ee..53a7f4c 100644 --- a/lib/core/auth/auth_notifier.dart +++ b/lib/core/auth/auth_notifier.dart @@ -1,15 +1,19 @@ -// Version: 2.0.0 | Created: 2026-04-01 | Updated: 2026-04-11 +// Version: 2.1.0 | Created: 2026-04-01 | Updated: 2026-04-27 // Riverpod notifier that owns the auth state machine. // // WEB SECURITY MODEL: Every visit requires a fresh login. No session // persistence. Only the Matrix device ID is saved across sessions so // encryption keys accumulate on one device instead of creating ghosts. +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../features/auth/data/auth_repository.dart'; import '../../features/auth/domain/auth_failure.dart'; +import '../network/matrix_client.dart'; +import '../push/web_push_service.dart'; import '../storage/sync_persistence_service.dart'; import 'auth_state.dart'; @@ -51,6 +55,10 @@ class AuthNotifier extends _$AuthNotifier { try { ref.read(syncPersistenceServiceProvider).start(); } catch (_) {} + + // Register Web Push subscription (fire-and-forget, non-fatal). + final client = ref.read(matrixClientProvider); + unawaited(setupWebPush(client)); } on AuthFailure catch (failure) { state = AuthState.unauthenticated(failure: failure.userMessage); } diff --git a/lib/core/push/web_push_service.dart b/lib/core/push/web_push_service.dart new file mode 100644 index 0000000..f072920 --- /dev/null +++ b/lib/core/push/web_push_service.dart @@ -0,0 +1,178 @@ +// Version: 1.0.0 | Created: 2026-04-27 +// Web Push subscription and Matrix pusher registration. +// Only active on web — no-ops on other platforms. +// +// Flow: +// 1. registerPushServiceWorker() — register push_sw.js (called at startup) +// 2. setupWebPush(client) — called after login; requests permission, +// subscribes to Web Push, registers the subscription as a Matrix pusher. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; + +import 'package:flutter/foundation.dart'; +import 'package:matrix/matrix.dart'; +import 'package:web/web.dart' as web; + +const _kVapidPublicKey = + 'BPP0AqC7qTxspQn1i0x2sPbPj3owJTTkhV256A67VUK9748NjaBfbMrQBJtI58foVSVpbk2n390NIq_w2glNjs8'; + +const _kAppId = 'au.m8chat.web.push'; +const _kPushGatewayUrl = 'https://matrix.m8chat.au/_matrix/push/v1/notify'; + +/// Registers push_sw.js at startup so the browser knows the service worker +/// before the user logs in. Idempotent. No-op on non-web platforms. +Future registerPushServiceWorker() async { + if (!kIsWeb) return; + try { + await _registerServiceWorker(); + } catch (e) { + debugPrint('[Push] SW pre-registration failed (non-fatal): $e'); + } +} + +/// Called after login. Requests notification permission, subscribes to Web +/// Push using the VAPID public key, and registers the subscription as a +/// Matrix pusher so Synapse routes notifications through Sygnal → browser. +/// +/// Idempotent — safe to call on every login. No-op on non-web platforms. +Future setupWebPush(Client client) async { + if (!kIsWeb) return; + + try { + final sw = await _registerServiceWorker(); + if (sw == null) { + debugPrint('[Push] SW not available — push skipped'); + return; + } + + final permission = await _requestPermission(); + if (permission != 'granted') { + debugPrint('[Push] Notification permission $permission — skipped'); + return; + } + + final sub = await _subscribe(sw); + if (sub == null) { + debugPrint('[Push] Could not create push subscription'); + return; + } + + await _registerPusher(client, sub); + } catch (e) { + debugPrint('[Push] Setup failed (non-fatal): $e'); + } +} + +// --------------------------------------------------------------------------- + +Future _registerServiceWorker() async { + if (!_pushSupported()) return null; + + try { + final reg = await web.window.navigator.serviceWorker + .register('/push_sw.js'.toJS, web.RegistrationOptions(scope: '/')) + .toDart; + debugPrint('[Push] Service worker registered: ${reg.scope}'); + return reg; + } catch (e) { + debugPrint('[Push] Service worker error: $e'); + return null; + } +} + +Future _requestPermission() async { + try { + final result = await web.Notification.requestPermission().toDart; + return result.toDart; + } catch (_) { + return 'denied'; + } +} + +Future _subscribe( + web.ServiceWorkerRegistration reg) async { + try { + // Re-use existing subscription if the browser already has one. + final existing = await reg.pushManager.getSubscription().toDart; + if (existing != null) { + debugPrint('[Push] Reusing existing push subscription'); + return existing; + } + + final keyBytes = _vapidKeyToBytes(_kVapidPublicKey); + final sub = await reg.pushManager + .subscribe(web.PushSubscriptionOptionsInit( + userVisibleOnly: true, + applicationServerKey: keyBytes, + )) + .toDart; + debugPrint('[Push] New subscription: ${sub.endpoint}'); + return sub; + } catch (e) { + debugPrint('[Push] Subscribe error: $e'); + return null; + } +} + +Future _registerPusher( + Client client, web.PushSubscription sub) async { + final json = sub.toJSON(); + final endpoint = json.endpoint; + if (endpoint.isEmpty) return; + + // Extract p256dh and auth keys from the subscription. + final keysObj = json.keys; + final p256dh = _jsObjectGet(keysObj, 'p256dh') ?? ''; + final auth = _jsObjectGet(keysObj, 'auth') ?? ''; + + await client.postPusher( + Pusher( + pushkey: endpoint, + kind: 'http', + appId: _kAppId, + appDisplayName: 'M8Chat Web', + deviceDisplayName: 'Web browser', + lang: 'en', + data: PusherData( + url: Uri.parse(_kPushGatewayUrl), + format: 'event_id_only', + additionalProperties: { + 'keys': {'p256dh': p256dh, 'auth': auth}, + }, + ), + ), + ); + debugPrint('[Push] Matrix pusher registered'); +} + +/// Converts a URL-safe base64 VAPID public key to a JS Uint8Array. +JSUint8Array _vapidKeyToBytes(String base64Url) { + final padded = base64Url + + '=' * ((4 - base64Url.length % 4) % 4); + final standard = padded.replaceAll('-', '+').replaceAll('_', '/'); + final bytes = base64Decode(standard); + return bytes.toJS; +} + +/// Reads a string property from a JSObject via js_interop. +String? _jsObjectGet(JSObject obj, String key) { + try { + final val = obj.getProperty(key.toJS); + if (val == null || val.isUndefinedOrNull) return null; + return (val as JSString).toDart; + } catch (_) { + return null; + } +} + +bool _pushSupported() { + try { + web.window.navigator.serviceWorker; + return true; + } catch (_) { + return false; + } +} diff --git a/lib/main.dart b/lib/main.dart index 403e524..9aedeef 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,12 +1,15 @@ -// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-10 +// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-27 // Application entry point. +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:olm/olm.dart' as olm; import 'app/app.dart'; +import 'core/push/web_push_service.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -19,6 +22,12 @@ void main() async { debugPrint('[M8Chat] Olm NOT available: $e'); } + // Register the push service worker at startup so the browser knows about it. + // The actual push subscription happens after login (in auth_notifier.dart). + if (kIsWeb) { + unawaited(registerPushServiceWorker()); + } + // Catch Flutter framework errors (widget build, rendering) without crashing. FlutterError.onError = (details) { debugPrint('[M8Chat] Flutter error: ${details.exceptionAsString()}'); diff --git a/pubspec.yaml b/pubspec.yaml index 36547d4..fe472f3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: m8chat_app description: "M8Chat — Matrix chat client for Android, iOS, and Web." publish_to: 'none' -version: 1.4.0+6 +version: 1.5.0+7 environment: sdk: '>=3.11.0 <4.0.0' diff --git a/web/push_sw.js b/web/push_sw.js new file mode 100644 index 0000000..a7018ca --- /dev/null +++ b/web/push_sw.js @@ -0,0 +1,64 @@ +// Version: 1.0.0 | Created: 2026-04-27 +// Web Push service worker for M8Chat. +// Receives push events from the browser and shows system notifications. +// This script is registered separately from flutter_service_worker.js. + +'use strict'; + +self.addEventListener('push', function (event) { + if (!event.data) return; + + let data = {}; + try { + data = event.data.json(); + } catch (_) { + data = { notification: { body: event.data.text() } }; + } + + // Sygnal delivers: { notification: { content: { body, msgtype, ... }, room_name, sender_display_name, ... } } + const notif = data.notification || {}; + const content = notif.content || {}; + + const sender = notif.sender_display_name || notif.sender || 'M8Chat'; + const room = notif.room_name || notif.room_alias || ''; + const body = content.body || notif.body || 'New message'; + + const title = room ? `${sender} in ${room}` : sender; + + const options = { + body: body, + icon: '/icons/Icon-192.png', + badge: '/icons/Icon-192.png', + tag: notif.room_id || 'm8chat', + data: { + roomId: notif.room_id, + url: self.location.origin + (notif.room_id + ? '/rooms/' + encodeURIComponent(notif.room_id) + : '/rooms'), + }, + requireInteraction: false, + }; + + event.waitUntil(self.registration.showNotification(title, options)); +}); + +self.addEventListener('notificationclick', function (event) { + event.notification.close(); + + const url = (event.notification.data && event.notification.data.url) + ? event.notification.data.url + : self.location.origin + '/rooms'; + + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (clientList) { + for (const client of clientList) { + if (client.url === url && 'focus' in client) { + return client.focus(); + } + } + if (clients.openWindow) { + return clients.openWindow(url); + } + }) + ); +});