Allow swapping UnifiedPush distributor

Fixes #120
This commit is contained in:
Henry Hiles 2026-10-14 16:41:01 -04:00
commit 3a33bf7bfe
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
5 changed files with 86 additions and 29 deletions

View file

@ -15,6 +15,8 @@ import "package:nexus/controllers/settings.dart";
import "package:nexus/controllers/unified_push.dart"; import "package:nexus/controllers/unified_push.dart";
import "package:nexus/controllers/spec_versions.dart"; import "package:nexus/controllers/spec_versions.dart";
import "package:nexus/controllers/unified_push_allowed.dart"; import "package:nexus/controllers/unified_push_allowed.dart";
import "package:nexus/controllers/unified_push_distributors.dart";
import "package:nexus/helpers/extensions/distributor_name.dart";
import "package:nexus/models/account_data.dart"; import "package:nexus/models/account_data.dart";
import "package:nexus/models/settings_category.dart"; import "package:nexus/models/settings_category.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
@ -138,7 +140,7 @@ class SettingsSectionsController
settings: .new([ settings: .new([
.new( .new(
title: "Push notifications", title: "Push notifications",
description: "Enable push notifications using Web Push", description: "Enable push notifications using Web Push, and choose which UnifiedPush distributor to use.",
builder: (title, description, icon) => HookConsumer( builder: (title, description, icon) => HookConsumer(
builder: (context, ref, _) { builder: (context, ref, _) {
final loading = useState(false); final loading = useState(false);
@ -148,9 +150,14 @@ class SettingsSectionsController
final unifiedPushAllowed = ref.watch( final unifiedPushAllowed = ref.watch(
UnifiedPushAllowedController.provider, UnifiedPushAllowedController.provider,
); );
final distributors = ref.watch(
UnifiedPushDistributorsController.provider,
);
final current = pusherRegistered.value;
return SwitchListTile( return DialogListTile<String?>(
title: Text(title), key: ValueKey(current),
title: title,
subtitle: Text( subtitle: Text(
unifiedPushAllowed.maybeWhen( unifiedPushAllowed.maybeWhen(
data: (data) => data, data: (data) => data,
@ -158,11 +165,18 @@ class SettingsSectionsController
) ?? ) ??
description, description,
), ),
secondary: Icon(icon), icon: Icon(icon),
value: pusherRegistered.maybeWhen( required: false,
data: (value) => value, initialValue: current,
orElse: () => false, options: [
), null,
...?distributors.value,
if (current != null &&
distributors.value?.contains(current) != true)
current,
],
getName: (option) =>
option == null ? "Off" : option.distributorName,
onChanged: loading.value onChanged: loading.value
? null ? null
: unifiedPushAllowed.maybeWhen( : unifiedPushAllowed.maybeWhen(
@ -171,9 +185,10 @@ class SettingsSectionsController
) )
? pusherRegistered.maybeWhen( ? pusherRegistered.maybeWhen(
data: (_) => (value) async { data: (_) => (value) async {
if (value == current) return;
try { try {
loading.value = true; loading.value = true;
if (value) { if (value != null) {
if (await ref if (await ref
.watch( .watch(
NotificationController NotificationController
@ -187,7 +202,7 @@ class SettingsSectionsController
.provider .provider
.notifier, .notifier,
) )
.register(); .register(value);
} else { } else {
// TODO: Handle not granted // TODO: Handle not granted
} }

View file

@ -17,12 +17,12 @@ import "package:unifiedpush/unifiedpush.dart";
import "package:unifiedpush_storage_shared_preferences/storage.dart"; import "package:unifiedpush_storage_shared_preferences/storage.dart";
import "package:window_manager/window_manager.dart"; import "package:window_manager/window_manager.dart";
class UnifiedPushController extends AsyncNotifier<bool> { class UnifiedPushController extends AsyncNotifier<String?> {
Completer<void>? _endpoint; Completer<void>? _endpoint;
@override @override
Future<bool> build() async { Future<String?> build() async {
if (!Platform.isLinux && !Platform.isAndroid) return false; if (!Platform.isLinux && !Platform.isAndroid) return null;
final registered = await UnifiedPush.initialize( final registered = await UnifiedPush.initialize(
linuxOptions: .new( linuxOptions: .new(
@ -56,7 +56,7 @@ class UnifiedPushController extends AsyncNotifier<bool> {
), ),
); );
state = .data(true); state = .data(await UnifiedPush.getDistributor());
}); });
if (_endpoint case final completer? when !completer.isCompleted) { if (_endpoint case final completer? when !completer.isCompleted) {
@ -125,17 +125,25 @@ class UnifiedPushController extends AsyncNotifier<bool> {
onUnregistered: deregister, onUnregistered: deregister,
); );
if (!registered) return false; if (!registered) return null;
try { try {
return await register(); return await register();
} catch (error, stackTrace) { } catch (error, stackTrace) {
showError(error, stackTrace); showError(error, stackTrace);
return false; return null;
} }
} }
Future<bool> register() async { /// Registers with [distributor], or the current/default distributor if none
/// is given. Returns the distributor that was registered with.
Future<String?> register([String? distributor]) async {
if (distributor != null &&
state.value != null &&
state.value != distributor) {
await deregister();
}
state = .loading(); state = .loading();
try { try {
final deviceIdCompleter = Completer<String>(); final deviceIdCompleter = Completer<String>();
@ -163,7 +171,9 @@ class UnifiedPushController extends AsyncNotifier<bool> {
); );
} }
if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor()) { if (distributor != null) {
await UnifiedPush.saveDistributor(distributor);
} else if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor()) {
throw Exception("No UnifiedPush distributors found."); throw Exception("No UnifiedPush distributors found.");
} }
@ -185,10 +195,11 @@ class UnifiedPushController extends AsyncNotifier<bool> {
onTimeout: () => throw Exception("UnifiedPush registration timed out."), onTimeout: () => throw Exception("UnifiedPush registration timed out."),
); );
state = .data(true); final current = await UnifiedPush.getDistributor();
return true; state = .data(current);
return current;
} catch (_) { } catch (_) {
state = .data(false); state = .data(null);
rethrow; rethrow;
} }
} }
@ -213,10 +224,10 @@ class UnifiedPushController extends AsyncNotifier<bool> {
} }
await UnifiedPush.unregister(instance ?? clientState!.deviceId!); await UnifiedPush.unregister(instance ?? clientState!.deviceId!);
state = .data(false); state = .data(null);
} }
static final provider = AsyncNotifierProvider<UnifiedPushController, bool>( static final provider = AsyncNotifierProvider<UnifiedPushController, String?>(
UnifiedPushController.new, UnifiedPushController.new,
); );
} }

View file

@ -2,16 +2,16 @@ import "dart:io";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:unifiedpush/unifiedpush.dart"; import "package:nexus/controllers/unified_push_distributors.dart";
class UnifiedPushAllowedController extends AsyncNotifier<String?> { class UnifiedPushAllowedController extends AsyncNotifier<String?> {
@override @override
Future<String?> build() async { Future<String?> build() async {
if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor().timeout( final distributors = await ref.watch(
.new(seconds: 10), UnifiedPushDistributorsController.provider.future,
onTimeout: () => );
throw Exception("UnifiedPush distributor check timed out."),
)) { if (distributors.isEmpty) {
return "No valid distributors found. ${Platform.isLinux return "No valid distributors found. ${Platform.isLinux
? "Try installing KUnifiedPush" ? "Try installing KUnifiedPush"
: Platform.isAndroid : Platform.isAndroid

View file

@ -0,0 +1,26 @@
import "dart:io";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:unifiedpush/unifiedpush.dart";
class UnifiedPushDistributorsController extends AsyncNotifier<IList<String>> {
@override
Future<IList<String>> build() async {
if (!Platform.isLinux && !Platform.isAndroid) return .new();
return IList(
await UnifiedPush.getDistributors().timeout(
.new(seconds: 10),
onTimeout: () =>
throw Exception("UnifiedPush distributor check timed out."),
),
);
}
static final provider =
AsyncNotifierProvider.autoDispose<
UnifiedPushDistributorsController,
IList<String>
>(UnifiedPushDistributorsController.new);
}

View file

@ -0,0 +1,5 @@
extension DistributorName on String {
String get distributorName => startsWith("nexus.federated.nexus")
? "Google Play Services"
: split(".").last;
}