From 7d5f2ca3f26bc7ce20fa1e4042554d1d47201242 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sun, 23 Aug 2026 13:55:07 -0400 Subject: [PATCH 01/29] support for registering pushers --- .vscode/settings.json | 4 +- android/app/build.gradle.kts | 4 + lib/controllers/client.dart | 9 ++ lib/controllers/settings_sections.dart | 43 +++++++ lib/controllers/unified_push.dart | 106 ++++++++++++++++++ lib/main.dart | 34 +++--- lib/models/capabilities.dart | 25 +++++ lib/models/client_state.dart | 1 + lib/models/join_rule.dart | 2 +- lib/models/membership_action.dart | 3 - lib/models/membership_status.dart | 3 - lib/models/requests/register_pusher.dart | 56 +++++++++ linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + linux/runner/my_application.cc | 2 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 64 +++++++++++ pubspec.yaml | 2 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 20 files changed, 348 insertions(+), 21 deletions(-) create mode 100644 lib/controllers/unified_push.dart create mode 100644 lib/models/capabilities.dart create mode 100644 lib/models/requests/register_pusher.dart diff --git a/.vscode/settings.json b/.vscode/settings.json index 2ff533e..fac2ed8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,6 +11,8 @@ "muks", "prefs", "unban", - "unredact" + "unifiedpush", + "unredact", + "webpush" ] } diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8a23bb6..9f9a087 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -63,6 +63,10 @@ android { } } +dependencies { + implementation("org.unifiedpush.android:embedded-fcm-distributor:3.1.0") +} + kotlin { compilerOptions { jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21 diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index af44d2f..6ccf05b 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -15,6 +15,7 @@ import "package:nexus/controllers/sync_status.dart"; import "package:nexus/controllers/top_level_spaces.dart"; import "package:nexus/helpers/extensions/gomuks_buffer.dart"; import "package:nexus/main.dart"; +import "package:nexus/models/capabilities.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/oauth_auth_code_response.dart"; @@ -31,6 +32,7 @@ import "package:nexus/models/requests/oauth/get_auth_url.dart"; import "package:nexus/models/requests/oauth/register_client.dart"; import "package:nexus/models/requests/paginate.dart"; import "package:nexus/models/requests/redact_event.dart"; +import "package:nexus/models/requests/register_pusher.dart"; import "package:nexus/models/requests/report.dart"; import "package:nexus/models/requests/send_event.dart"; import "package:nexus/models/requests/send_message.dart"; @@ -279,6 +281,9 @@ class ClientController extends AsyncNotifier { Future setAccountData(SetAccountDataRequest request) => _sendCommand("set_account_data", request.toJson()); + Future registerPusher(RegisterPusherRequest request) => + _sendCommand("register_homeserver_push", request.toJson()); + Future uploadMedia(UploadMediaRequest request) async => .fromJson(await _sendCommand("upload_media", request.toJson())); @@ -316,6 +321,10 @@ class ClientController extends AsyncNotifier { Future getSpecVersions() async => .fromJson(await _sendCommand("get_versions")); + Future getCapabilities() async => Capabilities.fromJson( + (await _sendCommand("get_capabilities"))["capabilities"], + ); + Future discoverHomeserver(Uri homeserver) async { try { final response = await _sendCommand("discover_homeserver", { diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index 042b42b..fea0d26 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -9,6 +9,7 @@ import "package:nexus/controllers/account_data.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/settings.dart"; +import "package:nexus/controllers/unified_push.dart"; import "package:nexus/controllers/spec_versions.dart"; import "package:nexus/models/account_data.dart"; import "package:nexus/models/settings_category.dart"; @@ -95,6 +96,46 @@ class SettingsSectionsController if (ref.watch(ClientStateController.provider)?.isLoggedIn == true) "Account": .new([ .new(title: "Profile", icon: Icons.person, settings: .new([])), + .new( + title: "Notifications", + icon: Icons.notifications, + settings: .new([ + // TODO: Disable if not supported + .new( + title: "Push notifications", + description: "Enable push notifications using Web Push", + builder: (title, description, icon) => Consumer( + builder: (context, ref, _) { + final pusherRegistered = ref.watch( + UnifiedPushController.provider, + ); + return SwitchListTile( + title: Text(title), + subtitle: Text(description), + secondary: Icon(icon), + value: pusherRegistered.maybeWhen( + data: (value) => value, + orElse: () => false, + ), + onChanged: pusherRegistered.maybeWhen( + data: (_) => + (value) => value + ? ref + .watch( + UnifiedPushController.provider.notifier, + ) + .register() + .onError(showError) + : /*deregeister*/ null, + orElse: () => null, + ), + ); + }, + ), + icon: Icons.notification_add, + ), + ]), + ), .new( title: "Safety", icon: Icons.gpp_good, @@ -166,6 +207,8 @@ class SettingsSectionsController onPressed: () async { Navigator.of(context) .popUntil((route) => route.isFirst); + Navigator.of(context) + .popUntil((route) => route.isFirst); await WidgetsBinding.instance.endOfFrame; diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart new file mode 100644 index 0000000..c7a6cde --- /dev/null +++ b/lib/controllers/unified_push.dart @@ -0,0 +1,106 @@ +import "dart:io"; + +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:intl/intl.dart"; +import "package:nexus/main.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/models/requests/register_pusher.dart"; +import "package:unifiedpush/unifiedpush.dart"; +import "package:unifiedpush_storage_shared_preferences/storage.dart"; + +class UnifiedPushController extends AsyncNotifier { + @override + Future build() async { + final registered = await UnifiedPush.initialize( + linuxOptions: .new( + dbusName: "nexus.federated.nexus", + storage: UnifiedPushStorageSharedPreferences(), + background: isInBackground, + ), + onNewEndpoint: (endpoint, instance) async { + final client = ref.watch(ClientController.provider.notifier); + await client.registerPusher( + .new( + appDisplayName: "Nexus", + appId: "nexus.federated.nexus", + data: PusherData.webPush( + url: Uri.parse(endpoint.url), + auth: endpoint.pubKeySet!.auth, + ), + deviceDisplayName: + "Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}", + kind: .webPush, + lang: "en", + pushKey: endpoint.pubKeySet!.pubKey, + ), + + // Pusher( + // appDisplayName: , + // data: { + // "url": endpoint.url, + // "auth": endpoint.pubKeySet.auth, + // "format": "event_id_only", + // }, + // deviceDisplayName: "Nexus", + // kind: "org.matrix.msc4174.webpush", + // lang: WidgetsBinding.instance.platformDispatcher.locale + // .toLanguageTag(), + // pushkey: endpoint.pubKeySet.publicKey, + // ), + ); + }, + onMessage: (message, instance) async { + print(message); + // TODO: Handle incoming message + }, + onRegistrationFailed: (e, r) { + throw "e"; + }, + onUnregistered: (_) { + throw "r"; + }, + onTempUnavailable: (_) { + throw "t"; + }, + ); + + if (registered) { + // Needs to be registered every startup + await register(true); + } + + return registered; + } + + Future register([bool early = false]) async { + final clientState = ref.watch(ClientStateController.provider); + if (clientState?.deviceId == null) ref.invalidateSelf(); + + final alreadyRegistered = early ? true : await future; + final client = ref.watch(ClientController.provider.notifier); + final capabilities = await client.getCapabilities(); + + if (capabilities.webpush?.vapid == null) { + throw UnsupportedError( + "Your homeserver does not support MSC4174 (Web Push), and therefore cannot send notifications to Nexus.", + ); + } + + if (!alreadyRegistered && + !await UnifiedPush.tryUseCurrentOrDefaultDistributor()) { + throw Exception("No UnifiedPush distributors found"); + } + + await UnifiedPush.register( + instance: clientState!.deviceId!, + vapid: capabilities.webpush?.vapid, + ); + + if (!alreadyRegistered) ref.invalidateSelf(); + } + + static final provider = AsyncNotifierProvider( + UnifiedPushController.new, + ); +} diff --git a/lib/main.dart b/lib/main.dart index a87d7f5..7b2aa63 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/multi_provider.dart"; import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/shared_prefs.dart"; +import "package:nexus/controllers/unified_push.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/scheme_to_theme.dart"; import "package:nexus/helpers/font_licenses.dart"; @@ -22,6 +23,7 @@ import "package:window_manager/window_manager.dart"; import "package:material_ui/material_ui.dart"; final GlobalKey navigatorKey = GlobalKey(); +late final bool isInBackground; final class Logger extends ProviderObserver { @override @@ -62,7 +64,7 @@ void showError(Object error, [StackTrace? stackTrace]) { } } -void main() async { +void main(List args) async { WidgetsFlutterBinding.ensureInitialized(); MediaKit.ensureInitialized(); @@ -77,25 +79,30 @@ void main() async { await windowManager.setMinimumSize(Size.square(500)); } + isInBackground = args.contains("--unifiedpush-bg"); + LicenseRegistry.addLicense(() => Stream.fromIterable(fontLicenses)); FlutterError.onError = (FlutterErrorDetails details) => showError(details.exception.toString(), details.stack); - runApp( - ProviderScope( - retry: (_, _) => null, - observers: [ - // Change false to true if you want debug information on provider reloads - // ignore: dead_code - if (false && kDebugMode) Logger(), - ], - child: const App(), - ), - ); + if (!isInBackground) { + runApp( + ProviderScope( + retry: (_, _) => null, + observers: [ + // Change false to true if you want debug information on provider reloads + // ignore: dead_code + if (false && kDebugMode) Logger(), + ], + child: App(isInBackground), + ), + ); + } } -class const App({super.key}) extends StatelessWidget { +class const App(final bool isInBackground, {super.key}) + extends StatelessWidget { @override Widget build(BuildContext context) => DynamicColorBuilder( builder: (lightDynamic, darkDynamic) => Consumer( @@ -138,6 +145,7 @@ class const App({super.key}) extends StatelessWidget { IListConst([ SharedPrefsController.provider, ClientController.provider, + UnifiedPushController.provider, ]), ), ) diff --git a/lib/models/capabilities.dart b/lib/models/capabilities.dart new file mode 100644 index 0000000..6830170 --- /dev/null +++ b/lib/models/capabilities.dart @@ -0,0 +1,25 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "capabilities.freezed.dart"; +part "capabilities.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const Capabilities({ + @JsonKey(name: "org.matrix.msc4174.webpush") final WebPush? webpush, +}) with _$Capabilities { + Map toJson() => _$CapabilitiesToJson(this); + + factory Capabilities.fromJson(Map json) => + _$CapabilitiesFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const WebPush({required final bool enabled, final String? vapid}) + with _$WebPush { + Map toJson() => _$WebPushToJson(this); + + factory WebPush.fromJson(Map json) => + _$WebPushFromJson(json); +} diff --git a/lib/models/client_state.dart b/lib/models/client_state.dart index ff433ab..262203d 100644 --- a/lib/models/client_state.dart +++ b/lib/models/client_state.dart @@ -10,6 +10,7 @@ class const ClientState({ required final bool isLoggedIn, required final bool isVerified, required final String? userId, + required final String? deviceId, required final String? homeserverUrl, }) with _$ClientState { Map toJson() => _$ClientStateToJson(this); diff --git a/lib/models/join_rule.dart b/lib/models/join_rule.dart index 3fade23..44fb9bb 100644 --- a/lib/models/join_rule.dart +++ b/lib/models/join_rule.dart @@ -1,4 +1,4 @@ import "package:freezed_annotation/freezed_annotation.dart"; -@JsonEnum(fieldRename: FieldRename.snake) +@JsonEnum(fieldRename: .snake) enum JoinRule { public, knock, invite, private, restricted, knockRestricted } diff --git a/lib/models/membership_action.dart b/lib/models/membership_action.dart index d852164..f03ca99 100644 --- a/lib/models/membership_action.dart +++ b/lib/models/membership_action.dart @@ -1,4 +1 @@ -import "package:freezed_annotation/freezed_annotation.dart"; - -@JsonEnum() enum MembershipAction { ban, kick, unban, invite } diff --git a/lib/models/membership_status.dart b/lib/models/membership_status.dart index ba7a241..7eb5f72 100644 --- a/lib/models/membership_status.dart +++ b/lib/models/membership_status.dart @@ -1,4 +1 @@ -import "package:freezed_annotation/freezed_annotation.dart"; - -@JsonEnum() enum MembershipStatus { leave, invite, ban, join, knock } diff --git a/lib/models/requests/register_pusher.dart b/lib/models/requests/register_pusher.dart new file mode 100644 index 0000000..dd34e62 --- /dev/null +++ b/lib/models/requests/register_pusher.dart @@ -0,0 +1,56 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "register_pusher.freezed.dart"; +part "register_pusher.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const RegisterPusherRequest({ + required final String appDisplayName, + required final String appId, + final bool append = false, + required final PusherData data, + required final String deviceDisplayName, + required final PusherKind kind, + required final String lang, + + /// TODO: What does this do? + final String? profileTag, + + @JsonKey(name: "pushkey") required final String pushKey, +}) with _$RegisterPusherRequest { + Map toJson() => _$RegisterPusherRequestToJson(this); +} + +@freezed +sealed class PusherData with _$PusherData { + const factory PusherData.http({ + required Uri url, + @Default(PushFormat.eventIdOnly) PushFormat format, + }) = HttpPusherData; + + const factory PusherData.webPush({ + required Uri url, + @Default(PushFormat.eventIdOnly) PushFormat format, + + /// `data.auth`: RFC8291 authentication secret. + required String auth, + }) = WebPushPusherData; + + factory PusherData.fromJson(Map json) => + _$PusherDataFromJson(json); +} + +@JsonEnum(fieldRename: .snake) +enum PushFormat { + @JsonValue(null) + all, + eventIdOnly, +} + +enum PusherKind { + http, + email, + @JsonValue("org.matrix.msc4174.webpush") + webPush, +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 603ea6a..70d9ba2 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -13,6 +13,7 @@ #include #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -37,6 +38,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) webcrypto_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WebcryptoPlugin"); + webcrypto_plugin_register_with_registrar(webcrypto_registrar); g_autoptr(FlPluginRegistrar) window_manager_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); window_manager_plugin_register_with_registrar(window_manager_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 7d8f046..1d53ad7 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_video screen_retriever_linux url_launcher_linux + webcrypto window_manager ) diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 62e5450..739e6df 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -1,5 +1,6 @@ #include "my_application.h" +#include #include #ifdef GDK_WINDOWING_X11 #include @@ -72,6 +73,7 @@ static void my_application_activate(GApplication* application) { gdk_rgba_parse(&background_color, "#000000"); fl_view_set_background_color(view, &background_color); gtk_widget_show(GTK_WIDGET(view)); + if (std::getenv("FLUTTER_HEADLESS")) gtk_widget_hide(GTK_WIDGET(window)); gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); // Show the window when Flutter renders. diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index f18d831..5d90835 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -15,6 +15,7 @@ import screen_retriever_macos import shared_preferences_foundation import url_launcher_macos import wakelock_plus +import webcrypto import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { @@ -28,5 +29,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) + WebcryptoPlugin.register(with: registry.registrar(forPlugin: "WebcryptoPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index d45c4d4..1625e8a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1509,6 +1509,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + unifiedpush: + dependency: "direct main" + description: + name: unifiedpush + sha256: "8ed9767f750a1dc6159a77e2171641d0cb825dc87682d1ce1b8618689b79f58e" + url: "https://pub.dev" + source: hosted + version: "6.2.0" + unifiedpush_android: + dependency: transitive + description: + name: unifiedpush_android + sha256: "2e6684e0a1a39ad8d6c7bc0d197954d50686acb1b1a7614b18ab0e0126f5492a" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + unifiedpush_linux: + dependency: transitive + description: + name: unifiedpush_linux + sha256: c062d5eedd1cec70bcd33270cc4e01ae0ff6501f33d471167c06b34a968adfeb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + unifiedpush_platform_interface: + dependency: transitive + description: + name: unifiedpush_platform_interface + sha256: "83372bc8d794b8b12ef6993b518d7be907dcfc2191bdf6de0ece5c4445d89880" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + unifiedpush_storage_interface: + dependency: transitive + description: + name: unifiedpush_storage_interface + sha256: b8d423a4695efc616aa21d8ab48fb5ef99d6288c68b56282b8faac1579ceabd9 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + unifiedpush_storage_shared_preferences: + dependency: "direct main" + description: + name: unifiedpush_storage_shared_preferences + sha256: eda9c52bac0058f81e0d9c65e33eedc12c5901d2e68ad47fdf68175ddf00a3b4 + url: "https://pub.dev" + source: hosted + version: "1.0.0" universal_platform: dependency: transitive description: @@ -1685,6 +1733,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webcrypto: + dependency: transitive + description: + name: webcrypto + sha256: "6b43001c4110856ff7fa5e5e65e7b2d44bec1d8b54a4d84d5fa2c7622267c5c1" + url: "https://pub.dev" + source: hosted + version: "0.6.0" webkit_inspection_protocol: dependency: transitive description: @@ -1693,6 +1749,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + webpush_encryption: + dependency: transitive + description: + name: webpush_encryption + sha256: "5b83272b91acda6ae515fcd980c94f06bf413702282497c5a68f5dfc64fed27f" + url: "https://pub.dev" + source: hosted + version: "1.0.1" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a9b2e01..520cb91 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -79,6 +79,8 @@ dependencies: app_links: 7.2.1 file_selector: 1.1.0 material_ui: 1.0.1 + unifiedpush: ^6.2.0 + unifiedpush_storage_shared_preferences: ^1.0.0 dev_dependencies: build_runner: 2.16.0 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 0694802..5d2b53e 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -13,6 +13,7 @@ #include #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -30,6 +31,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); + WebcryptoPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WebcryptoPlugin")); WindowManagerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("WindowManagerPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 238ef93..51af01c 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_video screen_retriever_windows url_launcher_windows + webcrypto window_manager ) From 0f1ff71522ef14438844e7a058d0700c35345de4 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sun, 23 Aug 2026 14:26:51 -0400 Subject: [PATCH 02/29] add UnifiedPushAllowedController --- lib/controllers/settings_sections.dart | 45 ++++++++++++++++------- lib/controllers/unified_push.dart | 5 ++- lib/controllers/unified_push_allowed.dart | 33 +++++++++++++++++ 3 files changed, 67 insertions(+), 16 deletions(-) create mode 100644 lib/controllers/unified_push_allowed.dart diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index fea0d26..8d67435 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -11,6 +11,7 @@ import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/unified_push.dart"; import "package:nexus/controllers/spec_versions.dart"; +import "package:nexus/controllers/unified_push_allowed.dart"; import "package:nexus/models/account_data.dart"; import "package:nexus/models/settings_category.dart"; import "package:nexus/main.dart"; @@ -100,7 +101,6 @@ class SettingsSectionsController title: "Notifications", icon: Icons.notifications, settings: .new([ - // TODO: Disable if not supported .new( title: "Push notifications", description: "Enable push notifications using Web Push", @@ -109,26 +109,43 @@ class SettingsSectionsController final pusherRegistered = ref.watch( UnifiedPushController.provider, ); + final unifiedPushAllowed = ref.watch( + UnifiedPushAllowedController.provider, + ); return SwitchListTile( title: Text(title), - subtitle: Text(description), + subtitle: Text( + unifiedPushAllowed.maybeWhen( + data: (data) => data, + orElse: () => null, + ) ?? + description, + ), secondary: Icon(icon), value: pusherRegistered.maybeWhen( data: (value) => value, orElse: () => false, ), - onChanged: pusherRegistered.maybeWhen( - data: (_) => - (value) => value - ? ref - .watch( - UnifiedPushController.provider.notifier, - ) - .register() - .onError(showError) - : /*deregeister*/ null, - orElse: () => null, - ), + onChanged: + unifiedPushAllowed.maybeWhen( + data: (data) => data == null, + orElse: () => false, + ) + ? pusherRegistered.maybeWhen( + data: (_) => + (value) => value + ? ref + .watch( + UnifiedPushController + .provider + .notifier, + ) + .register() + .onError(showError) + : /*deregeister*/ null, + orElse: () => null, + ) + : null, ); }, ), diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index c7a6cde..b4aa107 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -78,8 +78,9 @@ class UnifiedPushController extends AsyncNotifier { if (clientState?.deviceId == null) ref.invalidateSelf(); final alreadyRegistered = early ? true : await future; - final client = ref.watch(ClientController.provider.notifier); - final capabilities = await client.getCapabilities(); + final capabilities = await ref + .watch(ClientController.provider.notifier) + .getCapabilities(); if (capabilities.webpush?.vapid == null) { throw UnsupportedError( diff --git a/lib/controllers/unified_push_allowed.dart b/lib/controllers/unified_push_allowed.dart new file mode 100644 index 0000000..4f925cf --- /dev/null +++ b/lib/controllers/unified_push_allowed.dart @@ -0,0 +1,33 @@ +import "dart:io"; + +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:unifiedpush/unifiedpush.dart"; + +class UnifiedPushAllowedController extends AsyncNotifier { + @override + Future build() async { + if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor()) { + return "No valid distributors found. Try installing ${Platform.isLinux + ? "KUnifiedPush" + : Platform.isAndroid + ? "FCM or NTFY" + : "NTFY"}."; + } + + final capabilities = await ref + .watch(ClientController.provider.notifier) + .getCapabilities(); + + if (capabilities.webpush?.vapid == null) { + return "Your homeserver does not support MSC4174 (Web Push), and therefore cannot send notifications to Nexus."; + } + + return null; + } + + static final provider = + AsyncNotifierProvider.autoDispose( + UnifiedPushAllowedController.new, + ); +} From b6e9852501283bc78675e2e7636cbef96b6790f4 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sun, 23 Aug 2026 22:13:38 -0400 Subject: [PATCH 03/29] slight cleanup `onNewEndpoint` is still not getting called for some reason (help appreciated) --- lib/controllers/unified_push.dart | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index b4aa107..3097e6d 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -34,20 +34,6 @@ class UnifiedPushController extends AsyncNotifier { lang: "en", pushKey: endpoint.pubKeySet!.pubKey, ), - - // Pusher( - // appDisplayName: , - // data: { - // "url": endpoint.url, - // "auth": endpoint.pubKeySet.auth, - // "format": "event_id_only", - // }, - // deviceDisplayName: "Nexus", - // kind: "org.matrix.msc4174.webpush", - // lang: WidgetsBinding.instance.platformDispatcher.locale - // .toLanguageTag(), - // pushkey: endpoint.pubKeySet.publicKey, - // ), ); }, onMessage: (message, instance) async { @@ -73,11 +59,10 @@ class UnifiedPushController extends AsyncNotifier { return registered; } - Future register([bool early = false]) async { + Future register([bool alreadyRegistered = false]) async { final clientState = ref.watch(ClientStateController.provider); if (clientState?.deviceId == null) ref.invalidateSelf(); - final alreadyRegistered = early ? true : await future; final capabilities = await ref .watch(ClientController.provider.notifier) .getCapabilities(); From e09df4de05e87b9a1aa35691212bcef83901dcbd Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 25 Aug 2026 17:30:55 -0400 Subject: [PATCH 04/29] working registration --- lib/controllers/unified_push.dart | 2 +- linux/nix/devshell.nix | 1 - pubspec.lock | 22 +++++++++++----------- pubspec.yaml | 7 ++++--- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index 3097e6d..0e7d1aa 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -14,7 +14,7 @@ class UnifiedPushController extends AsyncNotifier { Future build() async { final registered = await UnifiedPush.initialize( linuxOptions: .new( - dbusName: "nexus.federated.nexus", + dbusName: "nexus.federated.nexus.UnifiedPush", storage: UnifiedPushStorageSharedPreferences(), background: isInBackground, ), diff --git a/linux/nix/devshell.nix b/linux/nix/devshell.nix index dd3c78e..8bc8641 100644 --- a/linux/nix/devshell.nix +++ b/linux/nix/devshell.nix @@ -45,7 +45,6 @@ pkgs.mkShell { GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${ANDROID_HOME}/build-tools/36.0.0/aapt2"; } // lib.optionalAttrs pkgs.stdenv.isLinux { - CPATH = lib.makeSearchPath "include" [ pkgs.glibc.dev ]; LD_LIBRARY_PATH = "./build/native_assets/linux:${lib.makeLibraryPath [ pkgs.zlib ]}"; }; } diff --git a/pubspec.lock b/pubspec.lock index 1625e8a..2c6ba10 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -77,10 +77,10 @@ packages: dependency: transitive description: name: archive - sha256: be169cf6ac481e052c4538715d88841d567150dfe1df38aaec76461a4e7b39f2 + sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "4.2.0" args: dependency: transitive description: @@ -253,10 +253,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 url: "https://pub.dev" source: hosted - version: "0.3.5+4" + version: "0.3.5+5" crypto: dependency: transitive description: @@ -277,10 +277,10 @@ packages: dependency: transitive description: name: cupertino_ui - sha256: "7ed8ce4159d342eec4c65f4ea6eec57adaf9365404378541f38efc1da20a5b3d" + sha256: "2137c0d41f3b62cc4d3d2495e6c354bd6b6133cd21c6bb155736cc31dbd49a11" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.1" dart_style: dependency: transitive description: @@ -891,10 +891,10 @@ packages: dependency: "direct main" description: name: material_ui - sha256: "4f3f38b9953df0a87d6bf5f21880029f77c47048487d5339410c39936be4683b" + sha256: "7ba1ca315d50a004791dbab290417c52a61466d56db2822fe3c5c2994903110c" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.1.0" measure_size: dependency: "direct main" description: @@ -1521,10 +1521,10 @@ packages: dependency: transitive description: name: unifiedpush_android - sha256: "2e6684e0a1a39ad8d6c7bc0d197954d50686acb1b1a7614b18ab0e0126f5492a" + sha256: a902e505e91c8e28b54b379bb27f255f66df409274a97af24d6719f12f0d13dc url: "https://pub.dev" source: hosted - version: "3.4.1" + version: "3.5.0" unifiedpush_linux: dependency: transitive description: @@ -1734,7 +1734,7 @@ packages: source: hosted version: "3.0.3" webcrypto: - dependency: transitive + dependency: "direct main" description: name: webcrypto sha256: "6b43001c4110856ff7fa5e5e65e7b2d44bec1d8b54a4d84d5fa2c7622267c5c1" diff --git a/pubspec.yaml b/pubspec.yaml index 520cb91..9159199 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -78,13 +78,14 @@ dependencies: package_info_plus: 10.2.1 app_links: 7.2.1 file_selector: 1.1.0 - material_ui: 1.0.1 - unifiedpush: ^6.2.0 - unifiedpush_storage_shared_preferences: ^1.0.0 + material_ui: 1.1.0 + unifiedpush: 6.2.0 + unifiedpush_storage_shared_preferences: 1.0.0 dev_dependencies: build_runner: 2.16.0 flutter_lints: 6.0.0 + webcrypto: 0.6.0 freezed: 4.0.0 riverpod_lint: 3.1.8 flutter_launcher_icons: 0.14.4 From 52a703e183d31953a2dccea8770a22d348edd5c4 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 25 Aug 2026 19:51:43 -0400 Subject: [PATCH 05/29] pass pushes to gomuks --- lib/controllers/client.dart | 32 ++++++++++++++++++++++--------- lib/controllers/unified_push.dart | 15 ++++++++------- lib/main.dart | 1 + pubspec.lock | 2 +- 4 files changed, 33 insertions(+), 17 deletions(-) diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 6ccf05b..2347431 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -1,3 +1,4 @@ +import "dart:async"; import "dart:ffi"; import "dart:io"; import "dart:isolate"; @@ -156,18 +157,15 @@ class ClientController extends AsyncNotifier { throw Exception("GomuksStart returned error code $errorCode"); } - Future _sendCommand( - String command, [ - Map data = const {}, - ]) async { + Future callGomuksMethod( + Map data, + FutureOr Function(int handle, GomuksBorrowedBuffer data) + callback, + ) async { final bufferPointer = data.toGomuksBufferPtr(); final handle = await future; final response = await Isolate.run( - () => GomuksSubmitCommand( - handle, - command.toNativeUtf8().cast(), - bufferPointer.ref, - ), + () => callback(handle, bufferPointer.ref), ); calloc.free(bufferPointer); @@ -180,6 +178,22 @@ class ClientController extends AsyncNotifier { return json; } + Future handlePush(Map data) async => .fromJson( + await callGomuksMethod( + data, + (handle, data) async => GomuksHandlePush(handle, data), + ), + ); + + Future _sendCommand( + String command, [ + Map data = const {}, + ]) => callGomuksMethod( + data, + (handle, data) async => + GomuksSubmitCommand(handle, command.toNativeUtf8().cast(), data), + ); + Future redactEvent(RedactEventRequest report) => _sendCommand("redact_event", report.toJson()); diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index 0e7d1aa..bfa0ad1 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -1,3 +1,4 @@ +import "dart:convert"; import "dart:io"; import "package:flutter_riverpod/flutter_riverpod.dart"; @@ -12,6 +13,7 @@ import "package:unifiedpush_storage_shared_preferences/storage.dart"; class UnifiedPushController extends AsyncNotifier { @override Future build() async { + final client = ref.watch(ClientController.provider.notifier); final registered = await UnifiedPush.initialize( linuxOptions: .new( dbusName: "nexus.federated.nexus.UnifiedPush", @@ -19,7 +21,6 @@ class UnifiedPushController extends AsyncNotifier { background: isInBackground, ), onNewEndpoint: (endpoint, instance) async { - final client = ref.watch(ClientController.provider.notifier); await client.registerPusher( .new( appDisplayName: "Nexus", @@ -37,16 +38,16 @@ class UnifiedPushController extends AsyncNotifier { ); }, onMessage: (message, instance) async { - print(message); - // TODO: Handle incoming message + final e = await client.handlePush( + json.decode(String.fromCharCodes(message.content)), + ); + print(e); }, onRegistrationFailed: (e, r) { throw "e"; }, - onUnregistered: (_) { - throw "r"; - }, - onTempUnavailable: (_) { + onUnregistered: (instance) => ref.invalidateSelf(), + onTempUnavailable: (instance) { throw "t"; }, ); diff --git a/lib/main.dart b/lib/main.dart index 7b2aa63..2221151 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -47,6 +47,7 @@ void showError(Object error, [StackTrace? stackTrace]) { error.toString().contains("Invalid source") || error.toString().contains("UTF-16") || error.toString().contains("HTTP request failed") || + error.toString().contains("'_nextFrame != null': is not true.") || error.toString().contains("Invalid image data")) { return; } diff --git a/pubspec.lock b/pubspec.lock index 2c6ba10..ff25956 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1734,7 +1734,7 @@ packages: source: hosted version: "3.0.3" webcrypto: - dependency: "direct main" + dependency: "direct dev" description: name: webcrypto sha256: "6b43001c4110856ff7fa5e5e65e7b2d44bec1d8b54a4d84d5fa2c7622267c5c1" From 3403bedd5c3ec5e31097dc2150fb871a86bf32be Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 25 Aug 2026 20:18:47 -0400 Subject: [PATCH 06/29] override webcrypto deps --- lib/controllers/unified_push.dart | 11 +++------- linux/flutter/generated_plugin_registrant.cc | 4 ---- linux/flutter/generated_plugins.cmake | 1 - macos/Flutter/GeneratedPluginRegistrant.swift | 2 -- pubspec.lock | 22 ++++++++++++++++--- pubspec.yaml | 5 +++-- .../flutter/generated_plugin_registrant.cc | 3 --- windows/flutter/generated_plugins.cmake | 1 - 8 files changed, 25 insertions(+), 24 deletions(-) diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index bfa0ad1..f72e8ec 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -38,18 +38,13 @@ class UnifiedPushController extends AsyncNotifier { ); }, onMessage: (message, instance) async { - final e = await client.handlePush( + final event = await client.handlePush( json.decode(String.fromCharCodes(message.content)), ); - print(e); - }, - onRegistrationFailed: (e, r) { - throw "e"; + print(event); }, + onRegistrationFailed: (error, instance) => throw error, onUnregistered: (instance) => ref.invalidateSelf(), - onTempUnavailable: (instance) { - throw "t"; - }, ); if (registered) { diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 70d9ba2..603ea6a 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -13,7 +13,6 @@ #include #include #include -#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -38,9 +37,6 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); - g_autoptr(FlPluginRegistrar) webcrypto_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "WebcryptoPlugin"); - webcrypto_plugin_register_with_registrar(webcrypto_registrar); g_autoptr(FlPluginRegistrar) window_manager_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); window_manager_plugin_register_with_registrar(window_manager_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 1d53ad7..7d8f046 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -10,7 +10,6 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_video screen_retriever_linux url_launcher_linux - webcrypto window_manager ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 5d90835..f18d831 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -15,7 +15,6 @@ import screen_retriever_macos import shared_preferences_foundation import url_launcher_macos import wakelock_plus -import webcrypto import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { @@ -29,6 +28,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) - WebcryptoPlugin.register(with: registry.registrar(forPlugin: "WebcryptoPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index ff25956..6f957d9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -169,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.2" + change_case: + dependency: transitive + description: + name: change_case + sha256: e41ef3df58521194ef8d7649928954805aeb08061917cf658322305e61568003 + url: "https://pub.dev" + source: hosted + version: "2.2.0" characters: dependency: transitive description: @@ -999,6 +1007,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.19.3" + native_toolchain_cmake: + dependency: transitive + description: + name: native_toolchain_cmake + sha256: de90e8952bad0684bdcff4d674c53d961f5cd1fdcf3a0255a669a35986c4ac1d + url: "https://pub.dev" + source: hosted + version: "0.2.7" navigation_rail_m3e: dependency: "direct main" description: @@ -1734,13 +1750,13 @@ packages: source: hosted version: "3.0.3" webcrypto: - dependency: "direct dev" + dependency: "direct overridden" description: name: webcrypto - sha256: "6b43001c4110856ff7fa5e5e65e7b2d44bec1d8b54a4d84d5fa2c7622267c5c1" + sha256: "9885ed99a846191542dd6ca820c83a098117df56be57453832233a3fef68f3f2" url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "0.6.1" webkit_inspection_protocol: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9159199..7f9a9d0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,6 +19,8 @@ environment: sdk: 3.13.0 dependency_overrides: + webcrypto: 0.6.1 + hooks: 2.2.0 dynamic_color: 2.1.0 path_provider_android: 2.2.23 # Pinned to avoid JNI linkify: @@ -52,7 +54,7 @@ dependencies: dynamic_polls: 0.0.8 flutter_hooks: 0.21.3+1 ffi: 2.2.0 - hooks: ^2.2.0 + hooks: 2.2.0 code_assets: 1.2.1 ffigen: 21.0.0 timeago: 3.7.1 @@ -85,7 +87,6 @@ dependencies: dev_dependencies: build_runner: 2.16.0 flutter_lints: 6.0.0 - webcrypto: 0.6.0 freezed: 4.0.0 riverpod_lint: 3.1.8 flutter_launcher_icons: 0.14.4 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 5d2b53e..0694802 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -13,7 +13,6 @@ #include #include #include -#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -31,8 +30,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); - WebcryptoPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("WebcryptoPlugin")); WindowManagerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("WindowManagerPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 51af01c..238ef93 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -10,7 +10,6 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_video screen_retriever_windows url_launcher_windows - webcrypto window_manager ) From 3b75b75bf6f1eae95ef88734688927a17d7e30c6 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 26 Aug 2026 00:25:47 -0400 Subject: [PATCH 07/29] send notifications natively --- README.md | 6 ++ android/app/build.gradle.kts | 10 +- ios/Runner/AppDelegate.swift | 3 +- lib/controllers/notifications.dart | 97 +++++++++++++++++++ lib/controllers/settings_sections.dart | 24 ++++- lib/controllers/unified_push.dart | 24 ++++- lib/main.dart | 2 + linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 + pubspec.lock | 70 +++++++++---- pubspec.yaml | 5 +- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 2 + 14 files changed, 225 insertions(+), 30 deletions(-) create mode 100644 lib/controllers/notifications.dart diff --git a/README.md b/README.md index 0c18acf..ee55903 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,12 @@ dart scripts/generate.dart > export CPATH="$(clang -v 2>&1 | grep "Selected GCC installation" | rev | cut -d' ' -f1 | rev)/include" > ``` +Build webcrypto: + +```sh +flutter pub run webcrypto:setup +``` + Build generated files, and watch for new changes: ```sh diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9f9a087..9c0d96c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -20,16 +20,21 @@ android { ndkVersion = flutter.ndkVersion compileOptions { - sourceCompatibility = JavaVersion.VERSION_21 + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } defaultConfig { applicationId = "nexus.federated.nexus" - minSdk = 29 + + minSdk = 29 targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode versionName = flutter.versionName + + multiDexEnabled = true } signingConfigs { @@ -65,6 +70,7 @@ android { dependencies { implementation("org.unifiedpush.android:embedded-fcm-distributor:3.1.0") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") } kotlin { diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c30b367..c73d4ae 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -7,7 +7,8 @@ import UIKit _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - return super.application(application, didFinishLaunchingWithOptions: launchOptions) + UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { diff --git a/lib/controllers/notifications.dart b/lib/controllers/notifications.dart new file mode 100644 index 0000000..faf5e8c --- /dev/null +++ b/lib/controllers/notifications.dart @@ -0,0 +1,97 @@ +import "dart:io"; + +import "package:flutter_local_notifications/flutter_local_notifications.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; + +class NotificationsController + extends AsyncNotifier { + @override + Future build() async { + final notifications = FlutterLocalNotificationsPlugin(); + + final darwin = DarwinInitializationSettings(); + + await notifications.initialize( + settings: .new( + windows: .new( + appName: "Nexus", + appUserModelId: "nexus.federated.nexus", + guid: "dde78daf-130f-4e46-a80a-e31deeab45d7", + ), + android: .new("ic_launcher"), + iOS: darwin, + macOS: darwin, + linux: .new(defaultActionName: "Open"), + ), + onDidReceiveNotificationResponse: (details) { + print(details.data); + }, + ); + + return notifications; + } + + Future requestPermissions() async { + final controller = await future; + + if (Platform.isIOS) { + return await controller + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin + >() + ?.requestPermissions(alert: true, badge: true, sound: true) ?? + true; + } else if (Platform.isMacOS) { + return await controller + .resolvePlatformSpecificImplementation< + MacOSFlutterLocalNotificationsPlugin + >() + ?.requestPermissions(alert: true, badge: true, sound: true) ?? + true; + } else if (Platform.isAndroid) { + return await controller + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.requestNotificationsPermission() ?? + true; + } + + return true; + } + + Future send({ + required int id, + required String title, + String? body, + File? icon, + String? payload, + }) async { + final notificationDetails = NotificationDetails( + android: .new( + "messages", + "Messages", + largeIcon: icon == null ? null : FilePathAndroidBitmap(icon.path), + ), + iOS: .new(), + macOS: .new(), + linux: .new(icon: icon == null ? null : FilePathLinuxIcon(icon.path)), + windows: .new(), + ); + + final controller = await future; + await controller.show( + id: id, + title: title, + body: body, + notificationDetails: notificationDetails, + payload: payload, + ); + } + + static final provider = + AsyncNotifierProvider< + NotificationsController, + FlutterLocalNotificationsPlugin + >(NotificationsController.new); +} diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index 8d67435..35753fe 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -8,6 +8,7 @@ import "package:m3e_buttons/m3e_buttons.dart"; import "package:nexus/controllers/account_data.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/notifications.dart"; import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/unified_push.dart"; import "package:nexus/controllers/spec_versions.dart"; @@ -132,17 +133,30 @@ class SettingsSectionsController orElse: () => false, ) ? pusherRegistered.maybeWhen( - data: (_) => - (value) => value - ? ref + data: (_) => (value) async { + if (value) { + if (await ref + .watch( + NotificationsController + .provider + .notifier, + ) + .requestPermissions()) { + ref .watch( UnifiedPushController .provider .notifier, ) .register() - .onError(showError) - : /*deregeister*/ null, + .onError(showError); + } else { + // TODO: Handle not granted + } + } else { + // TODO: Deregister + } + }, orElse: () => null, ) : null, diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index f72e8ec..c4d32ac 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -3,9 +3,12 @@ import "dart:io"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:intl/intl.dart"; +import "package:nexus/controllers/notifications.dart"; +import "package:nexus/controllers/rooms.dart"; import "package:nexus/main.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; +import "package:nexus/models/content/message.dart"; import "package:nexus/models/requests/register_pusher.dart"; import "package:unifiedpush/unifiedpush.dart"; import "package:unifiedpush_storage_shared_preferences/storage.dart"; @@ -38,10 +41,29 @@ class UnifiedPushController extends AsyncNotifier { ); }, onMessage: (message, instance) async { + if (message.decrypted == false) { + // TODO: Handle more gracefully, show option to re-register pusher + throw Exception("Failed to decrypt notification"); + } final event = await client.handlePush( json.decode(String.fromCharCodes(message.content)), ); - print(event); + + final room = ref.read( + RoomsController.provider.select((rooms) => rooms[event.roomId]), + ); + + await ref + .read(NotificationsController.provider.notifier) + .send( + id: event.eventId.hashCode & 0x7fffffff, + title: room?.metadata?.name ?? "New Message", + body: switch (event.content) { + MessageContent(:final body) => body, + // TODO: Handle more types + _ => null, + }, + ); }, onRegistrationFailed: (error, instance) => throw error, onUnregistered: (instance) => ref.invalidateSelf(), diff --git a/lib/main.dart b/lib/main.dart index 2221151..aaed5a0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ import "package:media_kit/media_kit.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/multi_provider.dart"; +import "package:nexus/controllers/notifications.dart"; import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/shared_prefs.dart"; import "package:nexus/controllers/unified_push.dart"; @@ -146,6 +147,7 @@ class const App(final bool isInBackground, {super.key}) IListConst([ SharedPrefsController.provider, ClientController.provider, + NotificationsController.provider, UnifiedPushController.provider, ]), ), diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 603ea6a..70d9ba2 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -13,6 +13,7 @@ #include #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -37,6 +38,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) webcrypto_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WebcryptoPlugin"); + webcrypto_plugin_register_with_registrar(webcrypto_registrar); g_autoptr(FlPluginRegistrar) window_manager_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); window_manager_plugin_register_with_registrar(window_manager_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 7d8f046..1d53ad7 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_video screen_retriever_linux url_launcher_linux + webcrypto window_manager ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index f18d831..8b6bbb6 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,6 +8,7 @@ import Foundation import app_links import dynamic_color import file_selector_macos +import flutter_local_notifications import media_kit_libs_macos_video import media_kit_video import package_info_plus @@ -15,12 +16,14 @@ import screen_retriever_macos import shared_preferences_foundation import url_launcher_macos import wakelock_plus +import webcrypto import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) @@ -28,5 +31,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) + WebcryptoPlugin.register(with: registry.registrar(forPlugin: "WebcryptoPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 6f957d9..9f951f3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -169,14 +169,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.2" - change_case: - dependency: transitive - description: - name: change_case - sha256: e41ef3df58521194ef8d7649928954805aeb08061917cf658322305e61568003 - url: "https://pub.dev" - source: hosted - version: "2.2.0" characters: dependency: transitive description: @@ -519,6 +511,46 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "1447ba911c60f2ba3f25dae1af151ec187162566b0f57e37771bf0b400f013ad" + url: "https://pub.dev" + source: hosted + version: "22.3.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b" + url: "https://pub.dev" + source: hosted + version: "8.0.1" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "43c3761d916c9bd3d5c7ebbc44d82f4990329840c0c5d62ad5260cc1b5d399bd" + url: "https://pub.dev" + source: hosted + version: "12.2.0" + flutter_local_notifications_web: + dependency: transitive + description: + name: flutter_local_notifications_web + sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "6f43bdd03b171b7a90f22647506fea33e2bb12294b7c7c7a3d690e960a382945" + url: "https://pub.dev" + source: hosted + version: "3.1.1" flutter_localizations: dependency: "direct main" description: flutter @@ -1007,14 +1039,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.19.3" - native_toolchain_cmake: - dependency: transitive - description: - name: native_toolchain_cmake - sha256: de90e8952bad0684bdcff4d674c53d961f5cd1fdcf3a0255a669a35986c4ac1d - url: "https://pub.dev" - source: hosted - version: "0.2.7" navigation_rail_m3e: dependency: "direct main" description: @@ -1517,6 +1541,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.7.1" + timezone: + dependency: transitive + description: + name: timezone + sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" + url: "https://pub.dev" + source: hosted + version: "0.11.1" typed_data: dependency: transitive description: @@ -1750,13 +1782,13 @@ packages: source: hosted version: "3.0.3" webcrypto: - dependency: "direct overridden" + dependency: "direct dev" description: name: webcrypto - sha256: "9885ed99a846191542dd6ca820c83a098117df56be57453832233a3fef68f3f2" + sha256: "6b43001c4110856ff7fa5e5e65e7b2d44bec1d8b54a4d84d5fa2c7622267c5c1" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.6.0" webkit_inspection_protocol: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7f9a9d0..79a3378 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,8 +19,6 @@ environment: sdk: 3.13.0 dependency_overrides: - webcrypto: 0.6.1 - hooks: 2.2.0 dynamic_color: 2.1.0 path_provider_android: 2.2.23 # Pinned to avoid JNI linkify: @@ -83,6 +81,7 @@ dependencies: material_ui: 1.1.0 unifiedpush: 6.2.0 unifiedpush_storage_shared_preferences: 1.0.0 + flutter_local_notifications: ^22.3.0 dev_dependencies: build_runner: 2.16.0 @@ -91,6 +90,8 @@ dev_dependencies: riverpod_lint: 3.1.8 flutter_launcher_icons: 0.14.4 json_serializable: 6.14.1 + # Transitive dep but needed for commands + webcrypto: any flutter_launcher_icons: image_path: assets/icon.png diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 0694802..5d2b53e 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -13,6 +13,7 @@ #include #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -30,6 +31,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); + WebcryptoPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WebcryptoPlugin")); WindowManagerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("WindowManagerPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 238ef93..c74b2f5 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -10,10 +10,12 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_video screen_retriever_windows url_launcher_windows + webcrypto window_manager ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows ) set(PLUGIN_BUNDLED_LIBRARIES) From 2e8d35f81ed22ea7c9d19034334b72810da9e129 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 26 Aug 2026 00:46:57 -0400 Subject: [PATCH 08/29] include avatars on notifs --- lib/controllers/notifications.dart | 13 ++++++++++++- lib/controllers/unified_push.dart | 8 ++++++++ pubspec.lock | 2 +- pubspec.yaml | 3 ++- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/lib/controllers/notifications.dart b/lib/controllers/notifications.dart index faf5e8c..2109cb7 100644 --- a/lib/controllers/notifications.dart +++ b/lib/controllers/notifications.dart @@ -1,6 +1,8 @@ import "dart:io"; import "package:flutter_local_notifications/flutter_local_notifications.dart"; +// ignore: implementation_imports +import "package:flutter_local_notifications_linux/src/model/hint.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; class NotificationsController @@ -75,7 +77,16 @@ class NotificationsController ), iOS: .new(), macOS: .new(), - linux: .new(icon: icon == null ? null : FilePathLinuxIcon(icon.path)), + linux: .new( + customHints: icon == null + ? null + : [ + .new( + name: "image-path", + value: LinuxHintStringValue(icon.path), + ), + ], + ), windows: .new(), ); diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index c4d32ac..0698bda 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -53,11 +53,19 @@ class UnifiedPushController extends AsyncNotifier { RoomsController.provider.select((rooms) => rooms[event.roomId]), ); + final avatar = room?.metadata?.avatar; + final icon = avatar == null + ? null + : await ref + .read(ClientController.provider.notifier) + .downloadMedia(.new(mxc: avatar, isAvatar: true)); + await ref .read(NotificationsController.provider.notifier) .send( id: event.eventId.hashCode & 0x7fffffff, title: room?.metadata?.name ?? "New Message", + icon: icon, body: switch (event.content) { MessageContent(:final body) => body, // TODO: Handle more types diff --git a/pubspec.lock b/pubspec.lock index 9f951f3..5f85f85 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -520,7 +520,7 @@ packages: source: hosted version: "22.3.0" flutter_local_notifications_linux: - dependency: transitive + dependency: "direct main" description: name: flutter_local_notifications_linux sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b" diff --git a/pubspec.yaml b/pubspec.yaml index 79a3378..de72e0e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -81,7 +81,8 @@ dependencies: material_ui: 1.1.0 unifiedpush: 6.2.0 unifiedpush_storage_shared_preferences: 1.0.0 - flutter_local_notifications: ^22.3.0 + flutter_local_notifications: 22.3.0 + flutter_local_notifications_linux: 8.0.1 dev_dependencies: build_runner: 2.16.0 From 9c2e7eb48c3196dca634044c09a479737e96a091 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 26 Aug 2026 11:55:20 -0400 Subject: [PATCH 09/29] smarter notif logic --- lib/controllers/notifications.dart | 13 +++++-- lib/controllers/unified_push.dart | 58 +++++++++++++++++++----------- 2 files changed, 47 insertions(+), 24 deletions(-) diff --git a/lib/controllers/notifications.dart b/lib/controllers/notifications.dart index 2109cb7..d9bcd8f 100644 --- a/lib/controllers/notifications.dart +++ b/lib/controllers/notifications.dart @@ -1,9 +1,11 @@ +import "dart:convert"; import "dart:io"; import "package:flutter_local_notifications/flutter_local_notifications.dart"; // ignore: implementation_imports import "package:flutter_local_notifications_linux/src/model/hint.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/event.dart"; class NotificationsController extends AsyncNotifier { @@ -26,7 +28,11 @@ class NotificationsController linux: .new(defaultActionName: "Open"), ), onDidReceiveNotificationResponse: (details) { - print(details.data); + if (details.payload case final payload?) { + final event = ref.watch( + EventController.provider(.fromJson(json.decode(payload))), + ); + } }, ); @@ -75,8 +81,6 @@ class NotificationsController "Messages", largeIcon: icon == null ? null : FilePathAndroidBitmap(icon.path), ), - iOS: .new(), - macOS: .new(), linux: .new( customHints: icon == null ? null @@ -87,6 +91,9 @@ class NotificationsController ), ], ), + // TODO: See if icons can be added to iOS, macOS, and Windows notifications (#68) + iOS: .new(), + macOS: .new(), windows: .new(), ); diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index 0698bda..1e90114 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -3,15 +3,19 @@ import "dart:io"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:intl/intl.dart"; +import "package:nexus/controllers/key.dart"; import "package:nexus/controllers/notifications.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/main.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/models/content/message.dart"; +import "package:nexus/models/content/sticker.dart"; +import "package:nexus/models/requests/get_event.dart"; import "package:nexus/models/requests/register_pusher.dart"; import "package:unifiedpush/unifiedpush.dart"; import "package:unifiedpush_storage_shared_preferences/storage.dart"; +import "package:window_manager/window_manager.dart"; class UnifiedPushController extends AsyncNotifier { @override @@ -23,32 +27,39 @@ class UnifiedPushController extends AsyncNotifier { storage: UnifiedPushStorageSharedPreferences(), background: isInBackground, ), - onNewEndpoint: (endpoint, instance) async { - await client.registerPusher( - .new( - appDisplayName: "Nexus", - appId: "nexus.federated.nexus", - data: PusherData.webPush( - url: Uri.parse(endpoint.url), - auth: endpoint.pubKeySet!.auth, - ), - deviceDisplayName: - "Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}", - kind: .webPush, - lang: "en", - pushKey: endpoint.pubKeySet!.pubKey, + onNewEndpoint: (endpoint, instance) => client.registerPusher( + .new( + appDisplayName: "Nexus", + appId: "nexus.federated.nexus", + data: PusherData.webPush( + url: Uri.parse(endpoint.url), + auth: endpoint.pubKeySet!.auth, ), - ); - }, + deviceDisplayName: + "Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}", + kind: .webPush, + lang: "en", + pushKey: endpoint.pubKeySet!.pubKey, + ), + ), onMessage: (message, instance) async { if (message.decrypted == false) { - // TODO: Handle more gracefully, show option to re-register pusher - throw Exception("Failed to decrypt notification"); + throw Exception( + "Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.", + ); } final event = await client.handlePush( json.decode(String.fromCharCodes(message.content)), ); + if (event.unreadType?.shouldNotify != true || + (!isInBackground && + await windowManager.isFocused() && + ref.watch(KeyController.provider(KeyController.roomKey)) == + event.roomId)) { + return; + } + final room = ref.read( RoomsController.provider.select((rooms) => rooms[event.roomId]), ); @@ -64,14 +75,19 @@ class UnifiedPushController extends AsyncNotifier { .read(NotificationsController.provider.notifier) .send( id: event.eventId.hashCode & 0x7fffffff, - title: room?.metadata?.name ?? "New Message", + title: room?.metadata?.name ?? "New Event", icon: icon, + payload: json.encode( + GetEventRequest(eventId: event.eventId, roomId: event.roomId), + ), body: switch (event.content) { - MessageContent(:final body) => body, - // TODO: Handle more types + MessageContent(:final body?) || + StickerContent(:final body) => body, _ => null, }, ); + + if (isInBackground) exit(0); }, onRegistrationFailed: (error, instance) => throw error, onUnregistered: (instance) => ref.invalidateSelf(), From 94a455188286d2a3a25f94abdbfe94074c493ab3 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 26 Aug 2026 16:27:43 -0400 Subject: [PATCH 10/29] add wip deregistering --- lib/controllers/client.dart | 17 ++++-- lib/controllers/notifications.dart | 19 ++++--- lib/controllers/settings_sections.dart | 60 ++++++++++++++-------- lib/controllers/unified_push.dart | 28 +++++++--- lib/models/requests/deregister_pusher.dart | 13 +++++ lib/pages/notifications.dart | 14 +++++ lib/widgets/appbar.dart | 2 +- 7 files changed, 113 insertions(+), 40 deletions(-) create mode 100644 lib/models/requests/deregister_pusher.dart create mode 100644 lib/pages/notifications.dart diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 2347431..10a1017 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -22,6 +22,7 @@ import "package:nexus/models/event.dart"; import "package:nexus/models/oauth_auth_code_response.dart"; import "package:nexus/models/open_graph_data.dart"; import "package:nexus/models/paginate.dart"; +import "package:nexus/models/requests/deregister_pusher.dart"; import "package:nexus/models/requests/download_media.dart"; import "package:nexus/models/requests/get_event.dart"; import "package:nexus/models/requests/get_related_events.dart"; @@ -178,12 +179,14 @@ class ClientController extends AsyncNotifier { return json; } - Future handlePush(Map data) async => .fromJson( - await callGomuksMethod( + Future handlePush(Map data) async { + final response = await callGomuksMethod( data, (handle, data) async => GomuksHandlePush(handle, data), - ), - ); + ); + + return response == null ? null : .fromJson(response); + } Future _sendCommand( String command, [ @@ -298,6 +301,12 @@ class ClientController extends AsyncNotifier { Future registerPusher(RegisterPusherRequest request) => _sendCommand("register_homeserver_push", request.toJson()); + Future deregisterPusher(DeregisterPusherRequest request) => + _sendCommand("register_homeserver_push", { + ...request.toJson(), + "kind": null, + }); + Future uploadMedia(UploadMediaRequest request) async => .fromJson(await _sendCommand("upload_media", request.toJson())); diff --git a/lib/controllers/notifications.dart b/lib/controllers/notifications.dart index d9bcd8f..bd80467 100644 --- a/lib/controllers/notifications.dart +++ b/lib/controllers/notifications.dart @@ -1,11 +1,12 @@ -import "dart:convert"; import "dart:io"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/main.dart"; import "package:flutter_local_notifications/flutter_local_notifications.dart"; // ignore: implementation_imports import "package:flutter_local_notifications_linux/src/model/hint.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/event.dart"; +import "package:nexus/pages/notifications.dart"; class NotificationsController extends AsyncNotifier { @@ -22,16 +23,20 @@ class NotificationsController appUserModelId: "nexus.federated.nexus", guid: "dde78daf-130f-4e46-a80a-e31deeab45d7", ), - android: .new("ic_launcher"), + android: .new("ic_launcher_foreground"), iOS: darwin, macOS: darwin, linux: .new(defaultActionName: "Open"), ), onDidReceiveNotificationResponse: (details) { - if (details.payload case final payload?) { - final event = ref.watch( - EventController.provider(.fromJson(json.decode(payload))), - ); + if (details.payload case final eventId?) { + if (navigatorKey.currentContext case final context?) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => NotificationsPage(eventId: eventId), + ), + ); + } } }, ); diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index 35753fe..1e60263 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -1,8 +1,9 @@ import "dart:io"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:material_ui/material_ui.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:intl/intl.dart"; import "package:m3e_buttons/m3e_buttons.dart"; import "package:nexus/controllers/account_data.dart"; @@ -105,14 +106,16 @@ class SettingsSectionsController .new( title: "Push notifications", description: "Enable push notifications using Web Push", - builder: (title, description, icon) => Consumer( + builder: (title, description, icon) => HookConsumer( builder: (context, ref, _) { + final loading = useState(false); final pusherRegistered = ref.watch( UnifiedPushController.provider, ); final unifiedPushAllowed = ref.watch( UnifiedPushAllowedController.provider, ); + return SwitchListTile( title: Text(title), subtitle: Text( @@ -127,34 +130,47 @@ class SettingsSectionsController data: (value) => value, orElse: () => false, ), - onChanged: - unifiedPushAllowed.maybeWhen( - data: (data) => data == null, - orElse: () => false, - ) + onChanged: loading.value + ? null + : unifiedPushAllowed.maybeWhen( + data: (data) => data == null, + orElse: () => false, + ) ? pusherRegistered.maybeWhen( data: (_) => (value) async { - if (value) { - if (await ref - .watch( - NotificationsController - .provider - .notifier, - ) - .requestPermissions()) { - ref + try { + loading.value = true; + if (value) { + if (await ref + .watch( + NotificationsController + .provider + .notifier, + ) + .requestPermissions()) { + await ref + .watch( + UnifiedPushController + .provider + .notifier, + ) + .register(); + } else { + // TODO: Handle not granted + } + } else { + await ref .watch( UnifiedPushController .provider .notifier, ) - .register() - .onError(showError); - } else { - // TODO: Handle not granted + .deregister(); } - } else { - // TODO: Deregister + } catch (error, stackTrace) { + showError(error, stackTrace); + } finally { + loading.value = false; } }, orElse: () => null, diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index 1e90114..f9d8f8f 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -11,7 +11,6 @@ import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/sticker.dart"; -import "package:nexus/models/requests/get_event.dart"; import "package:nexus/models/requests/register_pusher.dart"; import "package:unifiedpush/unifiedpush.dart"; import "package:unifiedpush_storage_shared_preferences/storage.dart"; @@ -52,9 +51,10 @@ class UnifiedPushController extends AsyncNotifier { json.decode(String.fromCharCodes(message.content)), ); - if (event.unreadType?.shouldNotify != true || + if (event == null || + event.unreadType?.shouldNotify != true || (!isInBackground && - await windowManager.isFocused() && + await windowManager.isFocused().onError((_, _) => false) && ref.watch(KeyController.provider(KeyController.roomKey)) == event.roomId)) { return; @@ -77,9 +77,7 @@ class UnifiedPushController extends AsyncNotifier { id: event.eventId.hashCode & 0x7fffffff, title: room?.metadata?.name ?? "New Event", icon: icon, - payload: json.encode( - GetEventRequest(eventId: event.eventId, roomId: event.roomId), - ), + payload: event.eventId, body: switch (event.content) { MessageContent(:final body?) || StickerContent(:final body) => body, @@ -128,6 +126,24 @@ class UnifiedPushController extends AsyncNotifier { if (!alreadyRegistered) ref.invalidateSelf(); } + Future deregister() async { + final clientState = ref.watch(ClientStateController.provider); + final key = await UnifiedPushStorageSharedPreferences().keys.get( + clientState!.deviceId!, + ); + + if (key == null) return; + + await ref + .watch(ClientController.provider.notifier) + .deregisterPusher( + .new(appId: "nexus.federated.nexus", pushKey: key.split("=").first), + ); + + await UnifiedPush.unregister(clientState.deviceId!); + ref.invalidateSelf(); + } + static final provider = AsyncNotifierProvider( UnifiedPushController.new, ); diff --git a/lib/models/requests/deregister_pusher.dart b/lib/models/requests/deregister_pusher.dart new file mode 100644 index 0000000..1416add --- /dev/null +++ b/lib/models/requests/deregister_pusher.dart @@ -0,0 +1,13 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "deregister_pusher.freezed.dart"; +part "deregister_pusher.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const DeregisterPusherRequest({ + required final String appId, + @JsonKey(name: "pushkey") required final String pushKey, +}) with _$DeregisterPusherRequest { + Map toJson() => _$DeregisterPusherRequestToJson(this); +} diff --git a/lib/pages/notifications.dart b/lib/pages/notifications.dart new file mode 100644 index 0000000..ceca514 --- /dev/null +++ b/lib/pages/notifications.dart @@ -0,0 +1,14 @@ +import "package:material_ui/material_ui.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/widgets/appbar.dart"; + +class const NotificationsPage({final String? eventId, super.key}) + extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: Appbar(title: Text("Notifications")), + body: Placeholder(), + ); + } +} diff --git a/lib/widgets/appbar.dart b/lib/widgets/appbar.dart index a6058ea..33727ac 100644 --- a/lib/widgets/appbar.dart +++ b/lib/widgets/appbar.dart @@ -39,7 +39,7 @@ final class const Appbar({ : (_) => windowManager.startDragging(), ), child: AppBar( - leading: InkWell(onTap: onTap, child: leading), + leading: leading == null ? null : InkWell(onTap: onTap, child: leading), backgroundColor: backgroundColor, scrolledUnderElevation: scrolledUnderElevation, actionsPadding: const .symmetric(horizontal: 8), From 52544d88144915d1326b0d83e85ec546dc6a1ceb Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 10 Sep 2026 14:14:10 -0400 Subject: [PATCH 11/29] more reliable UP deregistration with proper cross-platform support --- lib/controllers/key.dart | 1 + lib/controllers/push_key.dart | 30 +++++++++++ lib/controllers/unified_push.dart | 62 ++++++++++++++--------- lib/controllers/unified_push_allowed.dart | 2 +- 4 files changed, 69 insertions(+), 26 deletions(-) create mode 100644 lib/controllers/push_key.dart diff --git a/lib/controllers/key.dart b/lib/controllers/key.dart index b78acf8..0d02d17 100644 --- a/lib/controllers/key.dart +++ b/lib/controllers/key.dart @@ -4,6 +4,7 @@ import "package:nexus/controllers/shared_prefs.dart"; class KeyController(final String key) extends Notifier { static const String spaceKey = "space"; static const String roomKey = "room"; + static const String pushKeyKey = "pushKey"; @override String? build() => diff --git a/lib/controllers/push_key.dart b/lib/controllers/push_key.dart new file mode 100644 index 0000000..b73aa89 --- /dev/null +++ b/lib/controllers/push_key.dart @@ -0,0 +1,30 @@ +import "dart:convert"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/key.dart"; + +class PushKeyController(final String instance) extends Notifier { + @override + String? build() => json.decode( + ref.watch(KeyController.provider(KeyController.pushKeyKey)) ?? "{}", + )[instance]; + + Future set(String? value) async { + final provider = KeyController.provider(KeyController.pushKeyKey); + final notifier = ref.watch(provider.notifier); + final prefs = IMap(json.decode(ref.watch(provider) ?? "{}")); + + state = value; + notifier.set( + json.encode( + prefs.add(instance, value).where((_, value) => value != null), + ), + ); + } + + static final provider = + NotifierProvider.family( + PushKeyController.new, + ); +} diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index f9d8f8f..cb42577 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -1,10 +1,12 @@ import "dart:convert"; import "dart:io"; +import "package:flutter/foundation.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:intl/intl.dart"; import "package:nexus/controllers/key.dart"; import "package:nexus/controllers/notifications.dart"; +import "package:nexus/controllers/push_key.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/main.dart"; import "package:nexus/controllers/client.dart"; @@ -26,21 +28,26 @@ class UnifiedPushController extends AsyncNotifier { storage: UnifiedPushStorageSharedPreferences(), background: isInBackground, ), - onNewEndpoint: (endpoint, instance) => client.registerPusher( - .new( - appDisplayName: "Nexus", - appId: "nexus.federated.nexus", - data: PusherData.webPush( - url: Uri.parse(endpoint.url), - auth: endpoint.pubKeySet!.auth, + onNewEndpoint: (endpoint, instance) async { + final pushKey = endpoint.pubKeySet!.pubKey; + ref.watch(PushKeyController.provider(instance).notifier).set(pushKey); + + await client.registerPusher( + .new( + appDisplayName: "Nexus", + appId: "nexus.federated.nexus", + data: PusherData.webPush( + url: Uri.parse(endpoint.url), + auth: endpoint.pubKeySet!.auth, + ), + deviceDisplayName: + "Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}", + kind: .webPush, + lang: "en", + pushKey: pushKey, ), - deviceDisplayName: - "Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}", - kind: .webPush, - lang: "en", - pushKey: endpoint.pubKeySet!.pubKey, - ), - ), + ); + }, onMessage: (message, instance) async { if (message.decrypted == false) { throw Exception( @@ -88,7 +95,12 @@ class UnifiedPushController extends AsyncNotifier { if (isInBackground) exit(0); }, onRegistrationFailed: (error, instance) => throw error, - onUnregistered: (instance) => ref.invalidateSelf(), + onUnregistered: (instance) async { + await ref + .watch(PushKeyController.provider(instance).notifier) + .set(null); + ref.invalidateSelf(); + }, ); if (registered) { @@ -128,17 +140,17 @@ class UnifiedPushController extends AsyncNotifier { Future deregister() async { final clientState = ref.watch(ClientStateController.provider); - final key = await UnifiedPushStorageSharedPreferences().keys.get( - clientState!.deviceId!, - ); + final key = ref.watch(PushKeyController.provider(clientState!.deviceId!)); - if (key == null) return; - - await ref - .watch(ClientController.provider.notifier) - .deregisterPusher( - .new(appId: "nexus.federated.nexus", pushKey: key.split("=").first), - ); + if (key != null) { + await ref + .watch(ClientController.provider.notifier) + .deregisterPusher(.new(appId: "nexus.federated.nexus", pushKey: key)); + } else { + debugPrint( + "No matching pushKey found. Skipping deregistration from homeserver.", + ); + } await UnifiedPush.unregister(clientState.deviceId!); ref.invalidateSelf(); diff --git a/lib/controllers/unified_push_allowed.dart b/lib/controllers/unified_push_allowed.dart index 4f925cf..af50d62 100644 --- a/lib/controllers/unified_push_allowed.dart +++ b/lib/controllers/unified_push_allowed.dart @@ -11,7 +11,7 @@ class UnifiedPushAllowedController extends AsyncNotifier { return "No valid distributors found. Try installing ${Platform.isLinux ? "KUnifiedPush" : Platform.isAndroid - ? "FCM or NTFY" + ? "Google Play Services or NTFY" : "NTFY"}."; } From 783451359efd3c4c3208577c5812a8e3e348a328 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 10 Sep 2026 15:07:17 -0400 Subject: [PATCH 12/29] bug fixes --- lib/controllers/push_key.dart | 2 +- lib/controllers/unified_push.dart | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/controllers/push_key.dart b/lib/controllers/push_key.dart index b73aa89..7217886 100644 --- a/lib/controllers/push_key.dart +++ b/lib/controllers/push_key.dart @@ -18,7 +18,7 @@ class PushKeyController(final String instance) extends Notifier { state = value; notifier.set( json.encode( - prefs.add(instance, value).where((_, value) => value != null), + prefs.add(instance, value).where((_, value) => value != null).unlock, ), ); } diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index cb42577..1c1b41a 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -30,7 +30,9 @@ class UnifiedPushController extends AsyncNotifier { ), onNewEndpoint: (endpoint, instance) async { final pushKey = endpoint.pubKeySet!.pubKey; - ref.watch(PushKeyController.provider(instance).notifier).set(pushKey); + await ref + .watch(PushKeyController.provider(instance).notifier) + .set(pushKey); await client.registerPusher( .new( From 1efb68ebe028e2863c7aff54af45e6999392f7ad Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 10 Sep 2026 15:09:47 -0400 Subject: [PATCH 13/29] bump deps, remove unwanted and now un-needed analyzer rule Fixes #69 --- analysis_options.yaml | 1 - pubspec.lock | 328 ++++++++++++++++-------------------------- pubspec.yaml | 22 +-- 3 files changed, 135 insertions(+), 216 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index 17adc10..00df0c4 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -2,7 +2,6 @@ analyzer: errors: invalid_annotation_target: ignore avoid_print: ignore - annotate_overrides: ignore exclude: - "build/**" - "**/*.g.dart" diff --git a/pubspec.lock b/pubspec.lock index d45c4d4..753373a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,42 +5,42 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "1b0e6a07425a3e460666e88bf1c949ccc7bb0116ad562ce94a1eca60fe820725" + sha256: fe18c7e37d5acb3b43224fa255281dec007970d6fd01f6d76fcd88ed3777a117 url: "https://pub.dev" source: hosted - version: "103.0.0" + version: "107.0.0" analysis_server_plugin: dependency: transitive description: name: analysis_server_plugin - sha256: c58b2a05b260a023eefd0848496ff6b113a318c6c95dad4215165a4bd54ae600 + sha256: fc372bb4778b901afe4dea2e7f71ec74e6cbe772999d9961a4d8e382bc4fa49c url: "https://pub.dev" source: hosted - version: "0.3.18" + version: "0.3.22" analyzer: dependency: transitive description: name: analyzer - sha256: "61c04d0c1bfed555c681ea079519933f071a5a026578ff73c4ff0df2d3462e5e" + sha256: f2dde9e50c23fc95b846b4ef334e98c3ae8c0effd5d2453b02fc9fb3f8939791 url: "https://pub.dev" source: hosted - version: "13.3.0" + version: "14.3.0" analyzer_buffer: dependency: transitive description: name: analyzer_buffer - sha256: "445b77e2054fa3e8c8a8ef1b5e9e6b23bb8028fffd34b5e60eaef315b7750674" + sha256: f890a2e708e38fbf4238ad2f9fd3d506538acdddb0cd8eb2c3e8c7bd17784b57 url: "https://pub.dev" source: hosted - version: "0.3.3" + version: "0.3.4" analyzer_plugin: dependency: transitive description: name: analyzer_plugin - sha256: c2a75baf1657171cc7872db96a12b07e385873cf7a73f2b4f7638e9d0701ee53 + sha256: "521fbb02dada7d99d0fc6d10e4b501a07ae1d39ff2a850afbd0ec2279fb81f5c" url: "https://pub.dev" source: hosted - version: "0.14.12" + version: "0.14.16" app_links: dependency: "direct main" description: @@ -77,10 +77,10 @@ packages: dependency: transitive description: name: archive - sha256: be169cf6ac481e052c4538715d88841d567150dfe1df38aaec76461a4e7b39f2 + sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "4.2.0" args: dependency: transitive description: @@ -117,34 +117,34 @@ packages: dependency: transitive description: name: build - sha256: b94f5da9ed3d081fd4ecc426c260998b5f4f4eb2f6d89b7e5472edef0b2b2a1b + sha256: "8a5c5761af8e31748bba3c82f68925ace40f3225c3eea25be9beb57eca7cd7a8" url: "https://pub.dev" source: hosted - version: "4.0.10" + version: "4.0.11" build_config: dependency: transitive description: name: build_config - sha256: "94eaf6708fe64408c632ef2689ca3777b112f9421306ccf4f8c84d7c5c9f83f8" + sha256: d466ed2dc9c6cd1d169948879b84ee061eb5e22c64a7c6089879c6296d272a8d url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.3.3" build_daemon: dependency: transitive description: name: build_daemon - sha256: "79e05eaf15a48d7230b053a4363b8eaac0cc234bbd0134c3229455481f55cbc6" + sha256: e1d40ef3f7934986d5da2271b1ba07794921ce263e44d622fb6c406d76589e33 url: "https://pub.dev" source: hosted - version: "4.1.5" + version: "4.1.6" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "90363d438bd9f84a4d5bbb83352251f57d2d9d771bc95a44e6a33fe25fa52774" + sha256: "894c243f6bc32015fec466ce30a6925bd537a77a426ee5bf481120477eb3de67" url: "https://pub.dev" source: hosted - version: "2.16.0" + version: "2.16.1" built_collection: dependency: transitive description: @@ -157,10 +157,10 @@ packages: dependency: transitive description: name: built_value - sha256: "31b24be6615ec7fcf70b3aa5a7469fe35826485e639a16dd7eb83ba30e4cc6a8" + sha256: f87ea98192116f7093cb214551ce1929caae0681fdba282b3d8b4462adee7bb7 url: "https://pub.dev" source: hosted - version: "8.12.7" + version: "8.13.0" button_m3e: dependency: transitive description: @@ -185,14 +185,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" - cli_config: - dependency: transitive - description: - name: cli_config - sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec - url: "https://pub.dev" - source: hosted - version: "0.2.0" cli_util: dependency: transitive description: @@ -205,18 +197,18 @@ packages: dependency: transitive description: name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.3" code_assets: dependency: "direct main" description: name: code_assets - sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + sha256: cfd4f5f575a49c5f10ca856e9846073f1e6c3ee94912377eea5f6cefc5272941 url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "2.0.0" collection: dependency: "direct main" description: @@ -241,22 +233,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" - coverage: - dependency: transitive - description: - name: coverage - sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" - url: "https://pub.dev" - source: hosted - version: "1.15.1" cross_file: dependency: transitive description: name: cross_file - sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 url: "https://pub.dev" source: hosted - version: "0.3.5+4" + version: "0.3.5+5" crypto: dependency: transitive description: @@ -277,18 +261,18 @@ packages: dependency: transitive description: name: cupertino_ui - sha256: "7ed8ce4159d342eec4c65f4ea6eec57adaf9365404378541f38efc1da20a5b3d" + sha256: e9dfe7fac704028f8928cbe4028a0be5e8a709498e8daf8247de99e99a32aef3 url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.2" dart_style: dependency: transitive description: name: dart_style - sha256: "3f88fc9c96c568d631356507355a2d1e983ee000c9ac008bdcb39b5bf53ce777" + sha256: "82ade9fc4273f29ed673e33166944465225b4f7fc5d4aaef48605cc751c18fc1" url: "https://pub.dev" source: hosted - version: "3.1.12" + version: "3.1.13" dbus: dependency: transitive description: @@ -382,10 +366,10 @@ packages: dependency: "direct main" description: name: ffigen - sha256: "56fc0be88937409839a59c133dfdee4165e80da324c9e39f59013060249ffb44" + sha256: "31b2ca630cede89babbbf31688d20b735967c51bf60572b06ec16718e5a7f1ec" url: "https://pub.dev" source: hosted - version: "21.0.0" + version: "22.0.0" file: dependency: transitive description: @@ -406,34 +390,34 @@ packages: dependency: transitive description: name: file_selector_android - sha256: "1d45e9910f68c16eb0c74f0b10097ad81aed516ea28054c027137e8f7d75e840" + sha256: "7c76473740e33a11343c8fce88166049230850d2f19cd8a652ea935fcb8c9206" url: "https://pub.dev" source: hosted - version: "0.5.2+9" + version: "0.5.2+10" file_selector_ios: dependency: transitive description: name: file_selector_ios - sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca + sha256: "97269e5307a0ab813b1fa2430bada0a96e0afb74848417f8676f64ba5de0051c" url: "https://pub.dev" source: hosted - version: "0.5.3+5" + version: "0.5.3+6" file_selector_linux: dependency: transitive description: name: file_selector_linux - sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab url: "https://pub.dev" source: hosted - version: "0.9.4" + version: "0.9.4+1" file_selector_macos: dependency: transitive description: name: file_selector_macos - sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 url: "https://pub.dev" source: hosted - version: "0.9.5" + version: "0.9.5+1" file_selector_platform_interface: dependency: transitive description: @@ -454,10 +438,10 @@ packages: dependency: transitive description: name: file_selector_windows - sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec url: "https://pub.dev" source: hosted - version: "0.9.3+5" + version: "0.9.3+6" fixnum: dependency: transitive description: @@ -528,10 +512,10 @@ packages: dependency: "direct main" description: name: flutter_riverpod - sha256: "56e81e662e6e54e59b231da57e90ac0d06ac67d8e3f2273c129a37ae6282b40c" + sha256: "2b7f9d2a3c730ac1a98221b420cef966b990fca7ab546ede324642ca57a533e3" url: "https://pub.dev" source: hosted - version: "3.4.2" + version: "3.4.3" flutter_svg: dependency: "direct main" description: @@ -554,10 +538,10 @@ packages: dependency: "direct main" description: name: flutter_widget_from_html_core - sha256: "7ff010b116f6abc16429923e616fbc727f3f65ef4cee12ffdb280aeecbc21e7f" + sha256: "530f2e3cc57be1a00f8bfae1b0000064e8a6df0cf1fb4d48a80f85afde9b39b7" url: "https://pub.dev" source: hosted - version: "0.17.2" + version: "0.17.4" fluttertagger: dependency: "direct main" description: @@ -570,10 +554,10 @@ packages: dependency: "direct dev" description: name: freezed - sha256: "27585a6c2b9ac28ffb7b45d87fd1502332af1eb90e1c9f5eb1acb24c71d26e17" + sha256: "541a82723c84298f3ca46a6c2186935aa21ad529ad1f1423d67af18040494f51" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.1" freezed_annotation: dependency: "direct main" description: @@ -582,14 +566,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 - url: "https://pub.dev" - source: hosted - version: "4.0.0" get_x_storage: dependency: transitive description: @@ -602,10 +578,10 @@ packages: dependency: transitive description: name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.2.0" graphs: dependency: transitive description: @@ -634,18 +610,18 @@ packages: dependency: "direct main" description: name: hooks_riverpod - sha256: fce693abc6289c8bb42348eae520b979f7edbaae0c1b77b946ef058d0693f6f6 + sha256: d58e1e14aa112ca9c56d54effb7d0d717b2d3f96dad3cdd1fce16f8740450700 url: "https://pub.dev" source: hosted - version: "3.4.2" + version: "3.4.3" html: dependency: transitive description: name: html - sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699" url: "https://pub.dev" source: hosted - version: "0.15.6" + version: "0.15.7" http: dependency: "direct main" description: @@ -698,10 +674,10 @@ packages: dependency: transitive description: name: image_picker_android - sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a" + sha256: "1c0c38790306fda4ed774095620444333e56a2b6bc8fc98f3a35c9398781cf54" url: "https://pub.dev" source: hosted - version: "0.8.13+19" + version: "0.8.13+22" image_picker_for_web: dependency: transitive description: @@ -714,10 +690,10 @@ packages: dependency: transitive description: name: image_picker_ios - sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + sha256: ee3885b6fcd71958fbc79770dd194c63371439d536d69c47b279171a486482ae url: "https://pub.dev" source: hosted - version: "0.8.13+6" + version: "0.8.13+7" image_picker_linux: dependency: transitive description: @@ -762,10 +738,10 @@ packages: dependency: transitive description: name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "1.1.0" js: dependency: transitive description: @@ -851,10 +827,10 @@ packages: dependency: "direct main" description: name: m3e_buttons - sha256: "001031f1b72ef19643a8a6a531aafcfb31a06b53456b6f60014403c125e67056" + sha256: "6bf60389eb7c838ac0402dba3657738ae1b015e312bc955ad8330ac17695a516" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.2" m3e_card_list: dependency: "direct main" description: @@ -891,10 +867,10 @@ packages: dependency: "direct main" description: name: material_ui - sha256: "4f3f38b9953df0a87d6bf5f21880029f77c47048487d5339410c39936be4683b" + sha256: fbfb53cab6c4629438feeade5f3d305f72944bc9d9f25786b19106d32b80ec45 url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.2.0" measure_size: dependency: "direct main" description: @@ -979,10 +955,10 @@ packages: dependency: transitive description: name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" motor: dependency: transitive description: @@ -995,10 +971,10 @@ packages: dependency: transitive description: name: native_toolchain_c - sha256: a1c26117c48cebe5677b0cf0e33a980a79a7c5577effc86f52e5a0d309cdcb60 + sha256: "9d233b6f2d9c52e1a2b5fbe70451d2c10ac674d3bb419d0ec8de14989d437c26" url: "https://pub.dev" source: hosted - version: "0.19.3" + version: "0.19.4" navigation_rail_m3e: dependency: "direct main" description: @@ -1008,30 +984,22 @@ packages: url: "https://github.com/Henry-Hiles/material_3_expressive" source: git version: "0.3.5" - node_preamble: - dependency: transitive - description: - name: node_preamble - sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" - url: "https://pub.dev" - source: hosted - version: "2.0.2" objective_c: dependency: transitive description: name: objective_c - sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + sha256: ad56fd53a78ff6b1472fa59ff2a4e8b8ccabafc586fc263a1dfad0b99b5553e3 url: "https://pub.dev" source: hosted - version: "9.5.0" + version: "9.6.0" package_config: dependency: transitive description: name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "3.0.0" package_info_plus: dependency: "direct main" description: @@ -1124,10 +1092,10 @@ packages: dependency: transitive description: name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec url: "https://pub.dev" source: hosted - version: "3.1.6" + version: "3.2.0" plugin_platform_interface: dependency: transitive description: @@ -1148,10 +1116,10 @@ packages: dependency: transitive description: name: pool - sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" url: "https://pub.dev" source: hosted - version: "1.5.2" + version: "1.5.3" posix: dependency: transitive description: @@ -1160,22 +1128,30 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.2" + process: + dependency: transitive + description: + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" + url: "https://pub.dev" + source: hosted + version: "5.0.6" pub_semver: dependency: transitive description: name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.1" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + sha256: c38b81cbf34450b67e0265d73433569d12e34782e30ed769c9cc99c9d5f2e796 url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.6.0" quiver: dependency: transitive description: @@ -1196,26 +1172,26 @@ packages: dependency: transitive description: name: riverpod - sha256: "84351b3472a447f7b89f961f95c73287009d58c59d8cc1267425d995f78e50f1" + sha256: "484dfc873ea4c4f4240e5635444fa066f87b07862b7279ebd004a2b71fba4b7a" url: "https://pub.dev" source: hosted - version: "3.4.2" + version: "3.4.3" riverpod_analyzer_utils: dependency: transitive description: name: riverpod_analyzer_utils - sha256: "46a8dd0461080fde8f886f65f87580758dae8495e352f9ea6a09953abc3df7bd" + sha256: bb13cb3985815840cc103eda1027158358c5fdc00f308fc6f76574d0594107af url: "https://pub.dev" source: hosted - version: "1.0.0-dev.11" + version: "1.0.0-dev.12" riverpod_lint: dependency: "direct dev" description: name: riverpod_lint - sha256: "7d37414a82b0f52f3d8e440320830d9a8c59620663884b2bb5baaca26296c995" + sha256: "215cb3cd54a97e09bc3512535fa4e8bedd3bf2a06dc2ae015ac8e0050fc71bd1" url: "https://pub.dev" source: hosted - version: "3.1.8" + version: "3.1.9" rxdart: dependency: transitive description: @@ -1284,18 +1260,18 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" url: "https://pub.dev" source: hosted - version: "2.4.27" + version: "2.4.28" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" url: "https://pub.dev" source: hosted - version: "2.5.6" + version: "2.5.7" shared_preferences_linux: dependency: transitive description: @@ -1336,22 +1312,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.2" - shelf_packages_handler: - dependency: transitive - description: - name: shelf_packages_handler - sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - shelf_static: - dependency: transitive - description: - name: shelf_static - sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 - url: "https://pub.dev" - source: hosted - version: "1.1.3" shelf_web_socket: dependency: transitive description: @@ -1369,10 +1329,10 @@ packages: dependency: transitive description: name: source_gen - sha256: a603f1fb984a7391ae5978d1b92bfaaa08b350dca5c825256f925818f7943bf5 + sha256: "11b6047da8e4eb6c643ccac3d0fda3f9c9e11651695f7da24a85ade8aff15a44" url: "https://pub.dev" source: hosted - version: "4.2.4" + version: "4.3.0" source_helper: dependency: transitive description: @@ -1381,22 +1341,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.13" - source_map_stack_trace: - dependency: transitive - description: - name: source_map_stack_trace - sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b - url: "https://pub.dev" - source: hosted - version: "2.1.2" - source_maps: - dependency: transitive - description: - name: source_maps - sha256: "14c2945847669b44089bb1222f66873d7ff7103c58911917f2a63c5a62327898" - url: "https://pub.dev" - source: hosted - version: "0.10.14" source_span: dependency: transitive description: @@ -1409,10 +1353,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" url: "https://pub.dev" source: hosted - version: "1.12.1" + version: "1.12.2" state_notifier: dependency: transitive description: @@ -1433,10 +1377,10 @@ packages: dependency: transitive description: name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" string_scanner: dependency: transitive description: @@ -1457,10 +1401,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "3a7b5d17422dd0f8d5c6c14feaa5a1c65638b9455f871a96f08437562c046931" + sha256: "397b146f97613b83d84bdccb439bc8d0f3aebb6c1d14a9641e6f24201c490b2d" url: "https://pub.dev" source: hosted - version: "3.4.1+2" + version: "3.4.2" term_glyph: dependency: transitive description: @@ -1469,14 +1413,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" - test: - dependency: transitive - description: - name: test - sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f - url: "https://pub.dev" - source: hosted - version: "1.31.1" test_api: dependency: transitive description: @@ -1485,14 +1421,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.12" - test_core: - dependency: transitive - description: - name: test_core - sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 - url: "https://pub.dev" - source: hosted - version: "0.6.18" timeago: dependency: "direct main" description: @@ -1537,34 +1465,34 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610" url: "https://pub.dev" source: hosted - version: "6.3.32" + version: "6.3.33" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" url: "https://pub.dev" source: hosted - version: "6.4.1" + version: "6.4.2" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.3" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" url: "https://pub.dev" source: hosted - version: "3.2.5" + version: "3.2.6" url_launcher_platform_interface: dependency: transitive description: @@ -1585,10 +1513,10 @@ packages: dependency: transitive description: name: url_launcher_windows - sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" url: "https://pub.dev" source: hosted - version: "3.1.5" + version: "3.1.6" uuid: dependency: transitive description: @@ -1641,18 +1569,18 @@ packages: dependency: transitive description: name: wakelock_plus - sha256: "7253bca0fcf40d8413ddfcf4d2a1fa0a82475e79be25a4f2c564b695c9351486" + sha256: "22b3e7e937721de70e63c85e7139f4ac781dc22863b9262431a53ac030eb074b" url: "https://pub.dev" source: hosted - version: "1.7.0" + version: "1.8.0" wakelock_plus_platform_interface: dependency: transitive description: name: wakelock_plus_platform_interface - sha256: "0618d1799f0b28bcf98255b4ee8313e6fc4d38589dc4ee5fe5840d57d1aff6da" + sha256: "764c25504562abc8ac3406f5d175f126b276e6ba916c7c2675690b73368ca384" url: "https://pub.dev" source: hosted - version: "1.6.0" + version: "1.7.0" watcher: dependency: transitive description: @@ -1685,14 +1613,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" - webkit_inspection_protocol: - dependency: transitive - description: - name: webkit_inspection_protocol - sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" - url: "https://pub.dev" - source: hosted - version: "1.2.1" win32: dependency: transitive description: @@ -1729,10 +1649,10 @@ packages: dependency: transitive description: name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.4" yaml_edit: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a9b2e01..4b69b2c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,8 +31,8 @@ dependencies: sdk: flutter flutter_localizations: sdk: flutter - flutter_riverpod: 3.4.2 - hooks_riverpod: 3.4.2 + flutter_riverpod: 3.4.3 + hooks_riverpod: 3.4.3 intl: 0.20.3 fast_immutable_collections: 11.2.0 path_provider: 2.1.6 @@ -44,7 +44,7 @@ dependencies: collection: 1.19.1 window_manager: 0.5.2 color_hash: 1.0.1 - flutter_widget_from_html_core: 0.17.2 + flutter_widget_from_html_core: 0.17.4 flutter_svg: 2.3.0 json_annotation: 4.12.0 shared_preferences: 2.5.5 @@ -52,9 +52,9 @@ dependencies: dynamic_polls: 0.0.8 flutter_hooks: 0.21.3+1 ffi: 2.2.0 - hooks: ^2.2.0 - code_assets: 1.2.1 - ffigen: 21.0.0 + hooks: 2.2.0 + code_assets: 2.0.0 + ffigen: 22.0.0 timeago: 3.7.1 http: 1.6.0 flutter_linkify: 6.0.0 @@ -68,7 +68,7 @@ dependencies: media_kit_video: 2.0.1 media_kit_libs_video: 1.0.7 measure_size: 5.0.2 - m3e_buttons: 1.0.0 + m3e_buttons: 1.0.2 navigation_rail_m3e: git: url: https://github.com/Henry-Hiles/material_3_expressive @@ -78,13 +78,13 @@ dependencies: package_info_plus: 10.2.1 app_links: 7.2.1 file_selector: 1.1.0 - material_ui: 1.0.1 + material_ui: 1.2.0 dev_dependencies: - build_runner: 2.16.0 + build_runner: 2.16.1 flutter_lints: 6.0.0 - freezed: 4.0.0 - riverpod_lint: 3.1.8 + freezed: 4.0.1 + riverpod_lint: 3.1.9 flutter_launcher_icons: 0.14.4 json_serializable: 6.14.1 From 51ef66174a5cb3093d3e043c8aad73146aaeeed4 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 10 Sep 2026 15:15:12 -0400 Subject: [PATCH 14/29] fix generate script to work with new ffigen --- scripts/generate.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/generate.dart b/scripts/generate.dart index 4dc7329..7109929 100644 --- a/scripts/generate.dart +++ b/scripts/generate.dart @@ -1,4 +1,5 @@ import "dart:io"; + import "package:ffigen/ffigen.dart"; import "package:path/path.dart"; import "package:nexus/helpers/extensions/get_xcode_sdk.dart"; @@ -11,16 +12,17 @@ void main(List args) async { final libclangPath = Platform.environment["LIBCLANG_PATH"]; FfiGenerator( output: Output( - dartFile: Platform.script.resolve("../lib/src/third_party/gomuks.g.dart"), + dart: .new( + path: Platform.script.resolve("../lib/src/third_party/gomuks.g.dart"), + ), ), - headers: Headers( + input: .new( entryPoints: [File(join(repoDir.path, "pkg", "ffi", "gomuksffi.h")).uri], compilerOptions: [ "--no-warnings", if (Platform.isMacOS) "-I${await getXCodeTool()}/usr/include", ], ), - functions: Functions.includeAll, ).generate( libclangDylib: libclangPath == null ? null From 3d7ef9b32f79a3d59d4baf3d98c968434873c4d6 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 10 Sep 2026 15:20:56 -0400 Subject: [PATCH 15/29] actually generate bindings Yes `ffigen`, I did actually want you to generate the bindings, not a file consisting only of comments. Crazy, right? --- scripts/generate.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/generate.dart b/scripts/generate.dart index 7109929..2966c9f 100644 --- a/scripts/generate.dart +++ b/scripts/generate.dart @@ -16,6 +16,7 @@ void main(List args) async { path: Platform.script.resolve("../lib/src/third_party/gomuks.g.dart"), ), ), + visitors: [Visitor(func: (node) => node.isIncluded = true)], input: .new( entryPoints: [File(join(repoDir.path, "pkg", "ffi", "gomuksffi.h")).uri], compilerOptions: [ From c105a6035cb810d7021562e21f0211dbb87e15cc Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 10 Sep 2026 15:21:53 -0400 Subject: [PATCH 16/29] use dot shorthands where possible in generate script --- scripts/generate.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/generate.dart b/scripts/generate.dart index 2966c9f..b9b8511 100644 --- a/scripts/generate.dart +++ b/scripts/generate.dart @@ -11,12 +11,12 @@ void main(List args) async { final libclangPath = Platform.environment["LIBCLANG_PATH"]; FfiGenerator( - output: Output( + output: .new( dart: .new( path: Platform.script.resolve("../lib/src/third_party/gomuks.g.dart"), ), ), - visitors: [Visitor(func: (node) => node.isIncluded = true)], + visitors: [.new(func: (node) => node.isIncluded = true)], input: .new( entryPoints: [File(join(repoDir.path, "pkg", "ffi", "gomuksffi.h")).uri], compilerOptions: [ From 6e42902322d208887f35b11ec75a5818c00ddcec Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 10 Sep 2026 15:32:30 -0400 Subject: [PATCH 17/29] run GomuksInit on an Isolate Fixes #70 --- lib/controllers/client.dart | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index af44d2f..27c0646 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -49,15 +49,17 @@ import "package:path_provider/path_provider.dart"; class ClientController extends AsyncNotifier { @override Future build() async { - final Pointer root; - if (Platform.isAndroid || Platform.isIOS) { - final dir = await getApplicationSupportDirectory(); - root = "${dir.path}/gomuks".toNativeUtf8().cast(); - } else { - root = nullptr.cast(); - } + final handle = await Isolate.run(() async { + final Pointer root; + if (Platform.isAndroid || Platform.isIOS) { + final dir = await getApplicationSupportDirectory(); + root = "${dir.path}/gomuks".toNativeUtf8().cast(); + } else { + root = nullptr.cast(); + } - final handle = GomuksInit(root); + return GomuksInit(root); + }); final callable = NativeCallable< From 0e05245c50a13d783775d322f71a3ea343a3bb0b Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sun, 23 Aug 2026 13:55:07 -0400 Subject: [PATCH 18/29] support for registering pushers --- .vscode/settings.json | 4 +- android/app/build.gradle.kts | 4 + lib/controllers/client.dart | 9 ++ lib/controllers/settings_sections.dart | 43 +++++++ lib/controllers/unified_push.dart | 106 ++++++++++++++++++ lib/main.dart | 34 +++--- lib/models/capabilities.dart | 25 +++++ lib/models/client_state.dart | 1 + lib/models/join_rule.dart | 2 +- lib/models/membership_action.dart | 3 - lib/models/membership_status.dart | 3 - lib/models/requests/register_pusher.dart | 56 +++++++++ linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + linux/runner/my_application.cc | 2 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 64 +++++++++++ pubspec.yaml | 2 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 20 files changed, 348 insertions(+), 21 deletions(-) create mode 100644 lib/controllers/unified_push.dart create mode 100644 lib/models/capabilities.dart create mode 100644 lib/models/requests/register_pusher.dart diff --git a/.vscode/settings.json b/.vscode/settings.json index 2ff533e..fac2ed8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,6 +11,8 @@ "muks", "prefs", "unban", - "unredact" + "unifiedpush", + "unredact", + "webpush" ] } diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8a23bb6..9f9a087 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -63,6 +63,10 @@ android { } } +dependencies { + implementation("org.unifiedpush.android:embedded-fcm-distributor:3.1.0") +} + kotlin { compilerOptions { jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21 diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 27c0646..56cc4f8 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -15,6 +15,7 @@ import "package:nexus/controllers/sync_status.dart"; import "package:nexus/controllers/top_level_spaces.dart"; import "package:nexus/helpers/extensions/gomuks_buffer.dart"; import "package:nexus/main.dart"; +import "package:nexus/models/capabilities.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/oauth_auth_code_response.dart"; @@ -31,6 +32,7 @@ import "package:nexus/models/requests/oauth/get_auth_url.dart"; import "package:nexus/models/requests/oauth/register_client.dart"; import "package:nexus/models/requests/paginate.dart"; import "package:nexus/models/requests/redact_event.dart"; +import "package:nexus/models/requests/register_pusher.dart"; import "package:nexus/models/requests/report.dart"; import "package:nexus/models/requests/send_event.dart"; import "package:nexus/models/requests/send_message.dart"; @@ -281,6 +283,9 @@ class ClientController extends AsyncNotifier { Future setAccountData(SetAccountDataRequest request) => _sendCommand("set_account_data", request.toJson()); + Future registerPusher(RegisterPusherRequest request) => + _sendCommand("register_homeserver_push", request.toJson()); + Future uploadMedia(UploadMediaRequest request) async => .fromJson(await _sendCommand("upload_media", request.toJson())); @@ -318,6 +323,10 @@ class ClientController extends AsyncNotifier { Future getSpecVersions() async => .fromJson(await _sendCommand("get_versions")); + Future getCapabilities() async => Capabilities.fromJson( + (await _sendCommand("get_capabilities"))["capabilities"], + ); + Future discoverHomeserver(Uri homeserver) async { try { final response = await _sendCommand("discover_homeserver", { diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index 042b42b..fea0d26 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -9,6 +9,7 @@ import "package:nexus/controllers/account_data.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/settings.dart"; +import "package:nexus/controllers/unified_push.dart"; import "package:nexus/controllers/spec_versions.dart"; import "package:nexus/models/account_data.dart"; import "package:nexus/models/settings_category.dart"; @@ -95,6 +96,46 @@ class SettingsSectionsController if (ref.watch(ClientStateController.provider)?.isLoggedIn == true) "Account": .new([ .new(title: "Profile", icon: Icons.person, settings: .new([])), + .new( + title: "Notifications", + icon: Icons.notifications, + settings: .new([ + // TODO: Disable if not supported + .new( + title: "Push notifications", + description: "Enable push notifications using Web Push", + builder: (title, description, icon) => Consumer( + builder: (context, ref, _) { + final pusherRegistered = ref.watch( + UnifiedPushController.provider, + ); + return SwitchListTile( + title: Text(title), + subtitle: Text(description), + secondary: Icon(icon), + value: pusherRegistered.maybeWhen( + data: (value) => value, + orElse: () => false, + ), + onChanged: pusherRegistered.maybeWhen( + data: (_) => + (value) => value + ? ref + .watch( + UnifiedPushController.provider.notifier, + ) + .register() + .onError(showError) + : /*deregeister*/ null, + orElse: () => null, + ), + ); + }, + ), + icon: Icons.notification_add, + ), + ]), + ), .new( title: "Safety", icon: Icons.gpp_good, @@ -166,6 +207,8 @@ class SettingsSectionsController onPressed: () async { Navigator.of(context) .popUntil((route) => route.isFirst); + Navigator.of(context) + .popUntil((route) => route.isFirst); await WidgetsBinding.instance.endOfFrame; diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart new file mode 100644 index 0000000..c7a6cde --- /dev/null +++ b/lib/controllers/unified_push.dart @@ -0,0 +1,106 @@ +import "dart:io"; + +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:intl/intl.dart"; +import "package:nexus/main.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/models/requests/register_pusher.dart"; +import "package:unifiedpush/unifiedpush.dart"; +import "package:unifiedpush_storage_shared_preferences/storage.dart"; + +class UnifiedPushController extends AsyncNotifier { + @override + Future build() async { + final registered = await UnifiedPush.initialize( + linuxOptions: .new( + dbusName: "nexus.federated.nexus", + storage: UnifiedPushStorageSharedPreferences(), + background: isInBackground, + ), + onNewEndpoint: (endpoint, instance) async { + final client = ref.watch(ClientController.provider.notifier); + await client.registerPusher( + .new( + appDisplayName: "Nexus", + appId: "nexus.federated.nexus", + data: PusherData.webPush( + url: Uri.parse(endpoint.url), + auth: endpoint.pubKeySet!.auth, + ), + deviceDisplayName: + "Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}", + kind: .webPush, + lang: "en", + pushKey: endpoint.pubKeySet!.pubKey, + ), + + // Pusher( + // appDisplayName: , + // data: { + // "url": endpoint.url, + // "auth": endpoint.pubKeySet.auth, + // "format": "event_id_only", + // }, + // deviceDisplayName: "Nexus", + // kind: "org.matrix.msc4174.webpush", + // lang: WidgetsBinding.instance.platformDispatcher.locale + // .toLanguageTag(), + // pushkey: endpoint.pubKeySet.publicKey, + // ), + ); + }, + onMessage: (message, instance) async { + print(message); + // TODO: Handle incoming message + }, + onRegistrationFailed: (e, r) { + throw "e"; + }, + onUnregistered: (_) { + throw "r"; + }, + onTempUnavailable: (_) { + throw "t"; + }, + ); + + if (registered) { + // Needs to be registered every startup + await register(true); + } + + return registered; + } + + Future register([bool early = false]) async { + final clientState = ref.watch(ClientStateController.provider); + if (clientState?.deviceId == null) ref.invalidateSelf(); + + final alreadyRegistered = early ? true : await future; + final client = ref.watch(ClientController.provider.notifier); + final capabilities = await client.getCapabilities(); + + if (capabilities.webpush?.vapid == null) { + throw UnsupportedError( + "Your homeserver does not support MSC4174 (Web Push), and therefore cannot send notifications to Nexus.", + ); + } + + if (!alreadyRegistered && + !await UnifiedPush.tryUseCurrentOrDefaultDistributor()) { + throw Exception("No UnifiedPush distributors found"); + } + + await UnifiedPush.register( + instance: clientState!.deviceId!, + vapid: capabilities.webpush?.vapid, + ); + + if (!alreadyRegistered) ref.invalidateSelf(); + } + + static final provider = AsyncNotifierProvider( + UnifiedPushController.new, + ); +} diff --git a/lib/main.dart b/lib/main.dart index a87d7f5..7b2aa63 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/multi_provider.dart"; import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/shared_prefs.dart"; +import "package:nexus/controllers/unified_push.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/scheme_to_theme.dart"; import "package:nexus/helpers/font_licenses.dart"; @@ -22,6 +23,7 @@ import "package:window_manager/window_manager.dart"; import "package:material_ui/material_ui.dart"; final GlobalKey navigatorKey = GlobalKey(); +late final bool isInBackground; final class Logger extends ProviderObserver { @override @@ -62,7 +64,7 @@ void showError(Object error, [StackTrace? stackTrace]) { } } -void main() async { +void main(List args) async { WidgetsFlutterBinding.ensureInitialized(); MediaKit.ensureInitialized(); @@ -77,25 +79,30 @@ void main() async { await windowManager.setMinimumSize(Size.square(500)); } + isInBackground = args.contains("--unifiedpush-bg"); + LicenseRegistry.addLicense(() => Stream.fromIterable(fontLicenses)); FlutterError.onError = (FlutterErrorDetails details) => showError(details.exception.toString(), details.stack); - runApp( - ProviderScope( - retry: (_, _) => null, - observers: [ - // Change false to true if you want debug information on provider reloads - // ignore: dead_code - if (false && kDebugMode) Logger(), - ], - child: const App(), - ), - ); + if (!isInBackground) { + runApp( + ProviderScope( + retry: (_, _) => null, + observers: [ + // Change false to true if you want debug information on provider reloads + // ignore: dead_code + if (false && kDebugMode) Logger(), + ], + child: App(isInBackground), + ), + ); + } } -class const App({super.key}) extends StatelessWidget { +class const App(final bool isInBackground, {super.key}) + extends StatelessWidget { @override Widget build(BuildContext context) => DynamicColorBuilder( builder: (lightDynamic, darkDynamic) => Consumer( @@ -138,6 +145,7 @@ class const App({super.key}) extends StatelessWidget { IListConst([ SharedPrefsController.provider, ClientController.provider, + UnifiedPushController.provider, ]), ), ) diff --git a/lib/models/capabilities.dart b/lib/models/capabilities.dart new file mode 100644 index 0000000..6830170 --- /dev/null +++ b/lib/models/capabilities.dart @@ -0,0 +1,25 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "capabilities.freezed.dart"; +part "capabilities.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const Capabilities({ + @JsonKey(name: "org.matrix.msc4174.webpush") final WebPush? webpush, +}) with _$Capabilities { + Map toJson() => _$CapabilitiesToJson(this); + + factory Capabilities.fromJson(Map json) => + _$CapabilitiesFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const WebPush({required final bool enabled, final String? vapid}) + with _$WebPush { + Map toJson() => _$WebPushToJson(this); + + factory WebPush.fromJson(Map json) => + _$WebPushFromJson(json); +} diff --git a/lib/models/client_state.dart b/lib/models/client_state.dart index ff433ab..262203d 100644 --- a/lib/models/client_state.dart +++ b/lib/models/client_state.dart @@ -10,6 +10,7 @@ class const ClientState({ required final bool isLoggedIn, required final bool isVerified, required final String? userId, + required final String? deviceId, required final String? homeserverUrl, }) with _$ClientState { Map toJson() => _$ClientStateToJson(this); diff --git a/lib/models/join_rule.dart b/lib/models/join_rule.dart index 3fade23..44fb9bb 100644 --- a/lib/models/join_rule.dart +++ b/lib/models/join_rule.dart @@ -1,4 +1,4 @@ import "package:freezed_annotation/freezed_annotation.dart"; -@JsonEnum(fieldRename: FieldRename.snake) +@JsonEnum(fieldRename: .snake) enum JoinRule { public, knock, invite, private, restricted, knockRestricted } diff --git a/lib/models/membership_action.dart b/lib/models/membership_action.dart index d852164..f03ca99 100644 --- a/lib/models/membership_action.dart +++ b/lib/models/membership_action.dart @@ -1,4 +1 @@ -import "package:freezed_annotation/freezed_annotation.dart"; - -@JsonEnum() enum MembershipAction { ban, kick, unban, invite } diff --git a/lib/models/membership_status.dart b/lib/models/membership_status.dart index ba7a241..7eb5f72 100644 --- a/lib/models/membership_status.dart +++ b/lib/models/membership_status.dart @@ -1,4 +1 @@ -import "package:freezed_annotation/freezed_annotation.dart"; - -@JsonEnum() enum MembershipStatus { leave, invite, ban, join, knock } diff --git a/lib/models/requests/register_pusher.dart b/lib/models/requests/register_pusher.dart new file mode 100644 index 0000000..dd34e62 --- /dev/null +++ b/lib/models/requests/register_pusher.dart @@ -0,0 +1,56 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "register_pusher.freezed.dart"; +part "register_pusher.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const RegisterPusherRequest({ + required final String appDisplayName, + required final String appId, + final bool append = false, + required final PusherData data, + required final String deviceDisplayName, + required final PusherKind kind, + required final String lang, + + /// TODO: What does this do? + final String? profileTag, + + @JsonKey(name: "pushkey") required final String pushKey, +}) with _$RegisterPusherRequest { + Map toJson() => _$RegisterPusherRequestToJson(this); +} + +@freezed +sealed class PusherData with _$PusherData { + const factory PusherData.http({ + required Uri url, + @Default(PushFormat.eventIdOnly) PushFormat format, + }) = HttpPusherData; + + const factory PusherData.webPush({ + required Uri url, + @Default(PushFormat.eventIdOnly) PushFormat format, + + /// `data.auth`: RFC8291 authentication secret. + required String auth, + }) = WebPushPusherData; + + factory PusherData.fromJson(Map json) => + _$PusherDataFromJson(json); +} + +@JsonEnum(fieldRename: .snake) +enum PushFormat { + @JsonValue(null) + all, + eventIdOnly, +} + +enum PusherKind { + http, + email, + @JsonValue("org.matrix.msc4174.webpush") + webPush, +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 603ea6a..70d9ba2 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -13,6 +13,7 @@ #include #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -37,6 +38,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) webcrypto_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WebcryptoPlugin"); + webcrypto_plugin_register_with_registrar(webcrypto_registrar); g_autoptr(FlPluginRegistrar) window_manager_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); window_manager_plugin_register_with_registrar(window_manager_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 7d8f046..1d53ad7 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_video screen_retriever_linux url_launcher_linux + webcrypto window_manager ) diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 62e5450..739e6df 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -1,5 +1,6 @@ #include "my_application.h" +#include #include #ifdef GDK_WINDOWING_X11 #include @@ -72,6 +73,7 @@ static void my_application_activate(GApplication* application) { gdk_rgba_parse(&background_color, "#000000"); fl_view_set_background_color(view, &background_color); gtk_widget_show(GTK_WIDGET(view)); + if (std::getenv("FLUTTER_HEADLESS")) gtk_widget_hide(GTK_WIDGET(window)); gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); // Show the window when Flutter renders. diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index f18d831..5d90835 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -15,6 +15,7 @@ import screen_retriever_macos import shared_preferences_foundation import url_launcher_macos import wakelock_plus +import webcrypto import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { @@ -28,5 +29,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) + WebcryptoPlugin.register(with: registry.registrar(forPlugin: "WebcryptoPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 753373a..20e661e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1437,6 +1437,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + unifiedpush: + dependency: "direct main" + description: + name: unifiedpush + sha256: "8ed9767f750a1dc6159a77e2171641d0cb825dc87682d1ce1b8618689b79f58e" + url: "https://pub.dev" + source: hosted + version: "6.2.0" + unifiedpush_android: + dependency: transitive + description: + name: unifiedpush_android + sha256: "2e6684e0a1a39ad8d6c7bc0d197954d50686acb1b1a7614b18ab0e0126f5492a" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + unifiedpush_linux: + dependency: transitive + description: + name: unifiedpush_linux + sha256: c062d5eedd1cec70bcd33270cc4e01ae0ff6501f33d471167c06b34a968adfeb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + unifiedpush_platform_interface: + dependency: transitive + description: + name: unifiedpush_platform_interface + sha256: "83372bc8d794b8b12ef6993b518d7be907dcfc2191bdf6de0ece5c4445d89880" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + unifiedpush_storage_interface: + dependency: transitive + description: + name: unifiedpush_storage_interface + sha256: b8d423a4695efc616aa21d8ab48fb5ef99d6288c68b56282b8faac1579ceabd9 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + unifiedpush_storage_shared_preferences: + dependency: "direct main" + description: + name: unifiedpush_storage_shared_preferences + sha256: eda9c52bac0058f81e0d9c65e33eedc12c5901d2e68ad47fdf68175ddf00a3b4 + url: "https://pub.dev" + source: hosted + version: "1.0.0" universal_platform: dependency: transitive description: @@ -1613,6 +1661,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webcrypto: + dependency: transitive + description: + name: webcrypto + sha256: "6b43001c4110856ff7fa5e5e65e7b2d44bec1d8b54a4d84d5fa2c7622267c5c1" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + webpush_encryption: + dependency: transitive + description: + name: webpush_encryption + sha256: "5b83272b91acda6ae515fcd980c94f06bf413702282497c5a68f5dfc64fed27f" + url: "https://pub.dev" + source: hosted + version: "1.0.1" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4b69b2c..b000011 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -79,6 +79,8 @@ dependencies: app_links: 7.2.1 file_selector: 1.1.0 material_ui: 1.2.0 + unifiedpush: 6.2.0 + unifiedpush_storage_shared_preferences: 1.0.0 dev_dependencies: build_runner: 2.16.1 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 0694802..5d2b53e 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -13,6 +13,7 @@ #include #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -30,6 +31,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); + WebcryptoPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WebcryptoPlugin")); WindowManagerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("WindowManagerPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 238ef93..51af01c 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_video screen_retriever_windows url_launcher_windows + webcrypto window_manager ) From c73167a3afedc6929e27eb7607f3521aed5d357d Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sun, 23 Aug 2026 14:26:51 -0400 Subject: [PATCH 19/29] add UnifiedPushAllowedController --- lib/controllers/settings_sections.dart | 45 ++++++++++++++++------- lib/controllers/unified_push.dart | 5 ++- lib/controllers/unified_push_allowed.dart | 33 +++++++++++++++++ 3 files changed, 67 insertions(+), 16 deletions(-) create mode 100644 lib/controllers/unified_push_allowed.dart diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index fea0d26..8d67435 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -11,6 +11,7 @@ import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/unified_push.dart"; import "package:nexus/controllers/spec_versions.dart"; +import "package:nexus/controllers/unified_push_allowed.dart"; import "package:nexus/models/account_data.dart"; import "package:nexus/models/settings_category.dart"; import "package:nexus/main.dart"; @@ -100,7 +101,6 @@ class SettingsSectionsController title: "Notifications", icon: Icons.notifications, settings: .new([ - // TODO: Disable if not supported .new( title: "Push notifications", description: "Enable push notifications using Web Push", @@ -109,26 +109,43 @@ class SettingsSectionsController final pusherRegistered = ref.watch( UnifiedPushController.provider, ); + final unifiedPushAllowed = ref.watch( + UnifiedPushAllowedController.provider, + ); return SwitchListTile( title: Text(title), - subtitle: Text(description), + subtitle: Text( + unifiedPushAllowed.maybeWhen( + data: (data) => data, + orElse: () => null, + ) ?? + description, + ), secondary: Icon(icon), value: pusherRegistered.maybeWhen( data: (value) => value, orElse: () => false, ), - onChanged: pusherRegistered.maybeWhen( - data: (_) => - (value) => value - ? ref - .watch( - UnifiedPushController.provider.notifier, - ) - .register() - .onError(showError) - : /*deregeister*/ null, - orElse: () => null, - ), + onChanged: + unifiedPushAllowed.maybeWhen( + data: (data) => data == null, + orElse: () => false, + ) + ? pusherRegistered.maybeWhen( + data: (_) => + (value) => value + ? ref + .watch( + UnifiedPushController + .provider + .notifier, + ) + .register() + .onError(showError) + : /*deregeister*/ null, + orElse: () => null, + ) + : null, ); }, ), diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index c7a6cde..b4aa107 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -78,8 +78,9 @@ class UnifiedPushController extends AsyncNotifier { if (clientState?.deviceId == null) ref.invalidateSelf(); final alreadyRegistered = early ? true : await future; - final client = ref.watch(ClientController.provider.notifier); - final capabilities = await client.getCapabilities(); + final capabilities = await ref + .watch(ClientController.provider.notifier) + .getCapabilities(); if (capabilities.webpush?.vapid == null) { throw UnsupportedError( diff --git a/lib/controllers/unified_push_allowed.dart b/lib/controllers/unified_push_allowed.dart new file mode 100644 index 0000000..4f925cf --- /dev/null +++ b/lib/controllers/unified_push_allowed.dart @@ -0,0 +1,33 @@ +import "dart:io"; + +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:unifiedpush/unifiedpush.dart"; + +class UnifiedPushAllowedController extends AsyncNotifier { + @override + Future build() async { + if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor()) { + return "No valid distributors found. Try installing ${Platform.isLinux + ? "KUnifiedPush" + : Platform.isAndroid + ? "FCM or NTFY" + : "NTFY"}."; + } + + final capabilities = await ref + .watch(ClientController.provider.notifier) + .getCapabilities(); + + if (capabilities.webpush?.vapid == null) { + return "Your homeserver does not support MSC4174 (Web Push), and therefore cannot send notifications to Nexus."; + } + + return null; + } + + static final provider = + AsyncNotifierProvider.autoDispose( + UnifiedPushAllowedController.new, + ); +} From 2c107dd92f3e9604405535ccc274ea3fae4d7857 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sun, 23 Aug 2026 22:13:38 -0400 Subject: [PATCH 20/29] slight cleanup `onNewEndpoint` is still not getting called for some reason (help appreciated) --- lib/controllers/unified_push.dart | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index b4aa107..3097e6d 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -34,20 +34,6 @@ class UnifiedPushController extends AsyncNotifier { lang: "en", pushKey: endpoint.pubKeySet!.pubKey, ), - - // Pusher( - // appDisplayName: , - // data: { - // "url": endpoint.url, - // "auth": endpoint.pubKeySet.auth, - // "format": "event_id_only", - // }, - // deviceDisplayName: "Nexus", - // kind: "org.matrix.msc4174.webpush", - // lang: WidgetsBinding.instance.platformDispatcher.locale - // .toLanguageTag(), - // pushkey: endpoint.pubKeySet.publicKey, - // ), ); }, onMessage: (message, instance) async { @@ -73,11 +59,10 @@ class UnifiedPushController extends AsyncNotifier { return registered; } - Future register([bool early = false]) async { + Future register([bool alreadyRegistered = false]) async { final clientState = ref.watch(ClientStateController.provider); if (clientState?.deviceId == null) ref.invalidateSelf(); - final alreadyRegistered = early ? true : await future; final capabilities = await ref .watch(ClientController.provider.notifier) .getCapabilities(); From 30ca08d911601e26b3fbb787edf62a4da18a3ce9 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 25 Aug 2026 17:30:55 -0400 Subject: [PATCH 21/29] working registration --- lib/controllers/unified_push.dart | 2 +- linux/nix/devshell.nix | 1 - pubspec.lock | 6 +++--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index 3097e6d..0e7d1aa 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -14,7 +14,7 @@ class UnifiedPushController extends AsyncNotifier { Future build() async { final registered = await UnifiedPush.initialize( linuxOptions: .new( - dbusName: "nexus.federated.nexus", + dbusName: "nexus.federated.nexus.UnifiedPush", storage: UnifiedPushStorageSharedPreferences(), background: isInBackground, ), diff --git a/linux/nix/devshell.nix b/linux/nix/devshell.nix index dd3c78e..8bc8641 100644 --- a/linux/nix/devshell.nix +++ b/linux/nix/devshell.nix @@ -45,7 +45,6 @@ pkgs.mkShell { GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${ANDROID_HOME}/build-tools/36.0.0/aapt2"; } // lib.optionalAttrs pkgs.stdenv.isLinux { - CPATH = lib.makeSearchPath "include" [ pkgs.glibc.dev ]; LD_LIBRARY_PATH = "./build/native_assets/linux:${lib.makeLibraryPath [ pkgs.zlib ]}"; }; } diff --git a/pubspec.lock b/pubspec.lock index 20e661e..4426c39 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1449,10 +1449,10 @@ packages: dependency: transitive description: name: unifiedpush_android - sha256: "2e6684e0a1a39ad8d6c7bc0d197954d50686acb1b1a7614b18ab0e0126f5492a" + sha256: a902e505e91c8e28b54b379bb27f255f66df409274a97af24d6719f12f0d13dc url: "https://pub.dev" source: hosted - version: "3.4.1" + version: "3.5.0" unifiedpush_linux: dependency: transitive description: @@ -1662,7 +1662,7 @@ packages: source: hosted version: "3.0.3" webcrypto: - dependency: transitive + dependency: "direct main" description: name: webcrypto sha256: "6b43001c4110856ff7fa5e5e65e7b2d44bec1d8b54a4d84d5fa2c7622267c5c1" From d771b886d9b5cccd762e2e583a6c5724a3dd2d6b Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 25 Aug 2026 19:51:43 -0400 Subject: [PATCH 22/29] pass pushes to gomuks --- lib/controllers/client.dart | 32 ++++++++++++++++++++++--------- lib/controllers/unified_push.dart | 15 ++++++++------- lib/main.dart | 1 + pubspec.lock | 2 +- 4 files changed, 33 insertions(+), 17 deletions(-) diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 56cc4f8..8f6e6ad 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -1,3 +1,4 @@ +import "dart:async"; import "dart:ffi"; import "dart:io"; import "dart:isolate"; @@ -158,18 +159,15 @@ class ClientController extends AsyncNotifier { throw Exception("GomuksStart returned error code $errorCode"); } - Future _sendCommand( - String command, [ - Map data = const {}, - ]) async { + Future callGomuksMethod( + Map data, + FutureOr Function(int handle, GomuksBorrowedBuffer data) + callback, + ) async { final bufferPointer = data.toGomuksBufferPtr(); final handle = await future; final response = await Isolate.run( - () => GomuksSubmitCommand( - handle, - command.toNativeUtf8().cast(), - bufferPointer.ref, - ), + () => callback(handle, bufferPointer.ref), ); calloc.free(bufferPointer); @@ -182,6 +180,22 @@ class ClientController extends AsyncNotifier { return json; } + Future handlePush(Map data) async => .fromJson( + await callGomuksMethod( + data, + (handle, data) async => GomuksHandlePush(handle, data), + ), + ); + + Future _sendCommand( + String command, [ + Map data = const {}, + ]) => callGomuksMethod( + data, + (handle, data) async => + GomuksSubmitCommand(handle, command.toNativeUtf8().cast(), data), + ); + Future redactEvent(RedactEventRequest report) => _sendCommand("redact_event", report.toJson()); diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index 0e7d1aa..bfa0ad1 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -1,3 +1,4 @@ +import "dart:convert"; import "dart:io"; import "package:flutter_riverpod/flutter_riverpod.dart"; @@ -12,6 +13,7 @@ import "package:unifiedpush_storage_shared_preferences/storage.dart"; class UnifiedPushController extends AsyncNotifier { @override Future build() async { + final client = ref.watch(ClientController.provider.notifier); final registered = await UnifiedPush.initialize( linuxOptions: .new( dbusName: "nexus.federated.nexus.UnifiedPush", @@ -19,7 +21,6 @@ class UnifiedPushController extends AsyncNotifier { background: isInBackground, ), onNewEndpoint: (endpoint, instance) async { - final client = ref.watch(ClientController.provider.notifier); await client.registerPusher( .new( appDisplayName: "Nexus", @@ -37,16 +38,16 @@ class UnifiedPushController extends AsyncNotifier { ); }, onMessage: (message, instance) async { - print(message); - // TODO: Handle incoming message + final e = await client.handlePush( + json.decode(String.fromCharCodes(message.content)), + ); + print(e); }, onRegistrationFailed: (e, r) { throw "e"; }, - onUnregistered: (_) { - throw "r"; - }, - onTempUnavailable: (_) { + onUnregistered: (instance) => ref.invalidateSelf(), + onTempUnavailable: (instance) { throw "t"; }, ); diff --git a/lib/main.dart b/lib/main.dart index 7b2aa63..2221151 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -47,6 +47,7 @@ void showError(Object error, [StackTrace? stackTrace]) { error.toString().contains("Invalid source") || error.toString().contains("UTF-16") || error.toString().contains("HTTP request failed") || + error.toString().contains("'_nextFrame != null': is not true.") || error.toString().contains("Invalid image data")) { return; } diff --git a/pubspec.lock b/pubspec.lock index 4426c39..9d2c560 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1662,7 +1662,7 @@ packages: source: hosted version: "3.0.3" webcrypto: - dependency: "direct main" + dependency: "direct dev" description: name: webcrypto sha256: "6b43001c4110856ff7fa5e5e65e7b2d44bec1d8b54a4d84d5fa2c7622267c5c1" From 35df8f61a2e1510903d90c3edc2114ea06ebff95 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 25 Aug 2026 20:18:47 -0400 Subject: [PATCH 23/29] override webcrypto deps --- lib/controllers/unified_push.dart | 11 +++-------- linux/flutter/generated_plugin_registrant.cc | 4 ---- linux/flutter/generated_plugins.cmake | 1 - macos/Flutter/GeneratedPluginRegistrant.swift | 2 -- pubspec.lock | 12 ++++++++++-- pubspec.yaml | 2 ++ windows/flutter/generated_plugin_registrant.cc | 3 --- windows/flutter/generated_plugins.cmake | 1 - 8 files changed, 15 insertions(+), 21 deletions(-) diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index bfa0ad1..f72e8ec 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -38,18 +38,13 @@ class UnifiedPushController extends AsyncNotifier { ); }, onMessage: (message, instance) async { - final e = await client.handlePush( + final event = await client.handlePush( json.decode(String.fromCharCodes(message.content)), ); - print(e); - }, - onRegistrationFailed: (e, r) { - throw "e"; + print(event); }, + onRegistrationFailed: (error, instance) => throw error, onUnregistered: (instance) => ref.invalidateSelf(), - onTempUnavailable: (instance) { - throw "t"; - }, ); if (registered) { diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 70d9ba2..603ea6a 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -13,7 +13,6 @@ #include #include #include -#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -38,9 +37,6 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); - g_autoptr(FlPluginRegistrar) webcrypto_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "WebcryptoPlugin"); - webcrypto_plugin_register_with_registrar(webcrypto_registrar); g_autoptr(FlPluginRegistrar) window_manager_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); window_manager_plugin_register_with_registrar(window_manager_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 1d53ad7..7d8f046 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -10,7 +10,6 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_video screen_retriever_linux url_launcher_linux - webcrypto window_manager ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 5d90835..f18d831 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -15,7 +15,6 @@ import screen_retriever_macos import shared_preferences_foundation import url_launcher_macos import wakelock_plus -import webcrypto import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { @@ -29,6 +28,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) - WebcryptoPlugin.register(with: registry.registrar(forPlugin: "WebcryptoPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 9d2c560..7e7bccb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -169,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.2" + change_case: + dependency: transitive + description: + name: change_case + sha256: e41ef3df58521194ef8d7649928954805aeb08061917cf658322305e61568003 + url: "https://pub.dev" + source: hosted + version: "2.2.0" characters: dependency: transitive description: @@ -1662,10 +1670,10 @@ packages: source: hosted version: "3.0.3" webcrypto: - dependency: "direct dev" + dependency: "direct overridden" description: name: webcrypto - sha256: "6b43001c4110856ff7fa5e5e65e7b2d44bec1d8b54a4d84d5fa2c7622267c5c1" + sha256: "9885ed99a846191542dd6ca820c83a098117df56be57453832233a3fef68f3f2" url: "https://pub.dev" source: hosted version: "0.6.0" diff --git a/pubspec.yaml b/pubspec.yaml index b000011..efbe524 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,6 +19,8 @@ environment: sdk: 3.13.0 dependency_overrides: + webcrypto: 0.6.1 + hooks: 2.2.0 dynamic_color: 2.1.0 path_provider_android: 2.2.23 # Pinned to avoid JNI linkify: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 5d2b53e..0694802 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -13,7 +13,6 @@ #include #include #include -#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -31,8 +30,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); - WebcryptoPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("WebcryptoPlugin")); WindowManagerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("WindowManagerPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 51af01c..238ef93 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -10,7 +10,6 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_video screen_retriever_windows url_launcher_windows - webcrypto window_manager ) From 454353ca5eaca323fb56806a907f0cad04cb8054 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 26 Aug 2026 00:25:47 -0400 Subject: [PATCH 24/29] send notifications natively --- README.md | 6 ++ android/app/build.gradle.kts | 10 +- ios/Runner/AppDelegate.swift | 3 +- lib/controllers/notifications.dart | 97 +++++++++++++++++++ lib/controllers/settings_sections.dart | 24 ++++- lib/controllers/unified_push.dart | 24 ++++- lib/main.dart | 2 + linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 + pubspec.lock | 60 ++++++++++-- pubspec.yaml | 5 +- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 2 + 14 files changed, 224 insertions(+), 21 deletions(-) create mode 100644 lib/controllers/notifications.dart diff --git a/README.md b/README.md index 0c18acf..ee55903 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,12 @@ dart scripts/generate.dart > export CPATH="$(clang -v 2>&1 | grep "Selected GCC installation" | rev | cut -d' ' -f1 | rev)/include" > ``` +Build webcrypto: + +```sh +flutter pub run webcrypto:setup +``` + Build generated files, and watch for new changes: ```sh diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9f9a087..9c0d96c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -20,16 +20,21 @@ android { ndkVersion = flutter.ndkVersion compileOptions { - sourceCompatibility = JavaVersion.VERSION_21 + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } defaultConfig { applicationId = "nexus.federated.nexus" - minSdk = 29 + + minSdk = 29 targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode versionName = flutter.versionName + + multiDexEnabled = true } signingConfigs { @@ -65,6 +70,7 @@ android { dependencies { implementation("org.unifiedpush.android:embedded-fcm-distributor:3.1.0") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") } kotlin { diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c30b367..c73d4ae 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -7,7 +7,8 @@ import UIKit _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - return super.application(application, didFinishLaunchingWithOptions: launchOptions) + UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { diff --git a/lib/controllers/notifications.dart b/lib/controllers/notifications.dart new file mode 100644 index 0000000..faf5e8c --- /dev/null +++ b/lib/controllers/notifications.dart @@ -0,0 +1,97 @@ +import "dart:io"; + +import "package:flutter_local_notifications/flutter_local_notifications.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; + +class NotificationsController + extends AsyncNotifier { + @override + Future build() async { + final notifications = FlutterLocalNotificationsPlugin(); + + final darwin = DarwinInitializationSettings(); + + await notifications.initialize( + settings: .new( + windows: .new( + appName: "Nexus", + appUserModelId: "nexus.federated.nexus", + guid: "dde78daf-130f-4e46-a80a-e31deeab45d7", + ), + android: .new("ic_launcher"), + iOS: darwin, + macOS: darwin, + linux: .new(defaultActionName: "Open"), + ), + onDidReceiveNotificationResponse: (details) { + print(details.data); + }, + ); + + return notifications; + } + + Future requestPermissions() async { + final controller = await future; + + if (Platform.isIOS) { + return await controller + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin + >() + ?.requestPermissions(alert: true, badge: true, sound: true) ?? + true; + } else if (Platform.isMacOS) { + return await controller + .resolvePlatformSpecificImplementation< + MacOSFlutterLocalNotificationsPlugin + >() + ?.requestPermissions(alert: true, badge: true, sound: true) ?? + true; + } else if (Platform.isAndroid) { + return await controller + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.requestNotificationsPermission() ?? + true; + } + + return true; + } + + Future send({ + required int id, + required String title, + String? body, + File? icon, + String? payload, + }) async { + final notificationDetails = NotificationDetails( + android: .new( + "messages", + "Messages", + largeIcon: icon == null ? null : FilePathAndroidBitmap(icon.path), + ), + iOS: .new(), + macOS: .new(), + linux: .new(icon: icon == null ? null : FilePathLinuxIcon(icon.path)), + windows: .new(), + ); + + final controller = await future; + await controller.show( + id: id, + title: title, + body: body, + notificationDetails: notificationDetails, + payload: payload, + ); + } + + static final provider = + AsyncNotifierProvider< + NotificationsController, + FlutterLocalNotificationsPlugin + >(NotificationsController.new); +} diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index 8d67435..35753fe 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -8,6 +8,7 @@ import "package:m3e_buttons/m3e_buttons.dart"; import "package:nexus/controllers/account_data.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/notifications.dart"; import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/unified_push.dart"; import "package:nexus/controllers/spec_versions.dart"; @@ -132,17 +133,30 @@ class SettingsSectionsController orElse: () => false, ) ? pusherRegistered.maybeWhen( - data: (_) => - (value) => value - ? ref + data: (_) => (value) async { + if (value) { + if (await ref + .watch( + NotificationsController + .provider + .notifier, + ) + .requestPermissions()) { + ref .watch( UnifiedPushController .provider .notifier, ) .register() - .onError(showError) - : /*deregeister*/ null, + .onError(showError); + } else { + // TODO: Handle not granted + } + } else { + // TODO: Deregister + } + }, orElse: () => null, ) : null, diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index f72e8ec..c4d32ac 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -3,9 +3,12 @@ import "dart:io"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:intl/intl.dart"; +import "package:nexus/controllers/notifications.dart"; +import "package:nexus/controllers/rooms.dart"; import "package:nexus/main.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; +import "package:nexus/models/content/message.dart"; import "package:nexus/models/requests/register_pusher.dart"; import "package:unifiedpush/unifiedpush.dart"; import "package:unifiedpush_storage_shared_preferences/storage.dart"; @@ -38,10 +41,29 @@ class UnifiedPushController extends AsyncNotifier { ); }, onMessage: (message, instance) async { + if (message.decrypted == false) { + // TODO: Handle more gracefully, show option to re-register pusher + throw Exception("Failed to decrypt notification"); + } final event = await client.handlePush( json.decode(String.fromCharCodes(message.content)), ); - print(event); + + final room = ref.read( + RoomsController.provider.select((rooms) => rooms[event.roomId]), + ); + + await ref + .read(NotificationsController.provider.notifier) + .send( + id: event.eventId.hashCode & 0x7fffffff, + title: room?.metadata?.name ?? "New Message", + body: switch (event.content) { + MessageContent(:final body) => body, + // TODO: Handle more types + _ => null, + }, + ); }, onRegistrationFailed: (error, instance) => throw error, onUnregistered: (instance) => ref.invalidateSelf(), diff --git a/lib/main.dart b/lib/main.dart index 2221151..aaed5a0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ import "package:media_kit/media_kit.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/multi_provider.dart"; +import "package:nexus/controllers/notifications.dart"; import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/shared_prefs.dart"; import "package:nexus/controllers/unified_push.dart"; @@ -146,6 +147,7 @@ class const App(final bool isInBackground, {super.key}) IListConst([ SharedPrefsController.provider, ClientController.provider, + NotificationsController.provider, UnifiedPushController.provider, ]), ), diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 603ea6a..70d9ba2 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -13,6 +13,7 @@ #include #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -37,6 +38,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) webcrypto_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WebcryptoPlugin"); + webcrypto_plugin_register_with_registrar(webcrypto_registrar); g_autoptr(FlPluginRegistrar) window_manager_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); window_manager_plugin_register_with_registrar(window_manager_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 7d8f046..1d53ad7 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_video screen_retriever_linux url_launcher_linux + webcrypto window_manager ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index f18d831..8b6bbb6 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,6 +8,7 @@ import Foundation import app_links import dynamic_color import file_selector_macos +import flutter_local_notifications import media_kit_libs_macos_video import media_kit_video import package_info_plus @@ -15,12 +16,14 @@ import screen_retriever_macos import shared_preferences_foundation import url_launcher_macos import wakelock_plus +import webcrypto import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) @@ -28,5 +31,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) + WebcryptoPlugin.register(with: registry.registrar(forPlugin: "WebcryptoPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 7e7bccb..5335e4f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -169,14 +169,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.2" - change_case: - dependency: transitive - description: - name: change_case - sha256: e41ef3df58521194ef8d7649928954805aeb08061917cf658322305e61568003 - url: "https://pub.dev" - source: hosted - version: "2.2.0" characters: dependency: transitive description: @@ -503,6 +495,46 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "1447ba911c60f2ba3f25dae1af151ec187162566b0f57e37771bf0b400f013ad" + url: "https://pub.dev" + source: hosted + version: "22.3.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b" + url: "https://pub.dev" + source: hosted + version: "8.0.1" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "43c3761d916c9bd3d5c7ebbc44d82f4990329840c0c5d62ad5260cc1b5d399bd" + url: "https://pub.dev" + source: hosted + version: "12.2.0" + flutter_local_notifications_web: + dependency: transitive + description: + name: flutter_local_notifications_web + sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "6f43bdd03b171b7a90f22647506fea33e2bb12294b7c7c7a3d690e960a382945" + url: "https://pub.dev" + source: hosted + version: "3.1.1" flutter_localizations: dependency: "direct main" description: flutter @@ -1437,6 +1469,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.7.1" + timezone: + dependency: transitive + description: + name: timezone + sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" + url: "https://pub.dev" + source: hosted + version: "0.11.1" typed_data: dependency: transitive description: @@ -1670,10 +1710,10 @@ packages: source: hosted version: "3.0.3" webcrypto: - dependency: "direct overridden" + dependency: "direct dev" description: name: webcrypto - sha256: "9885ed99a846191542dd6ca820c83a098117df56be57453832233a3fef68f3f2" + sha256: "6b43001c4110856ff7fa5e5e65e7b2d44bec1d8b54a4d84d5fa2c7622267c5c1" url: "https://pub.dev" source: hosted version: "0.6.0" diff --git a/pubspec.yaml b/pubspec.yaml index efbe524..6952713 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,8 +19,6 @@ environment: sdk: 3.13.0 dependency_overrides: - webcrypto: 0.6.1 - hooks: 2.2.0 dynamic_color: 2.1.0 path_provider_android: 2.2.23 # Pinned to avoid JNI linkify: @@ -83,6 +81,7 @@ dependencies: material_ui: 1.2.0 unifiedpush: 6.2.0 unifiedpush_storage_shared_preferences: 1.0.0 + flutter_local_notifications: ^22.3.0 dev_dependencies: build_runner: 2.16.1 @@ -91,6 +90,8 @@ dev_dependencies: riverpod_lint: 3.1.9 flutter_launcher_icons: 0.14.4 json_serializable: 6.14.1 + # Transitive dep but needed for commands + webcrypto: any flutter_launcher_icons: image_path: assets/icon.png diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 0694802..5d2b53e 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -13,6 +13,7 @@ #include #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -30,6 +31,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); + WebcryptoPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WebcryptoPlugin")); WindowManagerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("WindowManagerPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 238ef93..c74b2f5 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -10,10 +10,12 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_video screen_retriever_windows url_launcher_windows + webcrypto window_manager ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows ) set(PLUGIN_BUNDLED_LIBRARIES) From c4542ba406dffcf51a9a77362c098f07dc738818 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 26 Aug 2026 00:46:57 -0400 Subject: [PATCH 25/29] include avatars on notifs --- lib/controllers/notifications.dart | 13 ++++++++++++- lib/controllers/unified_push.dart | 8 ++++++++ pubspec.lock | 2 +- pubspec.yaml | 3 ++- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/lib/controllers/notifications.dart b/lib/controllers/notifications.dart index faf5e8c..2109cb7 100644 --- a/lib/controllers/notifications.dart +++ b/lib/controllers/notifications.dart @@ -1,6 +1,8 @@ import "dart:io"; import "package:flutter_local_notifications/flutter_local_notifications.dart"; +// ignore: implementation_imports +import "package:flutter_local_notifications_linux/src/model/hint.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; class NotificationsController @@ -75,7 +77,16 @@ class NotificationsController ), iOS: .new(), macOS: .new(), - linux: .new(icon: icon == null ? null : FilePathLinuxIcon(icon.path)), + linux: .new( + customHints: icon == null + ? null + : [ + .new( + name: "image-path", + value: LinuxHintStringValue(icon.path), + ), + ], + ), windows: .new(), ); diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index c4d32ac..0698bda 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -53,11 +53,19 @@ class UnifiedPushController extends AsyncNotifier { RoomsController.provider.select((rooms) => rooms[event.roomId]), ); + final avatar = room?.metadata?.avatar; + final icon = avatar == null + ? null + : await ref + .read(ClientController.provider.notifier) + .downloadMedia(.new(mxc: avatar, isAvatar: true)); + await ref .read(NotificationsController.provider.notifier) .send( id: event.eventId.hashCode & 0x7fffffff, title: room?.metadata?.name ?? "New Message", + icon: icon, body: switch (event.content) { MessageContent(:final body) => body, // TODO: Handle more types diff --git a/pubspec.lock b/pubspec.lock index 5335e4f..82e2d72 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -504,7 +504,7 @@ packages: source: hosted version: "22.3.0" flutter_local_notifications_linux: - dependency: transitive + dependency: "direct main" description: name: flutter_local_notifications_linux sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b" diff --git a/pubspec.yaml b/pubspec.yaml index 6952713..ab2f727 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -81,7 +81,8 @@ dependencies: material_ui: 1.2.0 unifiedpush: 6.2.0 unifiedpush_storage_shared_preferences: 1.0.0 - flutter_local_notifications: ^22.3.0 + flutter_local_notifications: 22.3.0 + flutter_local_notifications_linux: 8.0.1 dev_dependencies: build_runner: 2.16.1 From 32527e1241b98ba79f58974592e67502ac2c7234 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 26 Aug 2026 11:55:20 -0400 Subject: [PATCH 26/29] smarter notif logic --- lib/controllers/notifications.dart | 13 +++++-- lib/controllers/unified_push.dart | 58 +++++++++++++++++++----------- 2 files changed, 47 insertions(+), 24 deletions(-) diff --git a/lib/controllers/notifications.dart b/lib/controllers/notifications.dart index 2109cb7..d9bcd8f 100644 --- a/lib/controllers/notifications.dart +++ b/lib/controllers/notifications.dart @@ -1,9 +1,11 @@ +import "dart:convert"; import "dart:io"; import "package:flutter_local_notifications/flutter_local_notifications.dart"; // ignore: implementation_imports import "package:flutter_local_notifications_linux/src/model/hint.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/event.dart"; class NotificationsController extends AsyncNotifier { @@ -26,7 +28,11 @@ class NotificationsController linux: .new(defaultActionName: "Open"), ), onDidReceiveNotificationResponse: (details) { - print(details.data); + if (details.payload case final payload?) { + final event = ref.watch( + EventController.provider(.fromJson(json.decode(payload))), + ); + } }, ); @@ -75,8 +81,6 @@ class NotificationsController "Messages", largeIcon: icon == null ? null : FilePathAndroidBitmap(icon.path), ), - iOS: .new(), - macOS: .new(), linux: .new( customHints: icon == null ? null @@ -87,6 +91,9 @@ class NotificationsController ), ], ), + // TODO: See if icons can be added to iOS, macOS, and Windows notifications (#68) + iOS: .new(), + macOS: .new(), windows: .new(), ); diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index 0698bda..1e90114 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -3,15 +3,19 @@ import "dart:io"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:intl/intl.dart"; +import "package:nexus/controllers/key.dart"; import "package:nexus/controllers/notifications.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/main.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/models/content/message.dart"; +import "package:nexus/models/content/sticker.dart"; +import "package:nexus/models/requests/get_event.dart"; import "package:nexus/models/requests/register_pusher.dart"; import "package:unifiedpush/unifiedpush.dart"; import "package:unifiedpush_storage_shared_preferences/storage.dart"; +import "package:window_manager/window_manager.dart"; class UnifiedPushController extends AsyncNotifier { @override @@ -23,32 +27,39 @@ class UnifiedPushController extends AsyncNotifier { storage: UnifiedPushStorageSharedPreferences(), background: isInBackground, ), - onNewEndpoint: (endpoint, instance) async { - await client.registerPusher( - .new( - appDisplayName: "Nexus", - appId: "nexus.federated.nexus", - data: PusherData.webPush( - url: Uri.parse(endpoint.url), - auth: endpoint.pubKeySet!.auth, - ), - deviceDisplayName: - "Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}", - kind: .webPush, - lang: "en", - pushKey: endpoint.pubKeySet!.pubKey, + onNewEndpoint: (endpoint, instance) => client.registerPusher( + .new( + appDisplayName: "Nexus", + appId: "nexus.federated.nexus", + data: PusherData.webPush( + url: Uri.parse(endpoint.url), + auth: endpoint.pubKeySet!.auth, ), - ); - }, + deviceDisplayName: + "Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}", + kind: .webPush, + lang: "en", + pushKey: endpoint.pubKeySet!.pubKey, + ), + ), onMessage: (message, instance) async { if (message.decrypted == false) { - // TODO: Handle more gracefully, show option to re-register pusher - throw Exception("Failed to decrypt notification"); + throw Exception( + "Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.", + ); } final event = await client.handlePush( json.decode(String.fromCharCodes(message.content)), ); + if (event.unreadType?.shouldNotify != true || + (!isInBackground && + await windowManager.isFocused() && + ref.watch(KeyController.provider(KeyController.roomKey)) == + event.roomId)) { + return; + } + final room = ref.read( RoomsController.provider.select((rooms) => rooms[event.roomId]), ); @@ -64,14 +75,19 @@ class UnifiedPushController extends AsyncNotifier { .read(NotificationsController.provider.notifier) .send( id: event.eventId.hashCode & 0x7fffffff, - title: room?.metadata?.name ?? "New Message", + title: room?.metadata?.name ?? "New Event", icon: icon, + payload: json.encode( + GetEventRequest(eventId: event.eventId, roomId: event.roomId), + ), body: switch (event.content) { - MessageContent(:final body) => body, - // TODO: Handle more types + MessageContent(:final body?) || + StickerContent(:final body) => body, _ => null, }, ); + + if (isInBackground) exit(0); }, onRegistrationFailed: (error, instance) => throw error, onUnregistered: (instance) => ref.invalidateSelf(), From 69959b9e11d13d4ca24fdebde274adacc28a4d2a Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 26 Aug 2026 16:27:43 -0400 Subject: [PATCH 27/29] add wip deregistering --- lib/controllers/client.dart | 17 ++++-- lib/controllers/notifications.dart | 19 ++++--- lib/controllers/settings_sections.dart | 60 ++++++++++++++-------- lib/controllers/unified_push.dart | 28 +++++++--- lib/models/requests/deregister_pusher.dart | 13 +++++ lib/pages/notifications.dart | 14 +++++ lib/widgets/appbar.dart | 2 +- 7 files changed, 113 insertions(+), 40 deletions(-) create mode 100644 lib/models/requests/deregister_pusher.dart create mode 100644 lib/pages/notifications.dart diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 8f6e6ad..b8e060d 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -22,6 +22,7 @@ import "package:nexus/models/event.dart"; import "package:nexus/models/oauth_auth_code_response.dart"; import "package:nexus/models/open_graph_data.dart"; import "package:nexus/models/paginate.dart"; +import "package:nexus/models/requests/deregister_pusher.dart"; import "package:nexus/models/requests/download_media.dart"; import "package:nexus/models/requests/get_event.dart"; import "package:nexus/models/requests/get_related_events.dart"; @@ -180,12 +181,14 @@ class ClientController extends AsyncNotifier { return json; } - Future handlePush(Map data) async => .fromJson( - await callGomuksMethod( + Future handlePush(Map data) async { + final response = await callGomuksMethod( data, (handle, data) async => GomuksHandlePush(handle, data), - ), - ); + ); + + return response == null ? null : .fromJson(response); + } Future _sendCommand( String command, [ @@ -300,6 +303,12 @@ class ClientController extends AsyncNotifier { Future registerPusher(RegisterPusherRequest request) => _sendCommand("register_homeserver_push", request.toJson()); + Future deregisterPusher(DeregisterPusherRequest request) => + _sendCommand("register_homeserver_push", { + ...request.toJson(), + "kind": null, + }); + Future uploadMedia(UploadMediaRequest request) async => .fromJson(await _sendCommand("upload_media", request.toJson())); diff --git a/lib/controllers/notifications.dart b/lib/controllers/notifications.dart index d9bcd8f..bd80467 100644 --- a/lib/controllers/notifications.dart +++ b/lib/controllers/notifications.dart @@ -1,11 +1,12 @@ -import "dart:convert"; import "dart:io"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/main.dart"; import "package:flutter_local_notifications/flutter_local_notifications.dart"; // ignore: implementation_imports import "package:flutter_local_notifications_linux/src/model/hint.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/event.dart"; +import "package:nexus/pages/notifications.dart"; class NotificationsController extends AsyncNotifier { @@ -22,16 +23,20 @@ class NotificationsController appUserModelId: "nexus.federated.nexus", guid: "dde78daf-130f-4e46-a80a-e31deeab45d7", ), - android: .new("ic_launcher"), + android: .new("ic_launcher_foreground"), iOS: darwin, macOS: darwin, linux: .new(defaultActionName: "Open"), ), onDidReceiveNotificationResponse: (details) { - if (details.payload case final payload?) { - final event = ref.watch( - EventController.provider(.fromJson(json.decode(payload))), - ); + if (details.payload case final eventId?) { + if (navigatorKey.currentContext case final context?) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => NotificationsPage(eventId: eventId), + ), + ); + } } }, ); diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index 35753fe..1e60263 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -1,8 +1,9 @@ import "dart:io"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:material_ui/material_ui.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:intl/intl.dart"; import "package:m3e_buttons/m3e_buttons.dart"; import "package:nexus/controllers/account_data.dart"; @@ -105,14 +106,16 @@ class SettingsSectionsController .new( title: "Push notifications", description: "Enable push notifications using Web Push", - builder: (title, description, icon) => Consumer( + builder: (title, description, icon) => HookConsumer( builder: (context, ref, _) { + final loading = useState(false); final pusherRegistered = ref.watch( UnifiedPushController.provider, ); final unifiedPushAllowed = ref.watch( UnifiedPushAllowedController.provider, ); + return SwitchListTile( title: Text(title), subtitle: Text( @@ -127,34 +130,47 @@ class SettingsSectionsController data: (value) => value, orElse: () => false, ), - onChanged: - unifiedPushAllowed.maybeWhen( - data: (data) => data == null, - orElse: () => false, - ) + onChanged: loading.value + ? null + : unifiedPushAllowed.maybeWhen( + data: (data) => data == null, + orElse: () => false, + ) ? pusherRegistered.maybeWhen( data: (_) => (value) async { - if (value) { - if (await ref - .watch( - NotificationsController - .provider - .notifier, - ) - .requestPermissions()) { - ref + try { + loading.value = true; + if (value) { + if (await ref + .watch( + NotificationsController + .provider + .notifier, + ) + .requestPermissions()) { + await ref + .watch( + UnifiedPushController + .provider + .notifier, + ) + .register(); + } else { + // TODO: Handle not granted + } + } else { + await ref .watch( UnifiedPushController .provider .notifier, ) - .register() - .onError(showError); - } else { - // TODO: Handle not granted + .deregister(); } - } else { - // TODO: Deregister + } catch (error, stackTrace) { + showError(error, stackTrace); + } finally { + loading.value = false; } }, orElse: () => null, diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index 1e90114..f9d8f8f 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -11,7 +11,6 @@ import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/sticker.dart"; -import "package:nexus/models/requests/get_event.dart"; import "package:nexus/models/requests/register_pusher.dart"; import "package:unifiedpush/unifiedpush.dart"; import "package:unifiedpush_storage_shared_preferences/storage.dart"; @@ -52,9 +51,10 @@ class UnifiedPushController extends AsyncNotifier { json.decode(String.fromCharCodes(message.content)), ); - if (event.unreadType?.shouldNotify != true || + if (event == null || + event.unreadType?.shouldNotify != true || (!isInBackground && - await windowManager.isFocused() && + await windowManager.isFocused().onError((_, _) => false) && ref.watch(KeyController.provider(KeyController.roomKey)) == event.roomId)) { return; @@ -77,9 +77,7 @@ class UnifiedPushController extends AsyncNotifier { id: event.eventId.hashCode & 0x7fffffff, title: room?.metadata?.name ?? "New Event", icon: icon, - payload: json.encode( - GetEventRequest(eventId: event.eventId, roomId: event.roomId), - ), + payload: event.eventId, body: switch (event.content) { MessageContent(:final body?) || StickerContent(:final body) => body, @@ -128,6 +126,24 @@ class UnifiedPushController extends AsyncNotifier { if (!alreadyRegistered) ref.invalidateSelf(); } + Future deregister() async { + final clientState = ref.watch(ClientStateController.provider); + final key = await UnifiedPushStorageSharedPreferences().keys.get( + clientState!.deviceId!, + ); + + if (key == null) return; + + await ref + .watch(ClientController.provider.notifier) + .deregisterPusher( + .new(appId: "nexus.federated.nexus", pushKey: key.split("=").first), + ); + + await UnifiedPush.unregister(clientState.deviceId!); + ref.invalidateSelf(); + } + static final provider = AsyncNotifierProvider( UnifiedPushController.new, ); diff --git a/lib/models/requests/deregister_pusher.dart b/lib/models/requests/deregister_pusher.dart new file mode 100644 index 0000000..1416add --- /dev/null +++ b/lib/models/requests/deregister_pusher.dart @@ -0,0 +1,13 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "deregister_pusher.freezed.dart"; +part "deregister_pusher.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const DeregisterPusherRequest({ + required final String appId, + @JsonKey(name: "pushkey") required final String pushKey, +}) with _$DeregisterPusherRequest { + Map toJson() => _$DeregisterPusherRequestToJson(this); +} diff --git a/lib/pages/notifications.dart b/lib/pages/notifications.dart new file mode 100644 index 0000000..ceca514 --- /dev/null +++ b/lib/pages/notifications.dart @@ -0,0 +1,14 @@ +import "package:material_ui/material_ui.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/widgets/appbar.dart"; + +class const NotificationsPage({final String? eventId, super.key}) + extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: Appbar(title: Text("Notifications")), + body: Placeholder(), + ); + } +} diff --git a/lib/widgets/appbar.dart b/lib/widgets/appbar.dart index a6058ea..33727ac 100644 --- a/lib/widgets/appbar.dart +++ b/lib/widgets/appbar.dart @@ -39,7 +39,7 @@ final class const Appbar({ : (_) => windowManager.startDragging(), ), child: AppBar( - leading: InkWell(onTap: onTap, child: leading), + leading: leading == null ? null : InkWell(onTap: onTap, child: leading), backgroundColor: backgroundColor, scrolledUnderElevation: scrolledUnderElevation, actionsPadding: const .symmetric(horizontal: 8), From 06720238b854ddb70a82437966665a83ce311fad Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 10 Sep 2026 14:14:10 -0400 Subject: [PATCH 28/29] more reliable UP deregistration with proper cross-platform support --- lib/controllers/key.dart | 1 + lib/controllers/push_key.dart | 30 +++++++++++ lib/controllers/unified_push.dart | 62 ++++++++++++++--------- lib/controllers/unified_push_allowed.dart | 2 +- 4 files changed, 69 insertions(+), 26 deletions(-) create mode 100644 lib/controllers/push_key.dart diff --git a/lib/controllers/key.dart b/lib/controllers/key.dart index b78acf8..0d02d17 100644 --- a/lib/controllers/key.dart +++ b/lib/controllers/key.dart @@ -4,6 +4,7 @@ import "package:nexus/controllers/shared_prefs.dart"; class KeyController(final String key) extends Notifier { static const String spaceKey = "space"; static const String roomKey = "room"; + static const String pushKeyKey = "pushKey"; @override String? build() => diff --git a/lib/controllers/push_key.dart b/lib/controllers/push_key.dart new file mode 100644 index 0000000..b73aa89 --- /dev/null +++ b/lib/controllers/push_key.dart @@ -0,0 +1,30 @@ +import "dart:convert"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/key.dart"; + +class PushKeyController(final String instance) extends Notifier { + @override + String? build() => json.decode( + ref.watch(KeyController.provider(KeyController.pushKeyKey)) ?? "{}", + )[instance]; + + Future set(String? value) async { + final provider = KeyController.provider(KeyController.pushKeyKey); + final notifier = ref.watch(provider.notifier); + final prefs = IMap(json.decode(ref.watch(provider) ?? "{}")); + + state = value; + notifier.set( + json.encode( + prefs.add(instance, value).where((_, value) => value != null), + ), + ); + } + + static final provider = + NotifierProvider.family( + PushKeyController.new, + ); +} diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index f9d8f8f..cb42577 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -1,10 +1,12 @@ import "dart:convert"; import "dart:io"; +import "package:flutter/foundation.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:intl/intl.dart"; import "package:nexus/controllers/key.dart"; import "package:nexus/controllers/notifications.dart"; +import "package:nexus/controllers/push_key.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/main.dart"; import "package:nexus/controllers/client.dart"; @@ -26,21 +28,26 @@ class UnifiedPushController extends AsyncNotifier { storage: UnifiedPushStorageSharedPreferences(), background: isInBackground, ), - onNewEndpoint: (endpoint, instance) => client.registerPusher( - .new( - appDisplayName: "Nexus", - appId: "nexus.federated.nexus", - data: PusherData.webPush( - url: Uri.parse(endpoint.url), - auth: endpoint.pubKeySet!.auth, + onNewEndpoint: (endpoint, instance) async { + final pushKey = endpoint.pubKeySet!.pubKey; + ref.watch(PushKeyController.provider(instance).notifier).set(pushKey); + + await client.registerPusher( + .new( + appDisplayName: "Nexus", + appId: "nexus.federated.nexus", + data: PusherData.webPush( + url: Uri.parse(endpoint.url), + auth: endpoint.pubKeySet!.auth, + ), + deviceDisplayName: + "Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}", + kind: .webPush, + lang: "en", + pushKey: pushKey, ), - deviceDisplayName: - "Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}", - kind: .webPush, - lang: "en", - pushKey: endpoint.pubKeySet!.pubKey, - ), - ), + ); + }, onMessage: (message, instance) async { if (message.decrypted == false) { throw Exception( @@ -88,7 +95,12 @@ class UnifiedPushController extends AsyncNotifier { if (isInBackground) exit(0); }, onRegistrationFailed: (error, instance) => throw error, - onUnregistered: (instance) => ref.invalidateSelf(), + onUnregistered: (instance) async { + await ref + .watch(PushKeyController.provider(instance).notifier) + .set(null); + ref.invalidateSelf(); + }, ); if (registered) { @@ -128,17 +140,17 @@ class UnifiedPushController extends AsyncNotifier { Future deregister() async { final clientState = ref.watch(ClientStateController.provider); - final key = await UnifiedPushStorageSharedPreferences().keys.get( - clientState!.deviceId!, - ); + final key = ref.watch(PushKeyController.provider(clientState!.deviceId!)); - if (key == null) return; - - await ref - .watch(ClientController.provider.notifier) - .deregisterPusher( - .new(appId: "nexus.federated.nexus", pushKey: key.split("=").first), - ); + if (key != null) { + await ref + .watch(ClientController.provider.notifier) + .deregisterPusher(.new(appId: "nexus.federated.nexus", pushKey: key)); + } else { + debugPrint( + "No matching pushKey found. Skipping deregistration from homeserver.", + ); + } await UnifiedPush.unregister(clientState.deviceId!); ref.invalidateSelf(); diff --git a/lib/controllers/unified_push_allowed.dart b/lib/controllers/unified_push_allowed.dart index 4f925cf..af50d62 100644 --- a/lib/controllers/unified_push_allowed.dart +++ b/lib/controllers/unified_push_allowed.dart @@ -11,7 +11,7 @@ class UnifiedPushAllowedController extends AsyncNotifier { return "No valid distributors found. Try installing ${Platform.isLinux ? "KUnifiedPush" : Platform.isAndroid - ? "FCM or NTFY" + ? "Google Play Services or NTFY" : "NTFY"}."; } From 7792a1aae44f939103a506920c2d2a43a4ac63e2 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 10 Sep 2026 15:07:17 -0400 Subject: [PATCH 29/29] bug fixes --- lib/controllers/push_key.dart | 2 +- lib/controllers/unified_push.dart | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/controllers/push_key.dart b/lib/controllers/push_key.dart index b73aa89..7217886 100644 --- a/lib/controllers/push_key.dart +++ b/lib/controllers/push_key.dart @@ -18,7 +18,7 @@ class PushKeyController(final String instance) extends Notifier { state = value; notifier.set( json.encode( - prefs.add(instance, value).where((_, value) => value != null), + prefs.add(instance, value).where((_, value) => value != null).unlock, ), ); } diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index cb42577..1c1b41a 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -30,7 +30,9 @@ class UnifiedPushController extends AsyncNotifier { ), onNewEndpoint: (endpoint, instance) async { final pushKey = endpoint.pubKeySet!.pubKey; - ref.watch(PushKeyController.provider(instance).notifier).set(pushKey); + await ref + .watch(PushKeyController.provider(instance).notifier) + .set(pushKey); await client.registerPusher( .new(