Compare commits

..
Author SHA1 Message Date
5ed6e5e083
slight cleanup
`onNewEndpoint` is still not getting called for some reason (help appreciated)
2026-08-23 22:13:38 -04:00
7218eb5195
add UnifiedPushAllowedController 2026-08-23 14:26:51 -04:00
fe871217b2
support for registering pushers 2026-08-23 14:09:53 -04:00
22 changed files with 386 additions and 22 deletions

View file

@ -11,6 +11,8 @@
"muks",
"prefs",
"unban",
"unredact"
"unifiedpush",
"unredact",
"webpush"
]
}

View file

@ -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

2
gomuks

@ -1 +1 @@
Subproject commit 9444dd293664c300c6e710bb6cddcd9c6cab365d
Subproject commit 6c8b2781e02f27b64f37735cba6634784f117b88

View file

@ -2,6 +2,7 @@ import "dart:ffi";
import "dart:io";
import "dart:isolate";
import "dart:math";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:ffi/ffi.dart";
import "package:flutter/foundation.dart";
@ -14,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";
@ -30,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";
@ -273,6 +276,9 @@ class ClientController extends AsyncNotifier<int> {
Future<void> setAccountData(SetAccountDataRequest request) =>
_sendCommand("set_account_data", request.toJson());
Future<void> registerPusher(RegisterPusherRequest request) =>
_sendCommand("register_homeserver_push", request.toJson());
Future<MessageContent> uploadMedia(UploadMediaRequest request) async =>
.fromJson(await _sendCommand("upload_media", request.toJson()));
@ -310,6 +316,10 @@ class ClientController extends AsyncNotifier<int> {
Future<SpecVersionsResponse> getSpecVersions() async =>
.fromJson(await _sendCommand("get_versions"));
Future<Capabilities> getCapabilities() async => Capabilities.fromJson(
(await _sendCommand("get_capabilities"))["capabilities"],
);
Future<Uri?> discoverHomeserver(Uri homeserver) async {
try {
final response = await _sendCommand("discover_homeserver", {

View file

@ -9,7 +9,9 @@ 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/controllers/unified_push_allowed.dart";
import "package:nexus/models/account_data.dart";
import "package:nexus/models/settings_category.dart";
import "package:nexus/main.dart";
@ -95,6 +97,62 @@ 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([
.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,
);
final unifiedPushAllowed = ref.watch(
UnifiedPushAllowedController.provider,
);
return SwitchListTile(
title: Text(title),
subtitle: Text(
unifiedPushAllowed.maybeWhen(
data: (data) => data,
orElse: () => null,
) ??
description,
),
secondary: Icon(icon),
value: pusherRegistered.maybeWhen(
data: (value) => value,
orElse: () => false,
),
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,
);
},
),
icon: Icons.notification_add,
),
]),
),
.new(
title: "Safety",
icon: Icons.gpp_good,
@ -166,6 +224,8 @@ class SettingsSectionsController
onPressed: () async {
Navigator.of(context)
.popUntil((route) => route.isFirst);
Navigator.of(context)
.popUntil((route) => route.isFirst);
await WidgetsBinding.instance.endOfFrame;

View file

@ -0,0 +1,92 @@
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<bool> {
@override
Future<bool> 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,
),
);
},
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<void> register([bool alreadyRegistered = false]) async {
final clientState = ref.watch(ClientStateController.provider);
if (clientState?.deviceId == null) ref.invalidateSelf();
final capabilities = await ref
.watch(ClientController.provider.notifier)
.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, bool>(
UnifiedPushController.new,
);
}

View file

@ -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<String?> {
@override
Future<String?> 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, String?>(
UnifiedPushAllowedController.new,
);
}

View file

@ -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<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
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<String> 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,
]),
),
)

View file

@ -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<String, Object?> toJson() => _$CapabilitiesToJson(this);
factory Capabilities.fromJson(Map<String, Object?> json) =>
_$CapabilitiesFromJson(json);
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const WebPush({required final bool enabled, final String? vapid})
with _$WebPush {
Map<String, Object?> toJson() => _$WebPushToJson(this);
factory WebPush.fromJson(Map<String, Object?> json) =>
_$WebPushFromJson(json);
}

View file

@ -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<String, Object?> toJson() => _$ClientStateToJson(this);

View file

@ -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 }

View file

@ -1,4 +1 @@
import "package:freezed_annotation/freezed_annotation.dart";
@JsonEnum()
enum MembershipAction { ban, kick, unban, invite }

View file

@ -1,4 +1 @@
import "package:freezed_annotation/freezed_annotation.dart";
@JsonEnum()
enum MembershipStatus { leave, invite, ban, join, knock }

View file

@ -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<String, Object?> 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<String, Object?> json) =>
_$PusherDataFromJson(json);
}
@JsonEnum(fieldRename: .snake)
enum PushFormat {
@JsonValue(null)
all,
eventIdOnly,
}
enum PusherKind {
http,
email,
@JsonValue("org.matrix.msc4174.webpush")
webPush,
}

View file

@ -13,6 +13,7 @@
#include <media_kit_video/media_kit_video_plugin.h>
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
#include <webcrypto/webcrypto_plugin.h>
#include <window_manager/window_manager_plugin.h>
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);

View file

@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
media_kit_video
screen_retriever_linux
url_launcher_linux
webcrypto
window_manager
)

View file

@ -1,5 +1,6 @@
#include "my_application.h"
#include <cstdlib>
#include <flutter_linux/flutter_linux.h>
#ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h>
@ -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.

View file

@ -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"))
}

View file

@ -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:

View file

@ -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

View file

@ -13,6 +13,7 @@
#include <media_kit_video/media_kit_video_plugin_c_api.h>
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
#include <webcrypto/webcrypto_plugin.h>
#include <window_manager/window_manager_plugin.h>
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"));
}

View file

@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
media_kit_video
screen_retriever_windows
url_launcher_windows
webcrypto
window_manager
)